做后端这些年,Redis限流基本是每个高并发项目绕不过去的坎。秒杀抢购、短信接口防刷、支付回调保护,哪一环没做好限流,线上流量尖峰一来就是事故。这篇文章把我的真实落地经验完整分享一下,从算法选型到Lua脚本实现,再到线上问题排查,适合正在做限流方案选型,或者已经把Redis限流跑起来但还觉得不稳定的朋友参考。
1. 什么时候真的需要Redis限流:从一次线上事故说起
1.1 一次秒杀活动把我打醒了
去年我接手了一个积分商城的秒杀项目,活动前压测一切正常,结果上线当天,用户直接涌进来。下单接口平时QPS也就500,活动开始瞬间冲到了5000。我没有给接口做任何限流,数据库连接池直接被干穿,客户端超时重试又放大了流量,最后整个服务都跟着挂了。复盘的时候最扎心的一个结论是:哪怕当时在最前面加一道最简单的Redis计数器限流,把QPS压到2000,数据库大概率也能扛住,不至于全站瘫痪。
从那以后,我就养成了一个习惯:凡是可能被外部流量直接打到的高危接口,先问自己三个问题——这个接口的容量上限是多少?峰值流量会不会超过这个上限?如果超过了,我是选择排队、拒绝,还是降级?想不清楚这三件事,就别谈上不上限流,因为上线就是赌运气。
1.2 本地限流和分布式限流的边界
很多团队早期会在代码里用Guava的RateLimiter做本地限流,一个进程一个令牌桶。单机部署的时候挺好用,性能极高,几乎零网络开销。但一旦服务水平扩展成多个实例,本地限流的命门就露出来了:每个实例都是独立计数,限流效果等于被实例数量稀释了。原本想限制1000 QPS,部署4个实例后实际可能放进来4000,保护作用瞬间失效。
Redis限流解决的就是这个分布式一致性问题。所有实例共享同一个计数器,计数逻辑收敛到Redis这个“总闸”上,多实例之间的流量控制才是真正统一的。代价也很直观:每次请求都至少多一次Redis往返,好在Redis本身的吞吐足够高,单实例能够支撑10万级QPS的读写,只要脚本写得简洁,性能完全够用。另一个容易忽略的点是Redis本身也有单点风险,所以生产环境至少是主从架构,限流相关脚本要打到主节点,保证计数的一致性。
2. 四种限流算法怎么选:固定窗口、滑动窗口、令牌桶、漏桶
2.1 固定窗口:最直观的计数器
固定窗口算法的思路非常简单:以时间为维度切分成固定的窗口,比如每分钟一个窗口,每个窗口维护一个计数器。用户请求来了,计数器加一,超过阈值直接拒绝,窗口结束自动归零。用Redis实现的话,就是一条命令的事:INCR一个带时间戳的key,第一次INCR时顺便设置过期时间。
这个方案最大的优势是“足够简单”,简单到几乎不可能写错。但它的缺陷同样明显:存在临界突刺问题。假设阈值为每分钟100次,在59分59秒这个窗口已经积累了100次请求,在60分00秒新窗口立刻重新计数,同样可以瞬间通过100次。看起来每个窗口都没超,但两个窗口交界处的一两秒内,实际放进来了接近200次请求。对于保护数据库这种对瞬时尖刺敏感的场景,这个误差是可能致命的。
那是不是固定窗口就没用了?也不全是。如果业务本身对瞬时波动容忍度较高,或者阈值本身留有余量,固定窗口依然是一种性价比很高的选择。比如我们将阈值设为容量的60%,即使出现窗口边界的双倍请求,实际也不会打爆后端。
2.2 滑动窗口:解决临界突刺
滑动窗口是对固定窗口的一种修正。它不再用整数时间窗口切分,而是把每个请求的时间戳都记录到一个有序集合里,判断当前时刻往前推一个时间窗口(比如60秒)内,总请求数是否超过了阈值。每次请求来的时候,先把窗口之外的旧记录清除,再把当前请求加入,最后统计集合大小,如果超过阈值就拒绝。
我在项目里通常用Redis的ZSET来做这件事,直接使用ZREMRANGEBYSCORE清理过期成员,ZADD记录当前请求时间戳,ZCARD获取窗口内总数。几个命令组合起来,精度比固定窗口高了一个量级,能真正把流量限制在一个连续的时间区间里。
代价是内存开销。每个请求都要在ZSET里保存一个成员,高并发场景下这个集合会迅速膨胀。所以我会配合PEXPIRE给ZSET设置一个略大于窗口的过期时间,同时定期清理过期成员。如果能接受毫秒级的时间精度误差,这个方案是目前权衡下来最实用的一种。
2.3 令牌桶:允许突发,匀速限流
令牌桶的思路是:系统以恒定的速率往桶里投放令牌,每个请求过来需要消耗一个令牌。桶有容量上限,满了之后令牌不再增加。好处非常明显——既能应对突发流量(桶里攒下的令牌可以一次性消耗),又能长期维持一个稳定的平均速率。比如桶容量是200,生成速率是每秒50个,那么短时间内可以放行200个突发请求,随后平滑到每秒50个。
Redis里实现令牌桶,我习惯用一个Hash结构保存两个字段:当前令牌数和上次补充时间。每次请求时,根据当前时间与上次补充时间的差值,计算出这段时间新增的令牌数,然后按需消耗。这个算法的精度依赖令牌补充计算,所以需要在Lua里使用Redis的TIME命令获取服务端时间,防止应用服务器时钟漂移导致的误差。
令牌桶是目前使用范围最广的限流模型,不少开源网关的默认限流插件底层就是它。很多场景下,我们既希望允许用户突发点击,又不希望突发流量淹没后端,令牌桶是唯一能同时满足这两个诉求的算法。
2.4 漏桶:强制平滑输出
漏桶算法和令牌桶相反:请求像水滴一样滴进一个桶里,桶底有个小孔,以固定速率把水漏走。如果桶满了,新的请求直接被丢弃。它强制了输出速率的绝对恒定,后端看到的流量完全是一条直线,不会有一点波澜。
但从用户角度来说,漏桶不太友好:即使后端有富余的处理能力,请求也得在桶里排队,缺乏突发处理能力。实际业务里我很少单独用漏桶,只有在保护下游系统(比如对外调用第三方支付接口,对方有严格的TPS限制)时才会考虑。大多数情况下,令牌桶的平滑能力已经足够,漏桶那种“绝对平均”的削峰反而不适合用户体验要求高的场景。
2.5 选型速查表:结合业务性质的权衡
理论说完了,落地时到底选哪个?我给自己定了一张速查表。如果业务能容忍边界突刺、实现要最快,选固定窗口;如果用户操作密集、需要精确控制时间窗口内的总量,比如抽奖、投票,选滑动窗口;如果既要限流又要允许用户短时间多次操作,比如浏览商品、点击按钮,选令牌桶;如果下游是第三方强约束接口,漏桶优先。
| 算法 | 实现复杂度 | 内存开销 | 突发处理 | 平滑度 | 典型场景 |
|---|---|---|---|---|---|
| 固定窗口 | 最低 | 极低 | 差(有临界双倍风险) | 差 | 简单防刷、粗粒度控制 |
| 滑动窗口 | 中 | 较高(ZSET成员多) | 中 | 中 | 投票、抽奖、接口防刷 |
| 令牌桶 | 中 | 低(Hash固定两个字段) | 好 | 好 | 秒杀、下单、API网关 |
| 漏桶 | 中 | 低 | 差 | 极好 | 第三方调用保护 |
3. 基于Redis+Lua的限流落地实现:核心脚本和参数计算
3.1 Redis为什么适合干这活
限流本质上是一个高并发读写的场景,对存储的要求就三点:低延迟、高吞吐、支持原子操作。Redis是单线程模型,所有命令串行执行,天然不存在并发竞争问题。但需要注意的一点是,限流往往由多条命令组合完成,比如INCR之后判断是否超过阈值,这种场景如果分开执行,就会有竞态问题——多个请求同时读到旧值然后同时放行,限流就失效了。
解决方式就是用Lua脚本把多条命令封装成一个原子操作。Redis从2.6版本开始支持Lua脚本,执行期间不会被其他命令插队,整个脚本要么全部执行,要么全部不执行,不会存在中间状态。这也是我在所有限流实现里都坚持用Lua脚本的原因。安装方面不需要额外组件,标准Redis就自带这个能力,5.0以上版本用EVALSHA缓存脚本也很方便。
3.2 固定窗口限流:一个INCR加EXPIRE搞定
固定窗口的Lua脚本是我写过的所有脚本里最轻量的一版。逻辑很简单:对指定key执行INCR,如果count等于1说明是新窗口的第一个请求,这时设置过期时间;如果count已经超过limit,直接返回0,否则返回1。
local key = KEYS[1] local limit = tonumber(ARGV[1]) local count = redis.call('INCR', key) if count == 1 then redis.call('EXPIRE', key, ARGV[2]) end if count > limit then return 0 end return 1这里有一点必须提醒:过期时间不能省。如果不设置EXPIRE,Redis里会堆满永久有效的计数器key,内存最终会被耗尽。另一个坑是第一次INCR和EXPIRE必须放在同一个Lua脚本里,不能先用INCR命令后单独用EXPIRE命令,否则在极端并发下可能有一部分请求的key永远不会设置过期时间。
调用这个脚本的Java代码也不复杂,我一般会先对脚本做SHA缓存,通过SHA值调用,减少脚本传输开销:
public boolean tryFixedWindow(String key, int limit, int windowSeconds) { Long result = redisTemplate.execute( fixedWindowScript, Collections.singletonList(key), String.valueOf(limit), String.valueOf(windowSeconds) ); return result != null && result == 1L; }实际用下来这个方案单次耗时在1毫秒以内,扣掉网络开销后,性能非常充沛。适合用在比如验证码发送频率、用户登录失败次数这类粗粒度控制上。
3.3 令牌桶限流:Hash存储的灵活实现
令牌桶的Lua脚本比固定窗口复杂一些,但也没有想象中那么难。我使用一个Hash结构的key保存两个字段:tokens代表当前桶内剩余令牌数,lastRefill代表上次补充令牌的时间戳。每次请求时先根据时间差计算新增令牌,再判断是否满足消耗条件。
local key = KEYS[1] local rate = tonumber(ARGV[1]) local capacity = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local data = redis.call('HGETALL', key) local tokens = capacity local lastRefill = now if #data > 0 then tokens = tonumber(data[2] or capacity) lastRefill = tonumber(data[4] or now) end local delta = math.max(0, (now - lastRefill) / 1000) tokens = math.min(capacity, tokens + delta * rate) local allowed = 0 if tokens >= 1 then tokens = tokens - 1 allowed = 1 end redis.call('HSET', key, 'tokens', tokens, 'lastRefill', now) redis.call('EXPIRE', key, 3600) return allowed注意now这个参数,最好在Java端通过Redis的TIME命令获取,而不是直接使用System.currentTimeMillis()。因为应用服务器的系统时间可能有几十毫秒甚至几秒的偏差,限流的精度会受影响。通过TIME命令取的是Redis服务器自身的时间,全局所有请求用的是同一个时钟,窗口划分才公平。
3.4 滑动窗口限流:ZSET精确到毫秒级
滑动窗口脚本的核心是对ZSET的操作。窗口开始时间等于当前时间减去窗口长度,先用ZREMRANGEBYSCORE把窗口之外的旧请求清掉,再统计集合中的元素个数。如果窗口内请求数小于阈值,就ZADD一条当前请求记录。
local key = KEYS[1] local windowMs = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs) local count = redis.call('ZCARD', key) if count < limit then redis.call('ZADD', key, now, now .. '_' .. math.random(0, 100000000)) redis.call('PEXPIRE', key, windowMs) return 1 end return 0这里有个细节:ZSET成员不能重复,所以member我用“时间戳_随机数”拼接,确保同一个毫秒内的不同请求也能各自入集合。如果不拼随机数,两个请求同一毫秒到达时,ZADD会认为是同一个member,导致计数偏少,限流效果失真。
内存上滑动窗口比前面两个算法费得多,因为每个请求都要在集合里留一条记录。我一般会在PEXPIRE里设置和窗口长度相同的过期时间,这样集合会在窗口滑过之后自动被回收。如果需要更激进的清理,也可以在每次请求时额外执行ZREMRANGEBYSCORE,把过期成员立即删除——这就是脚本里第一行做的事。
3.5 参数计算:阈值、速率和容量怎么定
限流参数是很多团队拍脑袋定的,这是最常见的坑。参数计算不能脱离容量评估。我的做法分三步:第一步,压测出下游系统的真实处理上限。比如下单接口依赖数据库,通过全链路压测可以得出数据库连接池不被打爆时的最大QPS,这个值就是基准容量C。第二步,留出安全余量,一般按C的70%到80%设置限流阈值。因为线上流量不是均匀的,还需要给GC暂停、网络抖动、数据库主从延迟等留出缓冲。第三步,根据业务容忍度确定突发窗口。
举个例子。某下单接口压测得出数据库能扛2000 QPS,我设置固定窗口限流阈值是1500 QPS,每个窗口60秒,那么脚本里的limit就是90000。如果是令牌桶,rate设为每秒1500,capacity设为7500,相当于允许5秒钟的突发积压。这个配置既保证长期平均流量不超过数据库容量的75%,又允许用户短时间内集中点击。
4. 限流从单点到全局:业务集成与防线设计
4.1 用拦截器还是注解AOP
Lua脚本本身只是一个“裁判”,它还缺一个合理的触发入口。我在Spring Boot项目里尝试过两种集成方式:HandlerInterceptor和自定义注解加AOP。小规模项目用拦截器最简单,按URL前缀匹配需要限流的接口,在preHandle里执行Lua脚本,返回false时直接输出一个统一提示。
但拦截器的粒度比较粗,不同接口限流规则不同时,配置会埋得到处都是。后来我改成了注解方式:定义@RateLimit注解,标注limit、window和key表达式,比如userId取自参数,接口路径自动拼接。通过AOP切面统一处理注解逻辑,代码可读性好很多,新接口限流只需要加一行注解。代价是多一次动态代理调用,性能影响基本可以忽略。
@RateLimit(limit = 100, window = 60, key = "order:create") @PostMapping("/order/create") public Result createOrder(@RequestBody OrderRequest req) { // 业务逻辑 }AOP切面里做的事情顺序是:先解析注解,拼出完整的Redis key,执行对应Lua脚本,返回值为0时抛出限流异常,由全局异常处理器转换成语义明确的提示。整个流程对业务代码完全透明,后面接监控也好做。
4.2 用户维度、接口维度还是全局维度
限流key怎么设计,直接决定了限流效果的准确性。最常见的三种维度:用户维度、接口维度、全局维度。
用户维度是把用户ID拼进key里,用来限制单个用户的操作频率,比如一个用户一分钟最多发送5次验证码。这种key天然是分散的,热度均匀分布在所有用户上,对Redis单key压力较小。接口维度是把接口名或URL作为key,限制整个接口的总调用量,比如秒杀接口全局限流2000 QPS。这种key是典型的热点key,因为所有用户请求都打在同一个key上,需要时刻关注Redis单实例的处理能力。全局维度则是对整个系统的入口流量做总量控制,通常放在网关层。
实际项目里这三种维度不是互斥的,而是叠加使用。我在网关层对全局流量做一次令牌桶限流,在业务层对用户维度做滑动窗口限流,在核心接口上再做一次接口维度限流。多道防线层层递减,即使某一层配置失误,后面还有兜底。
4.3 网关层限流和业务层限流的配合
很多团队会纠结限流到底做在网关还是业务代码里。我的观点是:不要把二者对立,各司其职。网关层(比如Nginx或Spring Cloud Gateway)承担的是入口保护,拦截掉无效流量,保护整个集群不再被攻击型流量冲击。业务层的Redis限流则更精细,可以结合用户身份、订单状态等业务字段做个性化控制。
在Nginx层面可以用自带的limit_req模块做IP维度限流,但IP限流在真实场景里很容易误伤——一个办公楼里几十个人共享出口IP,一人触发限流全体遭殃。所以IP限流只适合作为最外层的粗坝,阈值放宽到正常流量峰值的1.5倍以上。真正的精细化限流一定是在业务层识别用户维度后精确控制的,这也是Redis限流在体系里不可替代的原因。
4.4 限流触发后的动作:拒绝、排队还是降级
限流不只是“拒绝请求”,它应该是一个完整的策略组合。我常用四种触发动作:直接拒绝、排队等待、服务降级、旧数据兜底。直接拒绝适合写操作,比如下单重复提交;排队等待适合非实时请求,比如导出报表,把请求丢进消息队列慢慢处理;服务降级适合非核心功能,比如商品详情页的实时库存,限流后可以切换到缓存数据;旧数据兜底适合只读场景,返回上一次成功结果,用户无感。
设计触发动作时一定要结合业务场景,不要一刀切地返回“系统繁忙”。有一次我把某个详情页限流失配了,直接返回错误码,结果用户全部刷新页面,流量反而翻倍。后来改成返回缓存副本,问题立刻缓解。限流的最终目的是保护系统,用户体验是第二目标,但如果能同时兼顾,为什么不呢?
5. 线上踩坑实录与排查方法
5.1 原子性踩坑:两条命令差点毁了限流
有一段时间我的固定窗口限流没有用Lua,是先INCR再EXPIRE,单独两条Redis命令执行。逻辑上看着没问题,但高并发下出现了诡异现象:部分请求没限制住,QPS直接突破了阈值。排查后发现问题出在INCR和EXPIRE之间发生了线程切换——别的请求插队执行了INCR,然后好几个请求都认为是自己第一个触达窗口,都没设置过期时间。
那次之后我形成了两条铁律:凡是多个Redis命令组成一个业务动作的,全部用Lua脚本封装;凡是限流脚本里涉及判断、清除、写入的,绝不拆成多次往返执行。踩过这个坑之后,我再看市面上各种限流组件源码,重点也都会看它是否保证原子性。这一点,远比算法选得多精妙更重要。
5.2 Redis不可用时的降级策略
Redis是限流的核心依赖,但Redis自身也可能出问题,比如主从切换期间短暂不可用、连接池耗尽、网络分区等。如果限流组件在Redis异常时直接放行请求,那么保护层就消失了,系统裸奔;反过来,如果Redis异常时拒绝所有请求,系统直接全站瘫痪。两种极端我都不选。
我的降级策略是三级递进:第一级,Redis连接失败时快速失败,限流组件自动切换为本地令牌桶(用Guava),虽然各实例不共享计数,但至少能挡住一部分流量;第二级,本地限流也失效时,默认拒绝高风险写接口的请求,对于只读接口默认放行并辅以缓存;第三级,服务自己内存和CPU都异常时,触发全局限流开关,直接拒绝流量,保护进程不崩溃。降级逻辑写在限流切面里,不侵入业务代码,并且用独立的监测线程感知Redis恢复状态,恢复后自动切回主限流链路。
5.3 阈值拍脑袋引发的误杀事故
有次运营活动上线,我按自己预估的500 QPS配置了限流阈值,结果活动还没开始预热流量就到了800 QPS,大量真实用户被误杀,投诉电话被打爆。后来我仔细算了一下,当天正常访问量就有300 QPS,活动预热叠加后达到800,而我的阈值只留了1.67倍余量,根本没有考虑活动因素。
这次教训让我改变了参数确定方式:不再自己拍脑袋,而是让数据说话。上线前用全链路压测摸清容量上限,再结合历史监控数据看平时的流量基线和峰值曲线,最终阈值取max(峰值流量 * 1.2,容量上限 * 0.75)。活动类场景还要单独评估预热阶段和峰值阶段两档配置,通过配置中心动态调整,而不是改代码重启服务。
5.4 热Key和脚本性能:单key扛不住怎么办
接口维度的限流key天然是热Key,比如秒杀活动的大盘key,每秒几万请求同时打在一个key上,虽然Redis单实例处理能力足够强,但仍有两点需要警惕:网络带宽和CPU消耗。Lua脚本执行效率很高,但在极高并发下,单个Redis实例的CPU也可能成为瓶颈。
我的应对方式是分层限流:网关层先用Nginx的limit_req做第一层拦截,把流量降到Redis限流阈值的两倍以内;业务层Redis限流做精确控制。这样Redis单key承受的QPS压力大幅下降。如果单实例Redis还是扛不住,还可以对限流key做分片,比如按用户ID哈希分配到多个Redis key上,再在网关做一次总量兜底。分片会有一定的限流误差,实测下来误差在5%以内,对业务影响可控。
5.5 监控与排错:限流了还是有故障,如何快速定位
限流系统上线后没有监控等于盲飞。我维护了一套三层监控指标:第一层是限流维度,包括触发次数、放行次数、当前限流阈值,直接从Lua脚本里通过INCR独立的统计key获得,比如每秒触发次数;第二层是Redis性能维度,监控Info命令的qps、内存、慢查询数量,提前发现Redis本身的瓶颈;第三层是业务影响维度,比如下单接口的拒绝率和错误率变化,判断限流配置是否过严。
排查限流故障时有一个经典手段:开启Redis MONITOR命令观察实时命令流,看限流key的INCR、EVALSHA执行频率是否异常。但这命令很重,生产环境只能在低峰期短时间开启。更稳妥的方案是用SLOWLOG查慢日志,以及用Redis 5.0以上版本的COMMAND STATS看Lua脚本的平均执行时间和调用次数。脚本执行时间和调用次数一旦出现突变,基本可以锁定问题出在限流脚本本身还是下游数据量异常。
我个人在实际操作中有个感受:限流不是一次性配置完就结束的东西,它需要持续的调参、演练和监控。每次上线大促前,我都会把限流脚本在测试环境重新压一遍,确认延时没有随着数据量增长而劣化。线上限流的配置也通过配置中心管理,改参数不需要发版。这样一套下来,Redis限流才能真正从一个“救命工具”变成系统里的常驻防火墙。最后再分享一个小习惯:每次限流策略调整后,备份一份当时的压测数据和参数快照。等到下次复盘或者同类新项目启动时,这些历史数据比任何文档都管用,能帮你少走非常多的弯路。