限流不是「限速」:令牌桶、漏桶、滑动窗口,以及你为什么限了个寂寞

2026-10-29 11 0

限流不是「限速」:令牌桶、漏桶、滑动窗口,以及你为什么限了个寂寞

先说结论:绝大多数号称「限流」的系统,其实做的只是「限速」。请求来了,记一个计数,超了就拒——听起来天经地义,实际上漏洞多得能跑耗子。

举个例:某接口规定每用户每秒最多 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——「先别限我,调试呢」。这种限流跟没有是一个效果,甚至更糟:它制造了「我们有防护」的错觉。

我的建议很朴素:

  1. 先量后限,用压测数据定阈值,别拍脑袋。
  2. 分布式限流必须用统一时间源和原子操作,这是技术问题,不是玄学。
  3. 限流要分资源、分成本,分桶是基本礼貌。
  4. 一定要有 fail-open / fail-close 的明确策略和监控告警,别让降级路径成为黑箱。

顺带一句:限流最好的状态,是它永远不被触发——但这需要你有足够好的容量规划和足够诚实的压测数据。现实是这两样都很难有,所以限流器得配得足够靠谱。

限流这活儿,说到底是拿用户的等待换系统的稳定。关键在于你得诚实地告诉用户:我限了你,理由是什么,什么时候恢复。把用户当傻子糊弄的限流,换来的不是稳定,是投诉单。


如果这篇对你有点用,欢迎留言聊聊你踩过的限流坑。反面教材往往比正面示范更有教育意义——毕竟谁会主动去写一篇《我的限流器工作得很好》呢。

相关文章

你刚写进去的数据怎么就读不出来了:主从延迟的七种死法
别一上来就 Kafka:消息队列的五个真坑,和你必须算清的那笔一致性账
生成一个 ID 而已,怎么就变成了事故现场:分布式 ID 的五层坑
epoll 已经很能打了,Linux 干嘛还要再造一个 io_uring?
Rust 三大痛点,其实只有一个:所有权。读完你会想删掉所有 GC
NULL 不是空,是”未知”:一次凌晨两点的订单失踪,揪出 SQL 三值逻辑的真凶

发布评论