摘要
"要允许突发就用令牌桶"------这句流传最广的选型结论,在我把四种限流算法写成同构代码实测之后,需要补两个限定条件。本文用一套 130 行、可直接运行的 Python 实现,把固定窗口、滑动窗口分桶、滑动日志、令牌桶放在同一阈值(10 req/s)下做五个实验:边界突刺、泊松稳态精度、决策开销与内存、突发形态,以及一个用真实 sleep 和单调时钟跑的墙钟实验。实测结论:固定窗口在边界 160ms 内放行 2 倍阈值(20 个);令牌桶在 0.9 倍阈值的正常负载下就比精确基准多放行 14.5%~19.2%;而所有"计数型"算法在突发面前都会把整个阈值在一次 91ms 的簇里放完,想真正平滑只能把令牌桶容量压到 1。选型不是四选一,是"精度---突发---内存---下游耐受"四个轴上的取舍。
一、先把四个算法写成同一副骨架
统一接口 allow(t) -> bool,t 是毫秒时间戳。这样每个算法的行为差异只能来自算法本身,排掉测量噪声。
python
from collections import deque
WINDOW_MS, LIMIT = 1000, 10 # 阈值 10 req/s
class FixedWindow:
"""固定窗口:窗口内计数,越界即换窗清零。"""
def __init__(self, limit=LIMIT, win=WINDOW_MS):
self.limit, self.win = limit, win
self.count, self.win_start = 0, None
def allow(self, t):
w = t - (t % self.win) # 对齐到窗口起点
if w != self.win_start: # 换窗即清零 ------ 突刺的根源
self.win_start, self.count = w, 0
if self.count < self.limit:
self.count += 1
return True
return False
class SlidingLog:
"""滑动日志:保留每个放行时刻,判定绝对精确,内存随放行量线性增长。"""
def __init__(self, limit=LIMIT, win=WINDOW_MS):
self.limit, self.win = limit, win
self.q = deque()
def allow(self, t):
while self.q and self.q[0] <= t - self.win:
self.q.popleft() # 过期即弹
if len(self.q) < self.limit:
self.q.append(t)
return True
return False
class TokenBucket:
"""令牌桶:惰性补充(不需要后台线程)。rate 控长期均值,capacity 控瞬时突发。"""
def __init__(self, rate=LIMIT, capacity=LIMIT):
self.rate, self.capacity = rate, capacity
self.tokens, self.ts = float(capacity), None
def allow(self, t):
if self.ts is None:
self.ts = t
else: # 按流逝时间补令牌,封顶 capacity
self.tokens = min(self.capacity,
self.tokens + (t - self.ts) / 1000.0 * self.rate)
self.ts = t
if self.tokens >= 1.0:
self.tokens -= 1.0
return True
return False
滑动窗口分桶是"精度和内存之间的折中",格子数 m 就是那个旋钮:
python
class SlidingBuckets:
"""滑动窗口分桶:窗口切成 m 格,只统计最近 win 内的格子总和。"""
def __init__(self, limit=LIMIT, win=WINDOW_MS, m=10, prune=True):
self.limit, self.win, self.prune = limit, win, prune
self.bucket_ms = max(1, win // m)
self.cells = {} # 生产里应换成定长环形数组
def allow(self, t):
idx = t // self.bucket_ms
lo = t - self.win + 1
total, stale = 0, []
for k, v in self.cells.items():
if k * self.bucket_ms >= lo:
total += v # 仍在窗口内
elif self.prune:
stale.append(k) # 过期格子
for k in stale:
del self.cells[k] # prune=False 可复现"忘记清理"
if total < self.limit:
self.cells[idx] = self.cells.get(idx, 0) + 1
return True
return False
#mermaid-svg-g9DVEa4pemuw9Ndk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-g9DVEa4pemuw9Ndk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-g9DVEa4pemuw9Ndk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-g9DVEa4pemuw9Ndk .error-icon{fill:#552222;}#mermaid-svg-g9DVEa4pemuw9Ndk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-g9DVEa4pemuw9Ndk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-g9DVEa4pemuw9Ndk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-g9DVEa4pemuw9Ndk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-g9DVEa4pemuw9Ndk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-g9DVEa4pemuw9Ndk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-g9DVEa4pemuw9Ndk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-g9DVEa4pemuw9Ndk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-g9DVEa4pemuw9Ndk .marker.cross{stroke:#333333;}#mermaid-svg-g9DVEa4pemuw9Ndk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-g9DVEa4pemuw9Ndk p{margin:0;}#mermaid-svg-g9DVEa4pemuw9Ndk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-g9DVEa4pemuw9Ndk .cluster-label text{fill:#333;}#mermaid-svg-g9DVEa4pemuw9Ndk .cluster-label span{color:#333;}#mermaid-svg-g9DVEa4pemuw9Ndk .cluster-label span p{background-color:transparent;}#mermaid-svg-g9DVEa4pemuw9Ndk .label text,#mermaid-svg-g9DVEa4pemuw9Ndk span{fill:#333;color:#333;}#mermaid-svg-g9DVEa4pemuw9Ndk .node rect,#mermaid-svg-g9DVEa4pemuw9Ndk .node circle,#mermaid-svg-g9DVEa4pemuw9Ndk .node ellipse,#mermaid-svg-g9DVEa4pemuw9Ndk .node polygon,#mermaid-svg-g9DVEa4pemuw9Ndk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-g9DVEa4pemuw9Ndk .rough-node .label text,#mermaid-svg-g9DVEa4pemuw9Ndk .node .label text,#mermaid-svg-g9DVEa4pemuw9Ndk .image-shape .label,#mermaid-svg-g9DVEa4pemuw9Ndk .icon-shape .label{text-anchor:middle;}#mermaid-svg-g9DVEa4pemuw9Ndk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-g9DVEa4pemuw9Ndk .rough-node .label,#mermaid-svg-g9DVEa4pemuw9Ndk .node .label,#mermaid-svg-g9DVEa4pemuw9Ndk .image-shape .label,#mermaid-svg-g9DVEa4pemuw9Ndk .icon-shape .label{text-align:center;}#mermaid-svg-g9DVEa4pemuw9Ndk .node.clickable{cursor:pointer;}#mermaid-svg-g9DVEa4pemuw9Ndk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-g9DVEa4pemuw9Ndk .arrowheadPath{fill:#333333;}#mermaid-svg-g9DVEa4pemuw9Ndk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-g9DVEa4pemuw9Ndk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-g9DVEa4pemuw9Ndk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-g9DVEa4pemuw9Ndk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-g9DVEa4pemuw9Ndk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-g9DVEa4pemuw9Ndk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-g9DVEa4pemuw9Ndk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-g9DVEa4pemuw9Ndk .cluster text{fill:#333;}#mermaid-svg-g9DVEa4pemuw9Ndk .cluster span{color:#333;}#mermaid-svg-g9DVEa4pemuw9Ndk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-g9DVEa4pemuw9Ndk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-g9DVEa4pemuw9Ndk rect.text{fill:none;stroke-width:0;}#mermaid-svg-g9DVEa4pemuw9Ndk .icon-shape,#mermaid-svg-g9DVEa4pemuw9Ndk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-g9DVEa4pemuw9Ndk .icon-shape p,#mermaid-svg-g9DVEa4pemuw9Ndk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-g9DVEa4pemuw9Ndk .icon-shape .label rect,#mermaid-svg-g9DVEa4pemuw9Ndk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-g9DVEa4pemuw9Ndk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-g9DVEa4pemuw9Ndk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-g9DVEa4pemuw9Ndk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 完全不能, 要匀速
能忍 1~2 倍
要, 且内存不敏感
要, 且要控内存
不要, 只要粗配额
希望尽量用满配额
需要限流
下游能忍多大瞬时突发?
令牌桶 capacity 压到 1
或漏桶
要精确的滑动窗口语义吗?
滑动日志
滑动分桶 m=10~60
固定窗口
令牌桶 capacity=阈值
二、实验一:边界突刺是真的
构造最经典的攻击式流量:在 1 秒窗口边界两侧各打 8 个和 10 个请求(每 8ms 一个,共跨 160ms),看各算法放行多少。"1 秒滑动峰值"= 任意 1000ms 跨度内实际放行的最大值,这才是下游真正承受的速率。
| 注入模式(阈值 10 req/s) | 固定窗口 | 滑动分桶 m=10 | 滑动日志 | 令牌桶 cap=10 | 令牌桶 cap=3 |
|---|---|---|---|---|---|
| 边界两侧各 8 个(16 个/160ms) | 放行 16,峰值 16 ❌ | 10 / 10 ✅ | 10 / 10 ✅ | 11 / 11 ⚠️ | 4 / 4 ✅ |
| 边界两侧各 10 个(20 个/160ms) | 放行 20,峰值 20 ❌ | 10 / 10 ✅ | 10 / 10 ✅ | 11 / 11 ⚠️ | 4 / 4 ✅ |
固定窗口恰好放行了 2 倍阈值,这是教科书结论,复现无误。值得注意的是令牌桶 cap=10 也到了 11 个------因为它允许"攒令牌",闲时攒满 10 个后,边界处再补上 1~2 个新生成的令牌,160ms 内能放 11 个。桶容量就是这个算法的突发上限,而不是阈值本身,这是令牌桶最容易被误解的一点。
三、实验二:正常负载下的隐性超发
泊松到达 10 秒,以精确滑动日志的放行数为基准,3 个随机种子取区间。
| 到达率 | 基准放行 | 固定窗口偏差 | 滑动分桶 m=10 | 滑动分桶 m=60 | 令牌桶 cap=10 | 令牌桶 cap=3 |
|---|---|---|---|---|---|---|
| 6 req/s(0.6 倍阈值) | 49~59 | +0.0%~+1.8% | +0.0% | +0.0% | +0.0%~+3.6% | −1.7%~+1.8% |
| 9 req/s(0.9 倍阈值) | 69~78 | +8.6%~+10.3% | +0.0%~+5.8% | +0.0%~+2.9% | +14.5%~+19.2% | +2.9%~+6.4% |
| 12 req/s(1.2 倍阈值) | 81~88 | +9.9%~+15.5% | +1.1%~+2.5% | +0.0%~+1.2% | +21.0%~+28.6% | +4.5%~+6.0% |
关键读数:把负载降到 0.6 倍阈值时,所有算法都基本不超发(此时限流器根本没在工作,偏差来自随机性);但在 0.9 倍阈值这种"看起来很安全"的负载下,令牌桶 cap=10 超发了 14.5%~19.2%,固定窗口超发 8.6%~10.3%。也就是说你配了 10 req/s,下游实际收到的是 11.5~12 req/s。如果下游容量刚好按 10 配,故障往往出现在"负载还没打满"的时候,原因就在这。
格子数 m 的价值也量化出来了:m=10 在 0.9 倍阈值时最高偏差 5.8%,m=60 降到 2.9%,代价见下一节。
四、实验三:决策开销与内存
50000 次决策压在同一时间轴上(0~100000ms),本机 perf_counter 真实计时,3 轮取区间。内存只报数据结构的浅层 sizeof,不是进程 RSS。
| 算法 | 单次决策耗时 | 末态保留条目 | 结构 sizeof |
|---|---|---|---|
| 固定窗口 | 0.086~0.094 µs | 2 | 56 B |
| 滑动分桶 m=10 | 0.235~0.241 µs | 1 | 224 B |
| 滑动分桶 m=60 | 0.473~0.501 µs | 7 | 632 B |
| 滑动分桶(忘记清理) | 2.135~2.225 µs | 100 | 4688 B |
| 滑动日志 | 0.097~0.111 µs | 10 | 1288 B |
| 令牌桶 cap=10 | 0.218~0.226 µs | 3 | 56 B |
两个结论:m 每翻 6 倍,决策耗时约翻 2 倍 (0.24→0.47µs),因为它每次决策要扫 m 个格子;不清理过期格子的代价比 m 大得多------耗时升到 2.1µs(约固定窗口的 24 倍),保留条目从个位数涨到窗口数(100),且随运行时间线性增长。生产实现用定长环形数组而不是字典,就是为了从结构上杜绝这种情况。
五、实验四与五:突发到底被谁挡住了
实验四(同一套离散时间戳):空闲 3 秒后,在 300ms 内每 10ms 打 1 个请求(共 30 个,瞬时 100 req/s)。
| 算法 | 放行数 | 放行时间跨度 | 瞬时速率 | 分布 |
|---|---|---|---|---|
| 固定窗口 | 10 | 91ms | ≈110 req/s | 前 100ms 全放完 |
| 滑动分桶 m=10/60 | 10 | 91ms | ≈110 req/s | 前 100ms 全放完 |
| 滑动日志 | 10 | 91ms | ≈110 req/s | 前 100ms 全放完 |
| 令牌桶 cap=10 | 12 | 211ms | ≈57 req/s | 前 100ms:10,之后每 100ms 各 1 |
| 令牌桶 cap=3 | 5 | 211ms | ≈24 req/s | 前 100ms:3,之后每 100ms 各 1 |
这是全文最重要的一行数据:三种"计数型"算法在突发面前的表现完全一致------把整个阈值在一次 91ms 的密集簇里放出去。它们只保证"每秒不超过 N",从不保证"这 N 个要拉开"。想真正平滑,唯一手段是限制可积累量,即把令牌桶容量压小。
实验五是真实墙钟(另起线程按 10ms 间隔投 60 个请求 = 60 req/s,直接 perf_counter 记放行时刻,非仿真),3 轮结果完全稳定:
| 算法 | 放行数 | 放行间隔 p50 | p95 | 最大 | 首轮连续放行簇 |
|---|---|---|---|---|---|
| 固定窗口 | 10 | 10.0 ms | 10.0 ms | 10.0 ms | 10 个 |
| 滑动日志 | 10 | 10.0 ms | 10.0 ms | 10.0 ms | 10 个 |
| 令牌桶 cap=10 | 15 | 10.0 ms | 100.0 ms | 100.0 ms | 11 个 |
| 令牌桶 cap=1 | 6 | 110.0 ms | 110.0 ms | 110.0 ms | 1 个 |
令牌桶 cap=1 的放行间隔恒定在 110ms(100ms 补充周期 + 主线程自旋的粒度误差),这才是接近匀速的形态;cap=10 仍然有 11 个的连续簇。另外注意 cap=10 总共放行了 15 个 > 600ms×10req/s=6 的配额再加满桶 10,符合"闲时攒令牌"的预期。
六、避坑指南
- 别把阈值当容量下限。 0.9 倍阈值负载下令牌桶 cap=10 实测超发 14.5%~19.2%。给下游做容量规划时要按
阈值 + 桶容量预留瞬时峰值,而不是按阈值。 - "允许突发"不等于"会平滑突发"。 如果你的目的是保护一个怕抖动的下游(第三方计费接口、单连接数据库),计数型算法(固定/滑动窗口)完全没有平滑能力,实测它们在 91ms 内放完整个阈值。
- 令牌桶一定要显式设 capacity。 只传 rate 让 capacity 默认等于 rate 的实现,等于把突发上限设为整秒配额。cap=3 时同样阈值下边界突刺从 20 降到 4。
- 分桶必须清理过期格子。 实测不清理让单次决策开销从 0.09µs 涨到 2.1µs,内存随窗口数线性增长;用定长环形数组或每次决策顺手 prune。
- 滑动日志的内存是"放行数 × 窗口",不是"请求数"。 本实验末态只保留 10 条(等于阈值),所以它其实很省;真正的问题在分布式实现里用 Redis ZSET 承载时,member 大小和高阈值叠加后的开销,以及必须用 Lua 保证"裁剪+统计+写入"的原子性。
- 固定窗口不是不能用,是只能用在配额语义而非速率语义的场景。 比如"每用户每天 1000 次"------边界超发 2 倍对日配额毫无影响。测出来的 +0.0%~+1.8% 偏差也说明:远离阈值时它足够好。
- 单实例数字不能直接乘实例数。 本文全部是单进程决策;多实例各挂一个限流器时,实际放行是各实例之和,超发比例会进一步放大。分布式限流还要叠加时钟偏差与网络往返(这里未实测,不编数字)。
- Java/Go 侧同理。 常见写法里 Guava
RateLimiter.create(10)默认是 SmoothBursty、许可可攒到 1 秒量级,行为对应本文的 cap≈阈值;若下游怕抖,应改为预热型(Warmup)或把 burst 限制住。此段为概念对照,本机未编译验证 Java 代码。
七、结论
- 想控住边界突刺:滑动语义(分桶或日志),固定窗口不合格(实测 2 倍)。
- 想控住瞬时速率:只有小容量令牌桶做得到(cap=1 实测间隔恒定 110ms),计数型算法一律做不到(实测 91ms 放完 10 个)。
- 想控住内存与 CPU:分桶数 m 是唯一的旋钮,m=10→60 换来偏差 5.8%→2.9%,代价是 0.24µs→0.47µs。
- 一句话选型:配额用固定窗口,速率用滑动分桶(m 按延迟预算取 10~60),下游怕抖用低容量令牌桶,三者常常要在同一条链路上分层组合。
数据来源与复现:demos/rate_limiter_showdown.py 与运行留档 demos/rate_limiter_showdown.transcript.txt(Windows + Python 3.11,PYTHONIOENCODING=utf-8 运行,实验一/二/三/四为离散时间戳仿真,实验五为真实线程墙钟计时)。