有一阵子我盯监控的时候总有一种错觉,服务没什么异常,QPS也稳定,可总有用户投诉页面卡。后来查了日志才发现,是活动接口被脚本刷爆了,而当时的“限流模块”用的还是代码里的本地锁,客户端一多就直接失效。那次之后我花了一周时间把整套逻辑迁到了 Redis 上,重写了 Redis 限流策略,才真正把流量入口管住。这篇文章就把我这套方案从算法选型、实现细节、上线验证到线上排查的完整过程聊透,适合正在做接口保护、打算自研轻量限流能力的后端同学参考,不扯大理论,尽量给能直接落地的方案。
1. 限流算法选型:先把要限的东西想清楚
1.1 别急着写代码,先想清楚限流的维度
很多同学一说限流就直接上代码,这其实是最大的坑。限流之前必须先回答一个问题:限的是“什么维度”的什么指标?
- 全局维度:整个服务每秒最多接受多少请求,这是最基础的 QPS 限流。
- 用户维度:某个用户每分钟最多调多少次接口,防止单用户刷爆资源。
- 接口维度:某个敏感接口(比如发送验证码、下单、支付回调)的独立阈值。
- 并发维度:同时处理的任务数上限,比如线程池队列打满就触发拒绝策略。
我遇到的那个活动页面,问题是脚本在短时间内多线程并发刷接口,本地锁根本拦不住。第一反应是加全局限流,但仔细一想,不能把正常用户也误伤了。所以最终方案是两个维度叠加:全局按 QPS 粗粒度拦截,用户维度按“每分钟调用次数”精细管控。这个思路比单纯给某个接口定一个数字要合理得多。
无论定哪个维度,都需要明确一个核心原则:限流是保护手段,不是惩罚机制。阈值的设定必须基于服务的实际承载能力,而不是拍脑袋定一个吉利数字。
1.2 固定窗口和滑动窗口的取舍
限流算法里最常用的是固定窗口。把时间切成固定大小的片段,比如 60 秒一个窗口,每个窗口内允许 100 次请求,窗口一过计数器清零。这个思路实现极其简单,用 Redis 的 INCR 加 EXPIRE 就能做。
但这个方案有个非常经典的问题:临界突刺。假设窗口是每分钟 100 次,请求在 9:59:59 发了 100 次,在 10:00:00 又发了 100 次,两个窗口各自都只需要 100 次,但实际系统在 1 秒内扛了 200 个请求。对于扛 100 QPS 的系统来说,这就是雪崩的开始。
滑动窗口解决了这个问题。它不再用固定边界,而是统计“从当前时刻往前推一个完整时间段”内的请求数。比如现在是 10:00:30,它统计的就是 9:59:30 到 10:00:30 这 60 秒内的所有请求。只要有请求进入,窗口就自动滑动,临界突刺被自然抹平。
滑动窗口在 Redis 里的经典实现是用 ZSET,以请求时间作为 score,每次请求前先清理超时的记录,再统计 ZSET 大小。这样做精度高,但代价是内存占用大、操作复杂。所以我的建议是:对精度要求高的用户维度限流用滑动窗口,对全局粗粒度限流,固定窗口足够。
1.3 漏桶和令牌桶:两种完全不同的节奏
漏桶和令牌桶经常被放在一起说,其实它们的性格完全相反。
漏桶的核心理念是“匀速排队”。水流进来多快无所谓,桶底漏水的速度是恒定的,超出的水就排队等待或直接丢弃。漏桶天然适合做平滑速率控制,比如消息队列的消费速率、任务调度频率。缺点也很明显:漏桶不允许任何突发,即使系统有闲置能力,也只能按固定速率跑。
令牌桶正好相反。它允许一定程度的突发。桶里有 N 个令牌,每隔一段时间补充一个令牌,请求来了先抢一个令牌,抢到就放行,抢不到就拒绝。假设系统平时只跑 100 QPS,但某次运营活动有 3 秒的流量洪峰,如果桶容量设置为 300,这 3 秒的突发请求就不能被直接丢弃,令牌桶可以让它们“借未来”。Guava 的 RateLimiter 底层就是令牌桶思想。
Redis 实现令牌桶的关键在于:不需要真的定时任务去补令牌。只需要记录当前时间戳和令牌数,每次请求到来时,根据时间差算出这段时间应该补充多少令牌。这样省掉了后台任务,逻辑上也足够精确。
1.4 四种算法的选型对照
| 算法 | 核心思路 | Redis 实现成本 | 是否允许突发 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 固定时间切片,每片独立计数 | 低,INCR + EXPIRE 即可 | 否,但临界点可能突刺 | 全局粗粒度 QPS 限制 |
| 滑动窗口 | 统计当前时刻往前推一个完整时间段内的请求数 | 中,ZSET 操作有内存代价 | 否 | 用户维度频控、抢券校验 |
| 漏桶 | 请求以任意速率进入,以固定速率流出 | 中,需要记录队列水位和时间 | 否 | 消息消费、任务执行频率 |
| 令牌桶 | 按速率补充令牌,请求消费令牌 | 中高,需要维护令牌数和上次补充时间 | 是,最多突发桶容量大小 | 接口总流量控制、网关限流 |
我的经验是,绝大多数业务系统用固定窗口加滑动窗口两套组合就够了,令牌桶在前端网关层或对突发容忍度较高的场景再上。一上来就搞高深算法,很多时候是给自己增加维护成本。
2. Redis 实现限流的三个关键细节
2.1 INCR + EXPIRE 实现计数器,为什么不能拆开写
固定窗口限流最直观的写法是:
INCR counter:user:1001 EXPIRE counter:user:1001 60第一眼看上去没问题,计数器加一,设置 60 秒过期。但在高并发场景下有隐患:如果两个请求同时执行 INCR,后一个请求执行 EXPIRE 覆盖了前一个设置的过期时间,那么过期时间会被无限续期,key 就一直不会删除。更严重的是,如果应用在 INCR 之后、EXPIRE 之前宕机,这个 key 就会变成没有过期时间的永久 key,后续请求全部被拦截,直到人工干预。
正确做法是把它合成一段原子脚本:
local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local current = redis.call('INCR', key) if current == 1 then redis.call('EXPIRE', key, window) end if current > limit then return 0 end return 1这段脚本的关键点在if current == 1这里。只有第一次 INCR 时才设置过期时间,后续 INCR 不再动 EXPIRE,这样既不会因为并发导致过期时间被覆盖,也不会因宕机导致 no TTL。这个写法其实是固定窗口限流的标准范式。
另外注意一个细节,Lua 脚本中 INCR 返回的 current 是 Lua number 类型,Redis 内部的 number 是双精度浮点数。如果请求量过了 2 的 53 次方,精度会丢失,但现实里一个 key 根本不可能在几万秒内加到这么大,所以可以忽略。
2.2 ZSET 滑动窗口的精度和内存成本
滑动窗口用 ZSET 实现的时候,每个请求都是往 ZSET 里加一个 member,member 的 score 是当前时间戳。这里有个很容易踩的坑:ZADD 的 member 必须唯一。如果两个请求在同一毫秒到达,用了同一个 member,Redis 会把 score 更新而不是新增一条,导致计数直接少算。
所以 member 的生成规则必须足够复杂,至少是“用户ID + 随机数 + 时间戳”的组合。更省事的方式是用 UUID 或者雪花 ID,确保每一次调用都不一样。
核心脚本如下:
local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local member = ARGV[4] redis.call('ZREMRANGEBYSCORE', key, 0, now - window) local count = redis.call('ZCARD', key) if count >= limit then return 0 end redis.call('ZADD', key, now, member) redis.call('EXPIRE', key, window + 10) return 1每一次请求进来自带三个动作:清理过期记录、统计数量、写入新记录。逻辑清晰,但代价是每次请求都至少有三个 Redis 操作,而且 ZSET 里的元素会越积越多。
内存算是得算一笔账。一个 ZSET 元素包含 member(至少 16 字节)+ score(8 字节)+ 元数据,真实场景下单个元素大概占 40 到 50 字节。如果一个用户一分钟内被限流,ZSET 里动辄几百上千个元素,几百万用户就是几个 G 的内存。我见过一个团队用滑动窗口做全局限流,上线一天 Redis 内存直接涨了 2 G。
所以滑动窗口别用在全局限流上,只做用户维度的频控,而且窗口不能开太久,通常 60 到 300 秒就够,再长的窗口建议换其他方案。
2.3 为什么所有逻辑必须写成 Lua 脚本
这也是我踩过最惨坑的教训。早期我图省事,把这些判断放在 Java 代码里,先查计数器,再判断是否放行,再决定是否写入。三个步骤之间有一个时间差,并发请求同时读到一个旧值,判断全部通过,限流当场失效。
Redis 的 Lua 脚本能在服务端原子执行,脚本运行期间其他命令不会插入。这保证了“读-判断-写”这三个动作的整体性。表面上是换了一种写法,实际上是解决了分布式环境下的竞态问题。
用 Lua 还有一个隐性优势:减少网络往返。传统写法一个请求至少要 2 到 3 次 Redis 交互,Lua 脚本只要一次 EVALSHA 命令,延迟直接下降一大截。
选择 Lua 而不是 Redis 事务(MULTI/EXEC)的原因更简单:事务虽然也保证原子性,但不支持“先读取再根据结果决定写入”这种条件逻辑。你在事务里 WATCH 一个 key,再在事务里判断,处理 Redis 事务的并发驳回逻辑非常痛苦。Lua 脚本像写普通程序一样写判断条件,清晰且不容易出错。
不过 Lua 脚本也要注意别把慢操作带进去。比如 KEYS、HGETALL、LRANGE 一个大集合,这些命令会阻塞 Redis 单线程实例。脚本里查一下当前的值可以,但千万别遍历全量数据,否则整个 Redis 的响应都会被拉垮。
3. 一个真实的限流系统设计:从需求到落地
3.1 需求拆解和参数计算
假设现在有个活动页面,后端接口有 4 个实例,单台机器实测最高能扛 150 QPS。整条链路上还有数据库和下游服务,实际能安全承载的并发远低于 4 × 150。我在做容量评估时,一般会把真实承载能力的 70% 作为限流阈值,留下 30% 给突发的尖峰和下游抖动做缓冲。
- 全局接口维度:4 实例 × 150 QPS × 70% ≈ 420 QPS,取整设为 400 QPS,窗口用 1 秒。
- 用户维度:活动页用户正常操作最多 1 分钟点 3 次,考虑一些误操作,阈值定到每分钟 5 次,窗口 60 秒。
- 校验维度:验证码接口风险高,单用户每 10 分钟最多 3 次,并且全局每秒不超过 20 次。
固定窗口用于全局接口维度,这里用 1 秒窗口,即使存在临界突刺,也就是两个相邻窗口边缘可能出现双倍流量,但因为我们留了 30% 的余量,双倍流量也只会打到 800 QPS,数据库顶不住,但可以让服务先扛住再通过降级保护。用户维度的 60 秒窗口就用滑动窗口,防止用户在窗口交界处连续触发边界条件。
3.2 封装一个通用的限流组件
代码层面我习惯封装一个独立的限流服务,对外只暴露一个方法,业务层不需要关心用的是哪种算法。核心伪代码大概是这样的:
public interface RateLimiter { boolean tryAcquire(String key, RateLimitConfig config); }内部实现分三步:
第一步,根据 config 里的 algorithm 字段选择 Lua 脚本,脚本提前加载到 Redis,通过 EVALSHA 执行。这样可以省去每次执行都传输脚本内容的网络开销。EVALSHA 后如果返回 NOSCRIPT 错误,再回退到 EVAL 重新加载。
第二步,把业务传入的 key 和配置参数拼好,执行脚本。脚本返回值 1 表示放行,0 表示拒绝。拒绝以后要做一个本地听写或者日志标记,方便后续排查。
第三步,给这个限流服务加上降级开关。Redis 异常时,可以降级到本地内存版本的 SimpleRateLimiter(比如用 ConcurrentHashMap 加时间窗口),或者直接 fail-open 放行所有流量。关键是降级策略必须可配置,不同业务对限流失败的容忍度完全不一样。
为了减少开发成本,我把固定窗口、滑动窗口、令牌桶三种算法的 Lua 脚本都封装成同一个接口,由配置中心动态切换算法类型。这样线上如果发现限流效果不好,改一个配置就能切算法,不需要重新发版。
3.3 上线前必须做的验证
写好的限流组件不能直接上生产。我一般会做三类验证。
第一类是压测验证。用压测工具模拟比阈值高出 50% 到 100% 的流量,观察拒绝率是否与预期一致。比如全局阈值 400 QPS,压到 800 QPS,应该拒绝大约一半的请求;如果拒绝率明显偏低,说明脚本或者 key 设计有问题。压测一定要从业务入口打,不能直接打 Redis,否则验证不到真实链路。
第二类是准确性校验。滑动窗口实现里如果有 member 冲突,会出现少计数的隐蔽 bug。压测时可以对比“Redis 实际 ZCARD”和“压测工具统计的真实请求数”,差距超过 1% 就要检查 member 生成规则。
第三类是观察 Redis 的慢日志和内存。Lua 脚本一旦执行超过几十毫秒,会影响整个 Redis 实例的所有命令。压测期间盯着慢日志,看有没有BUSY报错、脚本执行时间过长的情况。内存则重点观察 ZSET key 的增长速度,确认 EXPIRE 生效。
3.4 落地后的监控与影响评估
限流上线后,除了业务 P99 延迟,我还会重点关注三个指标:拦截量、Redis 请求量和误杀率。
拦截量曲线能直观反映限流有没有被触发。如果长期为 0,说明阈值定得太高,等于没限。如果频繁触顶,说明阈值太低或者在引导用户绕开入口。
Redis 请求量的变化会直接影响 Redis 持久化、网络带宽和连接数。拿滑动窗口来举例,一个 60 秒窗口的 ZSET,每请求一次Redis 要做 ZREMRANGEBYSCORE、ZCARD、ZADD、EXPIRE 四个操作,整体 Redis OPS 会翻两到三倍。如果限流组件和业务缓存混用同一个 Redis 实例,极有可能把缓存拖垮。所以限流用的 Redis 最好单独部署,或者至少用独立的逻辑 DB 和连接池。
误杀率是最容易被忽略的指标。我用一段时间日志对比发现,有段时间限流组件拦截了大量请求,但那些请求只是同一用户在不同页面间的正常跳转。原因是限流维度设计得太粗,把用户所有接口都放进了同一个 key。后来改成“用户ID + 接口路径”的组合 key,误杀率立刻降到了 1% 以下。
4. 实战中踩过的坑和排查思路
4.1 Redis 挂掉之后的降级策略
有一次压测过程中,限流用的 Redis 因为内存满了触发 OOM,所有限流请求超时。我当时采用的降级策略是 fail-open 放行,结果流量全部打到下游,接口 P99 直接飙到几秒钟。
这次事故让我明白了,限流组件降级策略不能一刀切。合理的做法是给降级策略分层:
- 第一层:Redis 连接异常时,尝试降级到本地内存限流器,虽然每个实例的计数不共享,但至少能挡住单机的高并发。
- 第二层:本地限流也失败时,根据业务场景选择 fail-open 还是 fail-close。
- 第三层:所有限流都失效时,必须能在秒级关闭非核心功能,保护核心业务不宕机。
我们后来做了一版更细的降级:每个业务接口配置一个“降级行为”枚举,值可以是放行、拒绝、本地限流。配置中心一发布,所有生效,不需要重启。
4.2 主从切换和时钟偏移导致限流失灵
有同事遇到过这样的诡异现象:限流逻辑没问题,脚本也没报错,但某段时间限流完全失效。查来查去,发现是 Redis 主从切换导致的。主节点挂了之后,从节点提升为主节点,但 INCR 计数没有同步过来,计数器从 0 重新开始,原本一小时内只能调用 10 次的接口,切换瞬间又获得了 10 次配额。
这类问题的本质是 Redis 主从复制是异步的,限流这种强一致性的场景天然不适合。我的建议是,限流用的 Redis 尽量用单机模式加哨兵,而不是 Cluster 模式。Cluster 模式下,分布式共享同一个 key 还需要处理哈希槽迁移,复杂度更高。
另一个经常被忽略的问题是时间不一致。ZSET 滑动窗口的清理逻辑依赖于“当前时间戳”的传入,如果多个应用实例服务器时间不同步,A 机器发出的请求时间比 B 机器快了 5 秒,那么滑动窗口计算出来的窗口边界就是错的,限流效果就乱了。这个问题的排查方式是对比限流日志里的 requestTime 和 Redis 服务器时间,差超过 1 秒就要检查 NTP 同步是否正常。
4.3 Lua 脚本常见的三种报错
先说我遇到最多的:NOSCRIPT 错误。脚本用 EVAL 加载过一次后,后续应该改用 EVALSHA。但 Redis 重启后脚本缓存丢失,EVALSHA 就会找不到脚本。处理方式很简单,捕获到 NOSCRIPT 异常后,重新编译脚本并执行一次 EVAL,然后继续用新的 SHA 值。
第二种报错是脚本执行超时,错误信息里会看到BUSY关键词。Redis 的 Lua 脚本默认最长执行 5 秒,超过之后 Redis 会拒绝执行其他命令。如果脚本里不小心用了 KEYS 命令遍历全库,或者对一个超大 ZSET 执行了频繁操作,整个 Redis 都会被卡死。排查时打开慢日志,找到执行时间超过 100ms 的脚本,把里面的操作拆细。
第三种报错出现在脚本里使用了随机命令,比如TIME或者RANDOMKEY,Redis 会因为复制安全问题直接报错。有些人想在 Lua 内部获取当前时间避免应用服务器时间不一致,但 Redis 的脚本复制机制不允许在写入操作之后执行随机命令,所以这个做法并不稳妥。我最终采用的方案是应用服务器传时间戳,同时保证所有应用服务器时间通过 NTP 保持一致。
4.4 实操排查速查表
| 现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 限流没生效 | Lua 脚本没有以原子操作执行,或 key 命名不一致 | 对比限流日志和真实请求数 | 统一走脚本,key 必须由服务名+接口路径+用户ID拼接 |
| 误杀率很高 | 限流维度太粗,多个接口公用一个 key | 分析拦截日志中的请求路径分布 | key 细化到接口维度 |
| ZSET 内存暴涨 | 清理逻辑没有执行,或 EXPIRE 时间设置过长 | 用 BIGKEYS 命令观察大 key | 检查脚本是否有 ZREMRANGEBYSCORE,缩小窗口 |
| Redis 出现 BUSY | Lua 脚本执行时间过长 | 查慢日志中脚本耗时 | 拆脚本,避免遍历大集合 |
| 主从切换后限流失效 | 异步复制导致计数丢失 | 观察切换时间点前后的拦截量 | 限流单独部署单机 Redis 加哨兵 |
最后补充一个调优经验:限流日志必须记全。有人觉得限流是保护逻辑,日志无所谓,但对线上问题定位来说,限流日志的价值不亚于业务日志。我会记录请求时间、key、instan ID、放行结果、耗时,以及一个自增的请求 ID。有了这些字段,才能在出问题时快速定位是哪个实例、哪个用户、哪个接口在被限流,以及限流决策是否合理。这套 Redis 限流方案我后来在多个项目里复用,最深的感受是限流本身并不难,难的是把阈值、维度、降级策略和监控闭环做好。如果读这篇总结的你正准备动手自研,建议从固定窗口加滑动窗口这套组合开始,跑通后再考虑令牌桶。能跑稳定,比用多高级的算法重要得多。