一粟 · yisu.app
· 12 min read
限流

限流算法对比:从令牌桶到自适应流控

计数器、滑动窗口、令牌桶、漏桶、自适应流控……不同的限流算法对应不同业务场景。本文用代码 + 压测数据对比每种方案的优缺点,并给出在网关层和单机内分别该如何选择。

限流的核心不是「拒绝」而是「节奏」

很多人把限流想成「超额就拦」,其实更准确的描述是:限流把请求速率控制在系统可处理的范围之内。这里面有三种角色:

  • 流量源:可能是用户、爬虫、或者上游服务调用。
  • 限流器:观察速率、计算准入决策。
  • 被保护资源:下游服务、数据库、依赖的 RPC。
限流的「艺术」在于:让正常请求几乎不被察觉地通过,让超额的请求被温柔地拒掉。

计数器(Fixed Window):最简单的版本

把时间切成固定大小的窗口(比如 1 秒),用计数器记录窗口内请求数。超过阈值拒绝。优点是实现简单,缺点是「边界突刺」:1 秒的最后 50ms 和下一秒的前 50ms 都涌进 N 个请求,本质上等价于 2N 个 / 100ms 的瞬时速率。

class FixedWindow:
    def __init__(self, limit, window_seconds):
        self.limit = limit
        self.window = window_seconds
        self.count = 0
        self.start = time.monotonic()
    def acquire(self):
        now = time.monotonic()
        if now - self.start >= self.window:
            self.count = 0
            self.start = now
        if self.count < self.limit:
            self.count += 1
            return True
        return False

滑动窗口(Sliding Window):把边界磨平

滑动窗口逻辑上等价于每个时刻都统计「最近 1 秒内的请求数」。实现上可以用窗口环形 + 时间戳,或者把窗口切成更小的子窗口(比如 10 个 100ms),把它们的计数累加。这种方式能消除固定窗口的「边界突刺」。

代价是内存稍多、计算多。但对绝大多数 web 业务仍然够用。

漏桶(Leaky Bucket):以恒定速率出口

漏桶模型把请求看成水滴,放进桶里,再从桶底以恒定速率流出。桶满就拒。这是对后端最温柔的方式:它把突发流量抹平成稳定的输出。代价是当突发很多时,桶满也会让用户在某个时刻「卡顿」。

type LeakyBucket struct {
    capacity     float64
    rate         float64   // 每秒流出几个
    water        float64
    lastTime     time.Time
    mu           sync.Mutex
}

func (b *LeakyBucket) Allow() bool {
    b.mu.Lock()
    defer b.mu.Unlock()
    now := time.Now()
    leaked := b.rate * now.Sub(b.lastTime).Seconds()
    b.water = math.Max(0, b.water - leaked)
    b.lastTime = now
    if b.water + 1 > b.capacity {
        return false
    }
    b.water += 1
    return true
}

令牌桶(Token Bucket):允许「合理突发」

令牌桶与漏桶方向相反:桶按固定速率产生令牌,请求需要拿走令牌。多了的令牌会预留起来,达到上限为止。预留机制让「短时突发」可以通过,长期超额的请求会被拒。绝大多数 API 网关默认就是令牌桶。

NGINX 的 limit_req、AWS API Gateway、Spring Cloud Gateway 都用它。参数上关注两个:

  • rate:每秒填充的令牌数,决定长期平均速率。
  • burst:桶容量,决定可承受的瞬间突发。
令牌桶 = 「承诺一个长期均值 + 容忍短时突发」。这是最贴合业务的限流模型。

自适应流控:让限流「学会拒绝」

当被保护资源自己有指标(延迟、错误率、CPU),那限流可以做「自适应」:不是固定阈值,而是依据被保护方当下状态动态调整。这一点 Sentinel 的「冷启动」「匀速排队」做得很好:

特性固定令牌桶自适应
需要实时下游指标
对突发友好取决于算法
过载保护滞后即时
实现复杂度

我们生产中是这样搭的:网关层用令牌桶做粗粒度的「全局速率」;下游服务各自用 Sentinel 做自适应流控;最外层用「自适应」+「全链路 health score」做兜底。

几个总是被忘掉的工程细节

  1. 限流共享问题:单机令牌桶无法对齐多机总量。需要中心化存储(Redis)或分片算法。
  2. 限流颗粒度:按 IP / 用户 / 接口 / 全局?颗粒度越细,业务越灵活,但内存也越大。
  3. 突发后排队:直接拒不是唯一选项。可以让请求短暂排队(500ms 内),体验更好。
  4. 被拒响应:返回 429 是标准,但返回 503 + Retry-After 也是合理选择。

压测对比:四种限流在 5 万 QPS 下的差异

我们去年做了一次线上压测对比。结果如下(基于相同的下游容量):

算法尾延迟 P99拒绝数CPU 开销
计数器1.2s高(突刺)极低
滑动窗口0.9s
令牌桶0.7s
自适应0.5s最低

结论清晰:自适应最好,但 CPU 开销略高。在高并发场景下,依然是「令牌桶 + 自适应」组合最稳。

回到架构:分层限流的范式

2024 年之后,「多层限流」已经成为标准做法:

  • CDN/边缘层:按 ASN / 国家 / IP。
  • 网关层:按接口、按令牌桶。
  • 应用层:按用户、按业务身份(VIP / 普通)。
  • 资源层:按被保护对象的实时负载自适应。

这套分层让「上游减少无效请求、下游减少抖动、整体提高吞吐」三件事同时发生。

给团队的几个小建议

  • 把限流算法变成一种「业务级别的设计选择」,不是技术选型。
  • 把被拒响应做成客户端能理解的信号:SLO hint、Retry-After、Graceful degradation。
  • 压测时一定要把「上游满速打」和「下游不健康」两组变量一起跑,得到真实边界。

限流做得好,「被保护方」不会感觉到它的存在。这是限流的最高境界。