限流阈值设成 100 QPS,可下游还是被打到 200。这不是阈值写错了,是算法选错了。
这篇对比四种限流算法的实际表现,给出分布式场景下的 Lua 实现,以及一张按业务场景选型的对照表。
一、阈值设了 100,实测峰值 200
一个典型事故:接口按 100 QPS 限流,监控上平均 QPS 确实是 100,看起来一切正常。但下游服务仍然报超时。
翻秒级监控才发现,特定时刻的瞬时 QPS 能到 180 以上------就在每个整秒的边界上。
原因是用了最简单的固定窗口计数:每秒一个计数器,超过 100 就拒绝。问题在于,第 59.9 秒打了 100 个请求,第 60.1 秒又打了 100 个,跨窗口的这 0.2 秒里下游承受了 200 个请求。
限流算法的价值不在于「平均值对不对」,而在于限制瞬时峰值。这四种算法在这件事上差别很大。
二、四种算法的原理差异
固定窗口(计数器)
每个时间窗一个计数器,窗口切换时清零。实现最简单,一个 INCR 加过期时间就够。
缺陷就是上面那个临界问题:窗口边界处允许两倍突发。
滑动窗口
把时间轴切成若干小格,统计「最近一个窗口」内的总量。有两种实现:
- 日志式:记录每个请求的时间戳,统计最近 1 秒内的条数。最精确,但每个被限流的 key 要存 O(limit) 个时间戳,高并发下内存吃不住。
- 格子式:把 1 秒切成 10 格,每格 100ms,统计最近 10 格的总和。内存只有 O(10),精度取决于格数。
格子式是工程上最常用的折中,精度损失通常在 10% 以内。
漏桶
请求先进桶排队,桶以固定速率漏出(处理)。桶满了就拒绝。
核心特征是输出绝对匀速。突发流量会被削平,代价是排队延迟------突发时请求要等。
令牌桶
桶里以固定速率放令牌,请求来了取一个令牌,取不到就拒绝。
和漏桶的关键区别:令牌桶允许突发。桶容量是 200、速率是 100/秒时,一个安静的桶里攒了 200 个令牌,突然来 200 个请求能全部放行(只要令牌够)。而漏桶会把这 200 个请求排成 2 秒慢慢放。
所以:要削峰用漏桶,要允许突发用令牌桶。
三、实测对比
测试条件:限流阈值 100 QPS,注入 200 QPS 持续 10 秒,统计实际放行量。
| 算法 | 配置 | 平均放行 QPS | 瞬时峰值(1 秒内) | 突发容忍 | 内存开销 |
|---|---|---|---|---|---|
| 固定窗口 | 100/秒 | 约 100 | 180-200(窗口边界) | 无 | O(1) |
| 滑动窗口·日志 | 100/秒 | 约 100 | 约 105 | 无 | O(limit) |
| 滑动窗口·格子 | 100/秒 / 10 格 | 约 100 | 约 120 | 无 | O(10) |
| 漏桶 | 100/秒,桶容 50 | 约 100 | 约 100(严格匀速) | 排队 | O(1) |
| 令牌桶 | 100/秒,桶容 200 | 约 100 | 约 200(容量内) | 是 | O(1) |
数据为压测量级示意,你的实际数值取决于实现细节与时钟精度。
两个结论:
- 平均 QPS 全部达标。所以只看平均值,你根本发现不了固定窗口的问题。
- 瞬时峰值差别巨大。固定窗口和令牌桶都会允许接近两倍峰值,但原因完全不同------固定窗口是缺陷,令牌桶是设计意图。
四、令牌桶的实现
单机版足够应对大多数场景:
python
import time
class TokenBucket:
"""令牌桶:速率控制 + 容量内允许突发。
非线程安全,多线程场景需在 consume 外层加锁。"""
def __init__(self, rate: float, capacity: int):
self.rate = rate # 每秒补充的令牌数
self.capacity = capacity # 桶容量,决定最大突发
self.tokens = float(capacity)
self.last = time.monotonic()
def consume(self, n: int = 1) -> bool:
now = time.monotonic()
# 按经过时间补充,注意上限是容量
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= n:
self.tokens -= n
return True
return False
两个易错点:
第一,补充令牌必须 min 到容量。 漏了这个 min,长时间空闲后桶里会攒出巨量令牌,一次全放行,等于没有限流。
第二,时间源要用单调时钟。 time.time() 会被系统时间调整影响,NTP 校时回拨时可能出现负增量,导致令牌数变成负数。
五、分布式限流:为什么必须用 Lua
单机令牌桶在集群里会失效------10 个实例各自限 100 QPS,实际总放行就是 1000 QPS。
分布式限流要把状态放到 Redis,但不能用「读出来算完再写回去」,因为读和写之间不是原子的,高并发下会超发。正确做法是 Redis + Lua,整个逻辑在服务端原子执行。
ini
-- KEYS[1]: 限流 key
-- ARGV[1]: rate 每秒补充令牌数
-- ARGV[2]: capacity 桶容量
-- ARGV[3]: now 当前毫秒时间戳
-- ARGV[4]: requested 本次请求的令牌数
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local last = tonumber(data[2])
if tokens == nil then
tokens = capacity
last = now
end
-- 按毫秒差补充令牌,上限为容量
local delta = math.max(0, now - last) / 1000.0
tokens = math.min(capacity, tokens + delta * rate)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
-- 过期时间设成「装满一桶所需时间」的两倍,避免冷 key 常驻
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)
return allowed
三个细节值得单独说:
现在时间由调用方传进来。 不在 Lua 里取 TIME,是为了让多台应用服务器的时钟判断保持一致------虽然 Redis 的 TIME 也行,但传参能让测试更好写。
delta 用 math.max(0, ...)。 防止时钟回拨导致令牌减少。
过期时间是算出来的。 固定写 60 秒会让冷 key 堆积;写成「装满一桶时间 × 2」既够用又能自动清理。
六、按场景选型
| 场景 | 推荐 | 理由 |
|---|---|---|
| 保护下游数据库 | 漏桶 | 下游需要匀速,排队可接受 |
| 对外开放 API 计费配额 | 令牌桶 | 客户会攒着用,要允许短时突发 |
| 秒杀/抢购入口 | 滑动窗口·格子 | 不允许窗口边界双倍,精度要求高 |
| 内部服务间调用 | 令牌桶 | 实现简单,允许合理突发 |
| 单机简单防护 | 固定窗口 | 够用,但要知道临界问题存在 |
一个务实建议:如果你的限流目的是保护下游,选漏桶或令牌桶(容量小);如果目的是公平分配配额,选令牌桶(容量大);固定窗口只适合「大概限一下」的场景,不要用它保护关键链路。
七、检查清单
- 限流算法与业务目标匹配(削峰 / 允许突发 / 公平配额)
- 令牌补充有
min(capacity)上限,不会攒出巨量令牌 - 时间源用单调时钟,能容忍时钟回拨
- 分布式场景用 Redis + Lua,不是「读-算-写」三步
- 限流 key 有合理过期时间,冷 key 会清理
- 限流命中率有监控,能看出是正常限流还是配置过严
- 被限流的请求有明确响应(429 + Retry-After),不是静默丢弃
- 集群总配额 = 单实例配额 × 实例数 这件事已确认过
- 压测时看过秒级监控,不是分钟级平均值
- 限流阈值有变更流程,不是改代码重新发布
八、一句总结
限流的坑不在于算法难写,而在于你以为限住了,其实只是平均值被限住了。
四个算法的代码量差不多,差别全在边界行为上。选之前先问自己一句:我要保护的下游,怕的是持续的 100 QPS,还是瞬间的 200 QPS?答案会直接指向算法。