一篇读懂限流算法:从固定窗口到令牌桶

大促压测,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 设得太大,用户等来的是变相超时。队列容量要与上游的超时预算匹配,宁可早拒,不要晚死。

参考资料

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?