限流算法对比:从令牌桶到自适应流控
计数器、滑动窗口、令牌桶、漏桶、自适应流控……不同的限流算法对应不同业务场景。本文用代码 + 压测数据对比每种方案的优缺点,并给出在网关层和单机内分别该如何选择。
限流的核心不是「拒绝」而是「节奏」
很多人把限流想成「超额就拦」,其实更准确的描述是:限流把请求速率控制在系统可处理的范围之内。这里面有三种角色:
- 流量源:可能是用户、爬虫、或者上游服务调用。
- 限流器:观察速率、计算准入决策。
- 被保护资源:下游服务、数据库、依赖的 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」做兜底。
几个总是被忘掉的工程细节
- 限流共享问题:单机令牌桶无法对齐多机总量。需要中心化存储(Redis)或分片算法。
- 限流颗粒度:按 IP / 用户 / 接口 / 全局?颗粒度越细,业务越灵活,但内存也越大。
- 突发后排队:直接拒不是唯一选项。可以让请求短暂排队(500ms 内),体验更好。
- 被拒响应:返回 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。
- 压测时一定要把「上游满速打」和「下游不健康」两组变量一起跑,得到真实边界。
限流做得好,「被保护方」不会感觉到它的存在。这是限流的最高境界。