限流不是「限速」:令牌桶、漏桶、滑动窗口,以及你为什么限了个寂寞
先说结论:绝大多数号称「限流」的系统,其实做的只是「限速」。请求来了,记一个计数,超了就拒——听起来天经地义,实际上漏洞多得能跑耗子。
举个例:某接口规定每用户每秒最多 10 次请求。如果用户在同一瞬间打出 10 次,计数器看到「这一秒来了 10 次」,放行;下一秒再来 10 次,又放行——峰值流量被完整放进了下游。
这就是固定窗口计数最经典的翻车方式:它在窗口边界处放出了两倍于设定阈值的流量。而下游那个被你精心保护的服务,此刻正在被两倍的压力亲切问候。
所以限流的核心不在「限」,在「以什么形态限」。这一篇把四种主流算法掰开揉碎,再手写一个能上生产的 Redis 分布式限流器,顺带吐槽几个高级翻车现场。
一、先分清:限流、限速、限并发、熔断
这四个词被混用得有多惨,你去任何一个技术群吼一嗓子就知道了。简单划线:
- 限流(Rate Limit):单位时间内允许通过的请求数。关心的是「频率」。
- 限速(Speed Limit):约束数据量/字节数,比如上传带宽。关心的是「体量」。
- 限并发(Concurrency Limit):同时在途的请求数上限。关心的是「存量」。
- 熔断(Circuit Breaker):下游已经不健康了,主动断开保护自己。关心的是「对端状态」。
这四个经常需要同时存在:限流挡洪水,限并发保护线程池,熔断在下游已经死了的时候及时止损。只做一个,通常等于没做。
我的观点很直接:只做固定窗口计数就敢自称有「限流能力」的系统,和没有限流系统的区别,只在于它把故障从「立刻爆炸」推迟到了「十秒后爆炸」。
二、四种算法,四种性格
1. 固定窗口计数(Fixed Window)
最简单:key = {资源ID}:{当前时间窗口},INCR 一次,判阈值。实现成本极低,一行 Lua 都能搞定。
致命伤就是前面说的临界突刺:窗口末尾打满,下一秒开头再打满,实际 2 秒内放行了 2 倍流量。另外一个隐蔽的坑是:时间窗口要用哪台机器的时钟?如果多机部署、机器之间时钟有偏差,会出现「同一个逻辑窗口被算成两个」或者「两个窗口被算成一个」的混乱。分布式环境下,别信本机时钟。
2. 滑动窗口计数(Sliding Window)
把固定窗口切成若干小格(比如 10 格,每格 1 秒),请求落到当前所在的格子里,统计最近 10 格的总量和过期。这消除了边界突刺——因为窗口是连续滑动的,任一时刻的任意 1 秒内都不会超过阈值。
代价是内存和计算量随格子数增长。在 Redis 里的典型做法是给每个格子一个带 TTL 的 key,或者用 Hash 结构 + Lua 脚本原子完成「清理过期格 + 计数 + 判断」。格子取多少是经典权衡:格子越细越精确,内存和 GC 压力越大。
3. 令牌桶(Token Bucket)
桶里放令牌,以固定速率(比如每秒 10 个)往里扔,取请求前必须从桶里拿一个令牌,拿不到就拒。桶容量决定了允许的突发量:桶有 100 个令牌,意味着用户可以瞬间打 100 个请求,然后被限制在每秒 10 个的稳定速率。
这是最常被推荐的方案,因为它同时表达了「长期平均速率」和「短期突发容忍度」两个维度。它和滑动窗口的本质区别是:令牌桶允许的突发是一个固定额度,而滑动窗口保证的是任意连续时间段都不超限。前者更适合保护后端资源,后者更适合保护有硬性配额要求的服务。
4. 漏桶(Leaky Bucket)
跟令牌桶几乎相反:请求先进桶排队,桶以恒定速率匀速往下漏。桶满就拒绝新请求。
关键差别在于输出是否平滑。令牌桶允许突发通过,漏桶强制匀速输出——如果下游是一个不能被打断的场景(比如固定速率写外部系统),漏桶才是对的。所谓「削峰填谷」,实际上就是漏斗。
选型建议:保护普通 API 用令牌桶;需要严格配额用滑动窗口;需要平滑输出用漏桶;只要图个「有个东西挡一下」用固定窗口——但请至少在文档里写清楚你牺牲了什么。
三、手写一个能上生产的 Redis 令牌桶
单机限流用内存计数器就行,但服务一旦多实例,内存计数就各算各的,总阈值直接翻倍——这跟你没加限流其实没区别,只是错得更隐蔽。所以要分布式。
Redis 方案的关键点有三个:令牌存量存哪、时间戳怎么算、整个过程必须原子。三个都踩准了才算做完。
关于时间戳:绝对不要用 Redis TIME 之外的本机时间。客户端机器时钟漂移、带网络延迟,都会导致令牌计算偏差。Redis 自带的 TIME 命令是同一个数据中心的同一台时钟,所有实例共享,是唯一可信的「现在」。
-- KEYS[1]: 限流键 ARGV[1]: 容量 ARGV[2]: 每秒填充速率
-- ARGV[3]: 允许的最大补充时间(秒) ARGV[4]: 请求令牌数(默认1)
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = redis.call('TIME')
-- Redis TIME 返回 [秒, 微秒]
local nowSec = now[1] + now[2] / 1000000
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local lastFill = tonumber(data[2])
if tokens == nil then
tokens = capacity
lastFill = nowSec
end
local delta = nowSec - lastFill
if delta < 0 then delta = 0 end -- 时钟回拨保护
-- 按流逝时间补充令牌
tokens = math.min(capacity, tokens + delta * rate)
local allowed = 0
local requested = tonumber(ARGV[4] or 1)
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', nowSec)
-- 空闲时令牌桶不再变化,给个 TTL 回收内存
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 10)
return allowed
几点说明:
- 为什么用 Hash 而不是 String?因为要同时存令牌和时间戳,Hash 能一次
HMGET拿全,省一次往返。 - 为什么必须 Lua?「读令牌 → 判断 → 扣减 → 写回」这几步如果不在同一个原子操作里,两个并发请求会同时读到 100 个令牌,各自判定通过,扣完变成 98——相当于凭空多放行了请求。Lua 在 Redis 里是单线程原子执行的,天然解决这个竞态。
- 容量等于速率时,突发能力为 0,退化成严格匀速;容量远大于速率时,允许瞬时冲高。要不要留这个口子,取决于你的下游抗不抗揍。
- TTL 要设。否则每个用户一个 key,内存会被无限增长的 key 撑爆——限流系统自己成了 DoS 放大器,这种事我见过不止一次。
本地兜底:Redis 挂了怎么办?
很多实现里,Redis 一超时,限流器就直接放行(fail-open),理由是「宁可少限也不能影响业务」。但更常见的做法是本地兜底限流:进程内保留一个令牌桶,Redis 不可用时切换到本地桶,本地桶按节点数均分总阈值。
这里有个反直觉的点:本地兜底不是「更严格的限流」,而是「更粗糙的限流」——多实例均分后每个实例的阈值是 总阈值 / 实例数,如果实例数在扩缩容期间波动,实际放行量会跟着抖动。所以本地兜底桶的阈值宁可设高一点,宁可少挡,也不要在故障叠加时误伤正常用户。
四、几个我见过的翻车现场
翻车一:限流键设计得太粗。 key = user_id,一个只调便宜接口的用户被另一个调昂贵接口的用户给限了。限流必须按「资源成本」分桶,不同 API 的配额不该混在一个桶里。
翻车二:把限流做在业务代码里。 于是限制只在「记得调的那个服务」里生效,其他调用方绕过去照样打爆。限流必须在所有流量必经之路上——网关,或者至少一个统一的中间件层。散落在业务代码里的限流,历史上从没真正限住过任何东西。
翻车三:阈值靠拍脑袋。 「先设 100 吧」——然后呢?没人知道 100 是否合理。正确姿势是用压测得出下游的安全并发水位,再反推阈值。限流阈值应该是压测报告的产物,不是产品经理拍脑袋的产物。
翻车四:只统计成功请求。 失败请求同样消耗了资源,只对 2xx 计数等于把最该保护的场景漏掉了。
翻车五:限流日志打成了 INFO。 限流拒绝既是安全事件也是容量信号,应该打 WARN 并接监控。见过太多系统被限流打爆,事后查日志发现级别设成 INFO,什么都查不到。
五、最后几句
限流是分布式系统里最容易被「实现得像那么回事」的一环。架构图上画满限流、熔断、降级三件套,真实流量一来,限流器的令牌桶被人手工改成 1000000——「先别限我,调试呢」。这种限流跟没有是一个效果,甚至更糟:它制造了「我们有防护」的错觉。
我的建议很朴素:
- 先量后限,用压测数据定阈值,别拍脑袋。
- 分布式限流必须用统一时间源和原子操作,这是技术问题,不是玄学。
- 限流要分资源、分成本,分桶是基本礼貌。
- 一定要有 fail-open / fail-close 的明确策略和监控告警,别让降级路径成为黑箱。
顺带一句:限流最好的状态,是它永远不被触发——但这需要你有足够好的容量规划和足够诚实的压测数据。现实是这两样都很难有,所以限流器得配得足够靠谱。
限流这活儿,说到底是拿用户的等待换系统的稳定。关键在于你得诚实地告诉用户:我限了你,理由是什么,什么时候恢复。把用户当傻子糊弄的限流,换来的不是稳定,是投诉单。
如果这篇对你有点用,欢迎留言聊聊你踩过的限流坑。反面教材往往比正面示范更有教育意义——毕竟谁会主动去写一篇《我的限流器工作得很好》呢。