大促压测,QPS 一路涨到某个刻度,下游支付接口开始超时,线程池排队,重试洪流又叠加一层,整个服务像多米诺一样倒下去。限流器就是给系统装的保险丝:流量再猛,宁可拒绝一小部分,也要保住整体不塌。
但「限流」两个字背后藏着几种性格完全不同的算法:有的宽进严出,有的雷厉风行,有的允许你憋一个大招。挑错算法,要么把正常用户挡在门外,要么在真正的洪峰面前形同虚设。这篇就把最常用的四种——固定窗口、滑动窗口、漏桶、令牌桶——逐个讲透:怎么工作、临界问题在哪、什么场景该用谁。
固定窗口:最简单,也最会骗人
思路直白:把时间切成一段段固定长度的窗口(比如每秒一段),窗口内放一个计数器,请求来了加一,超过上限就拒绝,窗口翻转时清零。十行代码就能写完,几乎所有限流方案都从这里出发。
它的问题出在窗口边界。假设限 10 次/秒:请求方在 0.9 秒时打满 10 个,窗口在 1.0 秒翻转归零,1.1 秒时又放进去 10 个——200 毫秒之内放行了 20 个请求,瞬时速率是限额的 2 倍。理论上,两个相邻窗口的交界处可以堆出接近 2 倍的持续突刺。
这不是理论洁癖。下游容量是按峰值算的:突刺打过去,连接池占满,请求超时,客户端重试再叠一层——雪崩的引线往往就是这道边界的缝。
固定窗口并非一无是处。「每用户每小时 1000 次」这类配额场景里,统计口径天然就是整分整小时(计费、配额展示),窗口翻转与人造口径对齐,实现便宜、语义清楚,它依然是合理选择。
滑动窗口:把边界抹平
突刺的根源是「窗口」这条硬边界,滑动窗口的解法:统计跟着请求的时间戳走,而不是跟着日历走。
工程上有两种做法:
- 滑动窗口日志:把每个请求的时间戳存进队列,新请求到来时先清掉窗口外的旧记录,队列长度就是当前窗口内的请求数。判断精确,但内存与窗口内请求数成正比,高 QPS 下不划算,一般只用于低频严控的场景。
- 滑动窗口计数:不存每个请求,而是保留相邻窗口的计数,按时间比例加权——
当前估算 = 上一窗口计数 × (1 - 已流逝比例) + 当前窗口计数。窗口滑到一半时,上一秒的计数还带着一半的残余影响力,边界两侧不再有断层。内存只与窗口数相关,是各类网关的主流做法。
滑动窗口治好了计数类算法的临界病,但要认清它的视角局限:它仍然是「数数」,窗口内第 1 个请求和第 1000 个请求待遇完全一样——它能拒绝超额,却管不住请求到达的节奏。
漏桶:强制恒速的水龙头
漏桶的隐喻很形象:请求像水倒进桶里,桶底以恒定速率往外漏(这就是处理速率),桶满了再来的直接溢出(拒绝)。
它与前两者的本质区别:不仅限制数量,还约束节奏。不管进水多猛,出水永远是匀速滴答。这就是流量整形——上游可以狂野,下游看到的永远是平稳流量。
NGINX 的 limit_req 模块是教科书实现,官方博客明说它用的就是 leaky bucket。几个关键细节:
rate=10r/s不是「每秒随机放 10 个」,而是毫秒级记账——每 100 毫秒放行一个,粒度远比「每秒」细;- 默认没有队列:请求来得太密直接 503;
- 配
burst=20就多出一个 20 格的等待队列,超额请求排队,按每 100 毫秒一个匀速放给上游; - 再配
nodelay,队列里的请求立即放行,但格子照占、按同样的节奏腾出——允许突发通过,却不在上游制造排队延迟。NGINX 官方建议大多数部署用 burst + nodelay 组合。
漏桶适合保护「消化能力固定且脆弱」的下游:按请求计费的第三方接口、扛不住并发的老服务。代价是排队延迟:burst 队列里的请求要等水位退到自己的格子,排队就是在负债。
令牌桶:允许憋大招的事实标准
令牌桶把漏桶倒了过来:桶里放的不是请求而是令牌,系统恒速往桶里投,请求来了要拿令牌,拿到就走,拿不到就拒。
三个设计决定它的性格:
- 桶有容量上限。令牌积攒最多存这么多,这就是突发额度——系统空闲越久,能吃下的突发越大,上限即桶容量;
- 令牌按速率补给。不管瞬时多猛,长期平均速率被牢牢钉死在投放速率上;
- 不足即拒(或等待)。要 3 个只剩 2 个,绝不透支。
「平均速率受限、瞬时允许突发」,恰好是大多数真实业务想要的形状:接口平均 QPS 100,但新用户登录瞬间的一串请求不该被拒之门外。Java 世界的标准实现 Guava RateLimiter 就是令牌桶:SmoothBursty 空闲时最多攒约 1 秒的许可作为突发额度;SmoothWarmingUp 面向冷启动资源(连接池、带缓存的下游),从慢速逐渐爬坡到目标速率。它的 Javadoc 里有个很妙的语义:acquire 的第一个请求永远立即放行,代价记在下一个请求头上——限流器从不惩罚「第一个」。
工业界的重量级玩家也押注在它身上。Stripe 的限流体系里,请求速率限制器(他们自称 most important)就是中心化的令牌桶:每个用户一个桶,每来一个请求取走一个令牌,Redis 上慢慢滴灌补充,并特意留出突发余量应对真实活动的瞬间流量(他们举的例子是 flash sale)。配套的还有并发请求数限制器和按流量重要性预留容量的两级 load shedder——限流是个体系,令牌桶是主力。
flowchart LR
T["恒速投令牌:每秒 5 个"] --> S
subgraph B["令牌桶(容量 10)"]
S["令牌余量:最多攒 10 个"]
end
S --> D{"桶里够吗?"}
Q["请求:要 1 个令牌"] --> D
D -->|"够:扣 1 个令牌,放行"| OK["200 放行"]
D -->|"不够:拒绝或等待"| NO["429 拒绝"]
漏桶与令牌桶常被混为一谈,记一句对偶就分得清:漏桶约束流出速率(恒速放行),令牌桶约束平均速率(允许突发)。
代码走查:两种代表实现
四种算法不必都手写——固定窗口和令牌桶是两极:一个展示「计数」的坑,一个展示「预算」的巧。下面的 typescript 用虚拟时钟(毫秒整数)驱动,不依赖真实等待,结果可复现(Node 22 以上可 node rate-limiter.ts 直接运行):
// 固定窗口与令牌桶:虚拟时钟(毫秒整数)驱动,无真实等待,结果可复现
// 运行:node rate-limiter.ts(Node 22+ 可直接跑 .ts,低版本先经 tsc 编译)
// ---------- 固定窗口计数器:一个计数器 + 一个窗口翻转 ----------
class FixedWindowCounter {
private windowStart = 0
private count = 0
private limit: number
private windowMs: number
constructor(limit: number, windowMs: number) {
this.limit = limit
this.windowMs = windowMs
}
allow(now: number): boolean {
if (now - this.windowStart >= this.windowMs) {
this.windowStart = now // 窗口翻转:计数清零重新开始
this.count = 0
}
if (this.count < this.limit) {
this.count++
return true
}
return false
}
}
// ---------- 令牌桶:恒速放令牌,请求取令牌,不足即拒 ----------
class TokenBucket {
private tokens: number
private lastRefill: number
private capacity: number
private refillPerSec: number
constructor(capacity: number, refillPerSec: number, now = 0) {
this.capacity = capacity // 桶容量:能吃下的最大突发
this.refillPerSec = refillPerSec // 令牌生成速率
this.tokens = capacity // 冷启动满桶
this.lastRefill = now
}
private refill(now: number): void { // 惰性补给:请求来了才按流逝时间结算
const elapsed = now - this.lastRefill
if (elapsed <= 0) return
this.tokens = Math.min(this.capacity, this.tokens + (elapsed / 1000) * this.refillPerSec)
this.lastRefill = now
}
tryAcquire(now: number, n = 1): boolean { // 不足即拒:不透支、不排队
this.refill(now)
if (this.tokens >= n) {
this.tokens -= n
return true
}
return false
}
}
// 场景 1:固定窗口的临界突刺——限 10 次/秒,边界两侧各来 10 个
const win = new FixedWindowCounter(10, 1000)
let pass = 0
for (let i = 0; i < 10; i++) if (win.allow(900)) pass++ // 0.9 秒:旧窗口还有额度
for (let i = 0; i < 10; i++) if (win.allow(1100)) pass++ // 1.1 秒:新窗口额度已重置
console.log(`固定窗口:0.9s 十连发 + 1.1s 十连发,放行 ${pass} 个(200ms 内 2 倍限额)`)
// 场景 2:令牌桶——容量 10、速率 5 个/秒
const bucket = new TokenBucket(10, 5)
const burst = Array.from({ length: 15 }, () => bucket.tryAcquire(0)).filter(Boolean).length
const after1s = bucket.tryAcquire(1000)
const after2s = bucket.tryAcquire(2000)
console.log(`令牌桶:冷启动 15 连发放行 ${burst} 个;1s 后 ${after1s}、2s 后 ${after2s}(速率持续放行)`)
// 场景 3:令牌不足——按代价取令牌,不够就拒,不透支
const job = new TokenBucket(5, 1)
const tookFive = job.tryAcquire(0, 5)
const needThree = job.tryAcquire(10, 3) // 10ms 只攒 0.01 个
console.log(`令牌不足:一次取 5 个 ${tookFive};10ms 后取 3 个 ${needThree}(不足即拒)`)
// 场景 4:突发 vs 持压——同一配置,两种负载形态
const idle = new TokenBucket(10, 5)
const idlePass = Array.from({ length: 10 }, () => idle.tryAcquire(2000)).filter(Boolean).length
const steady = new TokenBucket(10, 5)
let steadyPass = 0
for (let t = 0; t <= 2000; t += 50) if (steady.tryAcquire(t)) steadyPass++ // 20/s 压 2 秒
console.log(`突发:空闲 2s 后 10 连发放行 ${idlePass} 个;20/s 持压 2s 放行 ${steadyPass} 个(满桶 10 + 补给 10)`)
我本机的实测输出:
固定窗口:0.9s 十连发 + 1.1s 十连发,放行 20 个(200ms 内 2 倍限额)
令牌桶:冷启动 15 连发放行 10 个;1s 后 true、2s 后 true(速率持续放行)
令牌不足:一次取 5 个 true;10ms 后取 3 个 false(不足即拒)
突发:空闲 2s 后 10 连发放行 10 个;20/s 持压 2s 放行 20 个(满桶 10 + 补给 10)
几个走查要点:
- 场景 1 把固定窗口的临界突刺摆在明面上:两个「合法」的窗口,拼出一个 2 倍限额的瞬间;
- 场景 2 里,冷启动的桶是满的,15 连发吃光 10 个令牌后,后续每次放行都由「余量 + 速率 × 流逝时间」决定——令牌桶不需要定时器,算出来即可;
- 场景 3 是代价化限流的入口:转码任务要 5 个令牌、普通查询要 1 个,同一个桶就能表达轻重不一的请求,重请求天然多占预算;
- 场景 4 是令牌桶的签名行为:空闲 2 秒的桶一口吞下 10 连发(桶容量就是突发上限),而 2 倍速率的持续压制 2 秒只放行 20 个——突发有上限,均值钉得死。
选型表与分布式的现实
| 场景 | 推荐算法 | 理由 |
|---|---|---|
| 代理入口按 IP 防刷 | 漏桶(NGINX limit_req + burst + nodelay) | 匀速放行,内存按 IP 记账可控 |
| 保护消化能力固定的下游 | 漏桶 | 出口恒速,下游只见平稳流量 |
| 用户配额(X 次/分钟) | 固定或滑动窗口 | 口径清晰,实现便宜 |
| 面向用户的 API 平均限速 | 令牌桶 | 均值受控,瞬时友好 |
| 客户端 SDK 本地限速 | 令牌桶(Guava RateLimiter) | 进程内零依赖,阻塞语义自然 |
| 冷启动资源(连接池、带缓存的下游) | 令牌桶 + 预热(SmoothWarmingUp) | 速率爬坡,不冲击冷缓存 |
单机写完只是开始。生产环境跑 20 个实例,每个各自限 100 QPS,实际放进来的就是 2000——限流必须是全局视野。主流做法是把计数器或桶状态集中到 Redis,请求来了先远程扣额度。但这里有个经典竞态:「读计数、比较、写回」三步被两个实例同时执行,就可能双双放行,超发就在毫秒之间发生。解法是 Lua 脚本:Redis 执行脚本期间不会插入其他命令,扣减逻辑天然原子。Stripe 正是把令牌桶逻辑放进 Lua 在 Redis 上跑,并发限制器也配了无锁计数加 Lua 判定的组合。
分布式限流还有两条 Stripe 用真实流量换来的经验,值得抄在配置旁边:fail open——限流组件自身故障时放行流量而不是拒绝所有请求,限流是保险丝,不是总开关;灰度观察——每个限流器先以只记录不拦截的模式上线,看清它会拦谁再启用,并随时备好 kill switch。
边界与坑
- 键的选择决定误伤面。按 IP 限流,NAT 后面一整个办公室共享一个额度;按用户限流,脚本注册的小号各享一份免费配额。常见做法是多级键叠加:IP 粗限 + 用户细限 + 接口维度独立计。
- 时间精度是隐形坑。毫秒取整、时钟回拨、多机时钟不同步,都会让窗口翻转或令牌补给出现偏差。严格的窗口判断别依赖应用服务器本地时钟,分布式场景以 Redis 服务器时间为准,或用单调递增的逻辑时间。
- 被限流方也要被善待。返回 429 时带上
Retry-After,SDK 里做指数退避加随机抖动。限流设计的另一半在客户端如何响应限流——设计得好,被拒的流量会自己摊平。 - 别把限流当队列用。漏桶排队是延迟负债,burst 设得太大,用户等来的是变相超时。队列容量要与上游的超时预算匹配,宁可早拒,不要晚死。
参考资料
- Scaling your API with rate limiters(Stripe 工程博客)——令牌桶 + Lua + 两级 load shedder 的生产级组合,fail open 与 dark launch 的实战经验。
- Rate Limiting with NGINX(NGINX 官方博客)——漏桶在毫秒粒度上的完整行为:burst、nodelay、delay 三种形态逐一拆解。
- Module ngx_http_limit_req_module(nginx.org 文档)——limit_req 各参数语义的权威定义。
- Guava RateLimiter Javadoc——acquire 阻塞语义、突发许可与预热模式的官方表述。
- Quick Guide to the Guava RateLimiter(Baeldung)——tryAcquire、超时语义与加权许可的快速上手。
读者留言
COMMENTS 暂无还没有留言,来说第一句?