前阵子线上出了一次不算大的事故:一个给第三方平台做数据同步的回调接口,平时QPS不过几十,结果对方某天重试风暴发作,回调请求像瀑布一样打过来,应用服务器CPU直接拉满,数据库连接池也见了底。复盘的时候我发现,这个接口什么保护都没做——没有限流,没有熔断,等于把大门敞开在公网上。从那以后,"接口上线前必须有限流"就写进了我们团队的代码规范。
这篇文章不打算写成教科书。我想把从单机到分布式做限流摸出来的经验,原原本本梳理一遍:四种经典限流算法各自的数学本质和适用边界;它们用 Java 怎么落地;用 Redis + Lua 怎么落地;以及选型的时候到底在比什么。中间会穿插一些只有踩过坑才看得懂的细节,比如双倍流量、时钟回拨、Redis 挂掉怎么办。适合正在做接口保护、准备 Java/Redis 面试,或者被线上突发流量折磨过的后端开发。
1. 先从一次线上故障说起:限流挡住的到底是什么
1.1 事故现场与排查链路
那次故障的根因链其实很短。第三方系统某个任务挂了,重试逻辑又没有退避,每几百毫秒就重推一次全量数据。我们的回调接口没有做任何保护,结果就是:请求量从几十 QPS 瞬间飙到几千 QPS。数据库连接池被占满,其他正常业务接口全部排队,最后整站响应时间从 50ms 恶化到 20 秒以上。
这个场景你可能也遇到过。保护一个接口,核心就是回答三个问题:
- 这个接口能承受的最大流量是多少?
- 超过这个流量以后,是丢弃、排队,还是降级成异步?
- 丢弃的标准是什么?按 QPS 总量、按并发数,还是按某个用户维度?
限流算法解决的就是第三个问题里的"判断标准"。它本质上是一个决策函数:给定当前系统的请求状态,决定这个新请求到底能不能放行。Java 实现和 Redis 实现,都是把这个决策函数放到不同位置去执行而已。
1.2 限流、熔断、降级三兄弟的分工
很多人把限流、熔断、降级混在一起说,但是它们解决的问题完全不同。
- 限流(Rate Limiting):管的是"进来的量"。不管系统现在健康不健康,进来太多就挡一部分,防止把自己打垮。
- 熔断(Circuit Breaking):管的是"下游的可用性"。当依赖的接口连续出错或者超时到一定阈值,直接短路,不再发起调用,给下游喘息时间。
- 降级(Degradation):管的是"资源的分配优先级"。保核心链路,砍非核心功能。比如大促时把商品评价接口关掉,保证下单接口的资源。
实际事故里,这三样经常要配合使用。只限流不熔断,下游一旦挂掉,流量还是会涌向一个已经瘫痪的服务;只熔断不限流,自己的入口 QPS 照样能把线程池击穿。所以限流是地基,这篇文章重点把地基讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四大经典限流算法:原理、边界与数学本质
2.1 计数器/固定窗口:最简单也最容易被打穿
计数器算法的逻辑一句话就能说完:把时间切成等长的窗口,每个窗口内维护一个计数器,请求进来就加一,超过阈值就拒绝;窗口结束计数器清零。
举个例子:限流规则是 60 秒内最多 600 次请求。那么从整点开始,每 60 秒一个窗口,窗口内请求超过 600 次就拒绝。
这个算法的优点是极其简单,内存只需要一个计数器和窗口开始时间。缺点是有一个著名的"边界突刺"问题:假设 600 次配额在窗口第一秒就全部用完了,那这个窗口剩下的 59 秒全部拒绝,这是合理的;但到了窗口切换的那一刻,上一个窗口的 600 次配额已经清空归零,下一个窗口又是满满的 600 次配额,如果请求刚好在窗口边界集中打过来,就会出现连续两个瞬间各通过 600 次的情况。
用数学说,固定窗口允许的最大瞬时流量可以达到阈值的两倍。对于很多核心业务来说,双倍流量已经足以击穿后端了。
2.2 滑动窗口:把边界问题摊平
滑动窗口是对固定窗口的修正。它不再关心"从整点开始到现在"这个周期,而是始终统计"从当前时刻往前推一个窗口长度"这个区间内的请求数。
还是 60 秒 600 次的例子:如果当前时刻是 12:00:30,它统计的是 11:59:30 到 12:00:30 这一分钟内有多少请求。这样就不存在"窗口切换瞬间配额重置"的问题,任何一秒内的限流判定都是基于过去真实的一秒。
实现上有两种常见方式:
- 精确记录:把每个请求的时间戳都存下来,每次判断时剔除窗口外的时间戳,统计剩余数量。精准,但需要存数据,高并发下内存开销大。
- 分桶近似:把窗口切成更小的粒度,比如把 60 秒切成 60 个 1 秒小桶,每秒的计数放在一个独立的桶里。判断时把当前时间对应的最近 60 个桶累加。这是一种非常实用的工程折中。
滑动窗口的精度取决于分桶粒度。粒度越细越接近精确,但需要的存储和计算量也越大。
2.3 漏桶算法:强制匀速,平整出口
漏桶算法的形象说法是:一个桶底部有一个漏孔,水以恒定速率从漏孔流出;请求先注入桶里,如果桶已经满了,新请求就会被丢弃。桶的容量决定了可以积压的突发请求量,漏孔的速率决定了系统实际的出流速度。
它的核心特征是强制平滑。不管上游流量怎么抖动,下游看到的流量都是恒定速率,这对保护下游非常有价值。比如对接短信网关、第三方支付回调这类对速率敏感的外部服务,漏桶几乎是首选。
但漏桶也有明显短板:它不允许任何突发。即便你的数据库连接池当前是空闲的、有能力一次性处理 100 个请求,漏桶还是会让你每秒只出去 10 个。对很多追求响应速度的内部 API 来说,这种强制匀速反而浪费了系统的处理能力。
2.4 令牌桶算法:允许突发,是最实用的选择
令牌桶我愿称之为"带积攒能力的漏桶"。系统以恒定速率往桶里放令牌,桶的容量有限,满了就不再增加。每个请求到达时,从桶里取一个令牌,取到就放行,取不到就拒绝或等待。
这里有两个关键参数:
- 桶容量(burst capacity):允许一次突发进来的最大请求数。相当于你平时攒下来的令牌总数。
- 补充速率(refill rate):每秒往桶里放的令牌数,决定了长期的平均速率上限。
这个算法同时解决了两个问题:平均速率被令牌补充速率钉死;瞬时突发又有桶容量兜底。比如配置容量 100、每秒补充 10,那么一个空闲很久的接口可以一口气放行 100 个突发请求,但长期跑下来平均速率不会超过每秒 10 个。
对比漏桶,可以这么理解:漏桶是无论你多着急,出口就那么大;令牌桶是平时你省下的配额,关键时刻可以一次性花出去。实际工程里,Guava RateLimiter、Nginx 的 limit_req、大部分自研限流组件,底层都是令牌桶思路。
2.5 四种算法横向对比
| 算法 | 核心思路 | 是否允许突发 | 实现成本 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 窗口内计数 | 最多双倍阈值 | 极低 | 简单接口保护,可接受边界突刺 |
| 滑动窗口 | 窗口内精确/近似统计 | 窗口精度内可控 | 中 | 对边界突刺敏感的核心接口 |
| 漏桶 | 恒定速率出流 | 不允许突发 | 中 | 对接第三方限速、流量整形 |
| 令牌桶 | 恒定速率补充 + 容量突发 | 允许最多容量个突发 | 中 | 通用 API 限流首选 |
3. Java 本地限流实现:从计数器到令牌桶
3.1 固定窗口计数器(AtomicLong + 时间戳)
单机场景下,固定窗口是最容易写对的一种。我见过很多人上来就只用 AtomicLong 做计数器,然后发现窗口转换的时候计数没法清零,于是加了一个 volatile 的窗口开始时间字段。下面是我习惯的写法:
java复制public class FixedWindowRateLimiter {
private final long windowSizeMs;
private final int limit;
private final AtomicLong count = new AtomicLong();
private volatile long windowStart = System.currentTimeMillis();
public FixedWindowRateLimiter(long windowSizeMs, int limit) {
this.windowSizeMs = windowSizeMs;
this.limit = limit;
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart >= windowSizeMs) {
// 窗口到期,需要重置。这里用同步块加二次检查,避免并发重置竞态
synchronized (this) {
if (now - windowStart >= windowSizeMs) {
windowStart = now;
count.set(0);
}
}
}
return count.incrementAndGet() <= limit;
}
}
注意两个细节。第一,窗口重置时用 synchronized 加二次判断,否则多个线程同时走到 if 里会导致窗口被重置多次、计数反复清零。第二,这里用的是墙钟时间,依赖系统时钟准确;后面我会专门讲时钟回拨的坑。
3.2 滑动窗口实现(双端队列与分桶近似)
精确滑动窗口在 Java 里直接用双端队列存时间戳就可以。每个请求到达时,把超出窗口的时间戳从队首弹出,然后判断队列长度是否达到阈值。
java复制public class SlidingWindowRateLimiter {
private final Deque<Long> timestamps = new ArrayDeque<>();
private final long windowSizeMs;
private final int limit;
public SlidingWindowRateLimiter(long windowSizeMs, int limit) {
this.windowSizeMs = windowSizeMs;
this.limit = limit;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
long boundary = now - windowSizeMs;
while (!timestamps.isEmpty() && timestamps.peekFirst() < boundary) {
timestamps.pollFirst();
}
if (timestamps.size() >= limit) {
return false;
}
timestamps.addLast(now);
return true;
}
}
这个写法逻辑最直观,但有两个工程问题:一是锁粒度大,高并发下所有请求抢同一把锁,性能很难上去;二是每个请求都要存一个时间戳,如果窗口内允许多个请求,内存里会积压大量 Long 对象。
实际项目中我更推荐分桶近似。把 10 秒窗口切成 10 个 1 秒的桶,每秒一个计数器,判断时累加当前秒往前 10 个桶的值。这样内存固定,锁竞争也可以缩小到单桶级别,代价是精度损失到秒级。对绝大多数接口来说,秒级精度已经足够。
3.3 手写令牌桶:懒计算填充的技巧
令牌桶的 Java 实现有一个很关键的工程技巧:不需要专门的定时线程去补充令牌,而是在每次请求到达时,根据离上次补充的时间差,懒计算出这段时间应该新增多少令牌。这样实现既简单,又没有额外的线程开销。
java复制public class TokenBucketRateLimiter {
private final long capacity;
private final double refillPerSecond;
private double tokens;
private long lastRefillTimeNanos;
public TokenBucketRateLimiter(long capacity, double refillPerSecond) {
this.capacity = capacity;
this.refillPerSecond = refillPerSecond;
this.tokens = capacity;
this.lastRefillTimeNanos = System.nanoTime();
}
public synchronized boolean tryAcquire() {
long now = System.nanoTime();
double elapsedSeconds = (now - lastRefillTimeNanos) / 1_000_000_000.0;
tokens = Math.min(capacity, tokens + elapsedSeconds * refillPerSecond);
lastRefillTimeNanos = now;
if (tokens >= 1.0) {
tokens -= 1.0;
return true;
}
return false;
}
}
这里有两个细节值得展开。
一是用 System.nanoTime() 而不是 System.currentTimeMillis()。nanoTime 是单调时钟,不受系统时间调整影响,专门用于测量时间间隔。虽然它不能直接转成绝对时间,但计算"过去了多久"这个场景它是更可靠的选择。
二是拒绝请求时也要把 lastRefillTimeNanos 更新到当前时刻。很多人忽略了这一点,导致一个请求被拒绝后,下一次请求会把这一段长时间积攒的令牌一次性补上,立刻又放行,造成"拒绝一次放行一次"的抖动。把时间推进之后,令牌是按真实时间均匀累积的,行为更平滑。
3.4 Guava RateLimiter:它到底做了什么
Guava 的 RateLimiter 是单机限流最常见的现成方案,值得花点时间说清楚它的行为。RateLimiter.create(10) 默认创建的是 SmoothBursty 模式,支持突发,突发上限约等于一秒钟的令牌量,也就是 10 个令牌。
另一个模式是 RateLimiter.create(10, Duration.ofSeconds(1), SmoothWarmingUp),它会从较低速率逐渐"预热"到满速。这种模式适合目标服务有缓存或连接池需要预热的情况,冷启动阶段快速放流量反而会让系统吞吐崩塌。
用的时候有个小坑,就是 tryAcquire(timeout) 这个方法。它允许请求者在拿不到令牌时原地等待一段时间。看起来是"等一会儿再试",但如果你在同步接口里大面积使用,线程会阻塞在限流器上,等 timeout 结束之后才返回 false。这里的 false 往往被业务方当成"再重试一次"的信号,很容易把重试风暴引到自己身上。我一般建议非核心链路直接 tryAcquire() 立即返回,由上层决定怎么处理拒绝。
4. Redis 分布式限流实现:用 Lua 保证原子与一致
4.1 为什么单机限流扛不住多实例
Java 本地方案再好,也只对单进程生效。一旦服务部署了 3 个实例,每个实例各自计数,那实际的放行总量是单实例阈值乘以实例数。如果你希望整个服务对外是"每分钟最多 6000 次",本地方案是做不到的。
当然有人会说,那我每台机器各自限 2000 次不就行了?问题是实例数会动态变化,容器扩缩容之后每台机器的配额要不要重新算?K8s 弹出一个新实例,它是按 2000 算还是按老机器已经用掉的量来补?更麻烦的还有用户维度限流:要求"每个用户每分钟最多 100 次",这种状态无论如何都要放到一个所有实例共享的地方去,也就是 Redis。
分布式限流的本质代价是每次判定多一次网络往返。在 Redis 团队标准里,一次本机 Redis RTT 大约 0.1~0.5 毫秒,对一个本身只需要 10 毫秒的接口来说完全可接受。但如果你的接口 QPS 高达十万级,每请求多一次 RTT 就是一个巨大的瓶颈,这时候本地限流反而更现实。
4.2 固定窗口的 Redis Lua 实现
固定窗口在 Redis 里最朴素的写法是 INCR 加 EXPIRE。但这两条命令不是一个原子操作——服务在 INCR 之后、EXPIRE 之前挂掉,这个 key 就变成了永不过期的残留。所以推荐直接写成 Lua 脚本,Redis 会保证脚本执行期间不被其他命令插入。
lua复制-- KEYS[1]:限流 key,建议带上窗口标识
-- ARGV[1]:窗口长度(秒)
-- ARGV[2]:窗口内最大请求数
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
return 0
end
redis.call('INCR', key)
redis.call('EXPIRE', key, window)
return 1
细看这段脚本,它在每次请求后都会执行 EXPIRE,这意味着这个 key 的过期时间被持续后移。如果接口流量一直存在,key 就一直不会过期,直到 Redis 内存被打爆。一个更好的做法是让 key 本身携带窗口标识,比如 rate:user:10086:202405021030 精确到分钟,窗口切换时自然换 key,旧 key 在下一分钟就会被清理掉。这样既不需要反复 EXPIRE,也避免了残留 key 的问题。
4.3 滑动窗口的 Redis Lua 实现(ZSET 方案)
Redis 里做精确滑动窗口,最自然的容器是 ZSET:用请求时间戳作为 score,member 用时间戳拼接随机数保证唯一。每次请求到达时,移除窗口范围外的旧记录,统计剩余数量,小于阈值就插入当前请求并放行。
lua复制-- KEYS[1]:滑动窗口 key
-- ARGV[1]:当前时间戳(毫秒)
-- ARGV[2]:窗口大小(毫秒)
-- ARGV[3]:窗口内最大请求数
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
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, now .. ':' .. math.random(1000000))
redis.call('PEXPIRE', key, window)
return 1
member 加随机后缀是必须的。高并发下多个请求可能落在同一毫秒,score 相同没关系,但 member 如果相同,ZADD 会覆盖同一条记录,滑动窗口的统计就失真了。
这个方案的问题也显而易见:每个请求都要在 ZSET 里存一个成员,窗口越长、流量越大,存储膨胀越严重。假设限流 10 万次/分钟,ZSET 里要堆 10 万个元素,每次请求还要先做一次大范围删除,Redis 主线程的 CPU 很容易被打满。
所以这个实现适合低 QPS、高精度要求的场景,比如风控的秒级限流。对高 QPS 的业务接口,我更推荐 4.2 的固定窗口,或者用下面 4.4 的令牌桶配合近似滑动窗口的方式来做。
4.4 令牌桶的 Redis Lua 实现(懒加载填充)
Redis 里实现令牌桶,同样可以用懒计算,不需要后台定时任务。需要两个 key:一个存当前令牌数,一个存上次补充时间。
lua复制-- KEYS[1]:限流 key 前缀,实际使用 KEYS[1]..':tokens' 和 KEYS[1]..':ts'
-- ARGV[1]:桶容量
-- ARGV[2]:每秒补充令牌数
-- ARGV[3]:当前时间戳(毫秒)
local tokensKey = KEYS[1] .. ':tokens'
local tsKey = KEYS[1] .. ':ts'
local capacity = tonumber(ARGV[1])
local refillPerSecond = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local tokens = redis.call('GET', tokensKey)
local lastTs = redis.call('GET', tsKey)
if not tokens then
tokens = capacity
lastTs = now
else
tokens = tonumber(tokens)
lastTs = tonumber(lastTs)
local elapsed = (now - lastTs) / 1000.0
if elapsed > 0 then
tokens = math.min(capacity, tokens + elapsed * refillPerSecond)
end
end
if tokens >= 1 then
redis.call('SET', tokensKey, tokens - 1)
redis.call('SET', tsKey, now)
return 1
end
redis.call('SET', tsKey, now)
return 0
注意最后一行:请求被拒绝时,我也把 tsKey 更新到了当前时刻。原因和 Java 版完全一样——避免下一次请求把拒绝期间积攒的令牌一次性补上,造成"拒绝一次放行一次"的规律性抖动。
调用这段脚本时,时间戳(ARGV[3])建议由 Java 侧传入毫秒值。虽然 Redis 提供了 TIME 命令可以拿服务器时间,但如果多个应用服务器的时钟本来就基本同步,传参的方式更简单,也不增加额外 RTT,只是要注意各机器时钟偏差不要超过明显范围,否则限流就不准了。
5. Java 实现和 Redis 实现怎么选:性能、一致性与运维成本
5.1 性能对比:从纳秒到毫秒的巨大差距
先摆一组质感性数字。Java 本地令牌桶,一次 tryAcquire 的耗时是几十纳秒到一两微秒,几乎可以忽略不计;Redis 实现则至少包含一次网络往返,局域网内 0.2 毫秒左右,跨机房可能到 1 毫秒以上。如果你的接口本身只花 5 毫秒,为了限流多花 0.5 毫秒,那 10% 的性能就没了;如果你的接口要花 100 毫秒,那 0.5 毫秒根本不值一提。
这决定了第一个选型原则:延迟敏感、追求极限吞吐的内部接口,优先本地限流;对延迟不敏感、但需要统一管理的网关接口,优先 Redis。
5.2 三种典型场景下的选型决策
从我实际看到的项目里,限流选型基本落在三张牌上:
- 场景一:API 网关 / 统一接入层。所有流量都过网关,这是做全局限流的最佳位置。必须用 Redis,因为网关背后挂着几十个服务实例,本地计数没有任何意义。很多网关框架自带的限流器也是基于 Redis 实现的。
- 场景二:业务服务自保护。比如你的订单服务担心被某个异常下游打爆,直接在服务内加限流。这时候本地令牌桶就够用,简单、无依赖、性能好。即使未来扩到多实例,每台机器限自己那部分也通常能接受。
- 场景三:用户维度限流。比如"每个用户每分钟最多发 10 条短信"。这种状态必须全局共享,只能放在 Redis,用用户 ID 作为 key 的一部分。
我的准则可以概括成一句话:能本地解决的不要引入网络依赖,需要全局精确的一定上 Redis。
5.3 阈值估算方法:别靠拍脑袋
限流阈值定多少,这是一个被严重低估的问题。很多人上线前随手填一个 1000 QPS,结果根本不符合系统真实容量。
我自己的标准流程是这样:
- 对目标接口做基准压测,找到单实例的"安全 QPS"。安全指的是延迟不恶化、错误率不增加的持续吞吐量,而不是最高吞吐量。
- 把这个值乘以实例数,得到集群的估算总容量。
- 再乘一个 0.7~0.85 的系数,作为限流阈值。内存和 CPU 都要留出应对流量尖峰的余量。
- 上线后观察触发情况,持续修正。如果限流几乎不触发,说明阈值定得偏高;如果经常误伤正常请求,说明偏低。
还有一个很多人忽略的事:限流阈值不能只看接口本身的吞吐,还要看你下游的承受能力。下游数据库连接池只有 50 个连接,那你接口就算能扛 5000 QPS,也不能放这么多过去。限流的本质是保护整个调用链,不光是保护自己。这一点我踩过不少次,后面细说。
6. 实战中踩过的坑和修法:原子性、时钟、降级与清理
6.1 固定窗口的双倍流量问题,到底有多严重
固定窗口允许的最大流量超过阈值两倍,这个结论不是理论上的。举个例子:限流 60 秒 600 次。假设 12:00:59 这一秒内,前面 59 秒流量较低,最后 1 秒涌入 600 次;12:01:00 窗口切换,下一秒又涌入 600 次。请求集中在窗口边界的那一两秒内,实际通过的请求数是 1200 次,而系统的真实容量可能只有 600 QPS。下游在那一瞬间收到的压力,是预期值的一倍以上。
这个问题的修复方案有两个:一是换成滑动窗口或令牌桶,彻底消除边界重置;二是在固定窗口的基础上增加一个"软限制",比如窗口前半段最多用掉 80% 的配额,剩下的配额平摊到窗口各个时间段。第二种方式的实现成本更低,但对流量模型的假设比较强。
6.2 INCR 与 EXPIRE 的原子性问题:必须交给 Lua
固定窗口的简单实现是 INCR key 之后 EXPIRE key 60。但这两条命令不是原子的。想象一个极端场景:QPS 很高,INCR 已经把计数加到 1000,此时进程被 kill,EXPIRE 没执行到,Redis 里这个 key 一直存在。下个窗口开始时,新的请求从 1000 开始继续累加,直接触发限流,系统看起来毫无理由地拒绝了所有请求。
类似的坑我也在别人的代码里见过:有些人图省事,用 RedisTemplate 连续执行 increment 和 expire 两个方法,中间不做原子性保证。一旦 Redis 命令因为网络抖动断开,残留 key 的过期时间就永远设置不上。所以这种场景我强烈建议统一封装 Lua 脚本,让原子性交给 Redis 保证。
6.3 时钟回拨:本地限流最容易忽略的坑
Java 本地实现里,如果用了 System.currentTimeMillis() 做窗口边界,就存在一个隐患:服务器上跑着 NTP 时间同步,如果时间往前跳了几百毫秒,限流器的窗口可能直接"穿越"到未来。这会带来两种异常行为:一是窗口迟迟不重置,配额一直不恢复,接口被误杀;二是窗口瞬间计算出一个很大的时间差,令牌桶一次性补满令牌,导致一段突发流量全部通过。
修复方法很简单:计算时间间隔一律用 System.nanoTime()。它是一个单调递增的时钟,不受系统时间调整影响,只适合前后两个时刻做差,不适合显示成绝对时间。固定窗口的"窗口开始时间"如果用 nanoTime,也需要一开始建立基准,把当前墙上时钟映射到一个单调时间轴上。这段处理看起来不起眼,但在大促跨零点、服务器批量重启这种时间敏感场景里,能帮你免掉很多莫名其妙的告警。
6.4 Redis 不可用时的降级:fail open 还是 fail closed
分布式限流必然面临一个问题:Redis 挂了怎么办?两种极端策略:
- fail open:Redis 不可用时直接放行。优点是服务可用性不受影响,缺点是限流保护完全失效,流量洪峰来临时后端该挂还是挂。
- fail closed:Redis 不可用时直接拒绝。优点是绝对安全,缺点是 Redis 抖动几秒钟,你的正常用户全部被拒,体验很差。
我现在的做法是两层设计。Redis 限流作为第一道门,同时在本地维护一个非常宽松的兜底限流器。当 Redis 连续超时达到几个阈值之后,触发降级开关,第一道门放行,但本地的宽松限流仍然在跑——比如正常限流是 1000 QPS,兜底是 3000 QPS。这样 Redis 就算挂了,系统也只是从"精确限流"退化成"粗粒度保护",不会完全裸奔。
6.5 客户端重试放大:被限流之后的事情更关键
最后要说的这个坑,往往不在限流器本身,而在被限流方。当接口开始大量返回 429 或限流错误时,很多调用方的第一反应是重试。如果没有退避策略,重试请求会叠加在原本已经超限的流量上,形成雪崩。
我的两个建议:
- 服务端在返回限流错误时,带上
Retry-After响应头,告诉调用方等多久再试。 - 客户端做指数退避加随机抖动(jitter)。比如第一次等 100ms,第二次等 200ms,第三次 400ms,叠加一个随机偏移,避免所有客户端在同一个时刻集体重试。
限流不是把请求挡掉就完事了,它是一个链路上的约定:有能力的一方要明确告诉对方"现在不行、多久之后再来",有义务的一方要尊重这个信号。配合好了,限流才是保护系统的手段;配合不好,限流自己就会变成故障放大器。
做完这些之后,我又回到最初那个事故回调接口,给它加了 Redis 固定窗口限流,外加本地令牌桶兜底,下游数据库连接池也配置了独立保护。后来对方又触发了几次重试,最多的一次打到 3000 QPS,接口稳稳扛住,其他业务一点没受影响。这就是限流该有的样子:你不一定能阻止异常发生,但你能保证异常发生时,它只影响该影响的那一部分。
