令牌桶在 0.9 倍负载超发 19%、固定窗口边界放行 20 个:四种限流算法同构实测

摘要

"要允许突发就用令牌桶"------这句流传最广的选型结论,在我把四种限流算法写成同构代码实测之后,需要补两个限定条件。本文用一套 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,符合"闲时攒令牌"的预期。

六、避坑指南

  1. 别把阈值当容量下限。 0.9 倍阈值负载下令牌桶 cap=10 实测超发 14.5%~19.2%。给下游做容量规划时要按 阈值 + 桶容量 预留瞬时峰值,而不是按阈值。
  2. "允许突发"不等于"会平滑突发"。 如果你的目的是保护一个怕抖动的下游(第三方计费接口、单连接数据库),计数型算法(固定/滑动窗口)完全没有平滑能力,实测它们在 91ms 内放完整个阈值。
  3. 令牌桶一定要显式设 capacity。 只传 rate 让 capacity 默认等于 rate 的实现,等于把突发上限设为整秒配额。cap=3 时同样阈值下边界突刺从 20 降到 4。
  4. 分桶必须清理过期格子。 实测不清理让单次决策开销从 0.09µs 涨到 2.1µs,内存随窗口数线性增长;用定长环形数组或每次决策顺手 prune。
  5. 滑动日志的内存是"放行数 × 窗口",不是"请求数"。 本实验末态只保留 10 条(等于阈值),所以它其实很省;真正的问题在分布式实现里用 Redis ZSET 承载时,member 大小和高阈值叠加后的开销,以及必须用 Lua 保证"裁剪+统计+写入"的原子性。
  6. 固定窗口不是不能用,是只能用在配额语义而非速率语义的场景。 比如"每用户每天 1000 次"------边界超发 2 倍对日配额毫无影响。测出来的 +0.0%~+1.8% 偏差也说明:远离阈值时它足够好。
  7. 单实例数字不能直接乘实例数。 本文全部是单进程决策;多实例各挂一个限流器时,实际放行是各实例之和,超发比例会进一步放大。分布式限流还要叠加时钟偏差与网络往返(这里未实测,不编数字)。
  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 运行,实验一/二/三/四为离散时间戳仿真,实验五为真实线程墙钟计时)。

相关推荐
唠点键盘之外的2 小时前
27 存量系统接大模型:旁挂一个 AI 微服务,老架构一行不改
人工智能·微服务·架构
GOWIN革文品牌咨询3 小时前
工业测量品牌系统化搭建指南:从内容架构到长期治理的全流程
大数据·人工智能·架构
Cc.Y3 小时前
Java零基础入门:深入Java核心:文件IO与序列化全解
java·开发语言·python
拿本唠嗑AI研究3 小时前
本体工程实战指南:从建模到可维护的完整落地路径
java·人工智能·python·jena·ontology
Smoothcloud润云3 小时前
PyTorch推理服务模型热切换与失败回滚实战:降低版本更新中断
服务器·人工智能·pytorch·python·深度学习·gpu
forest_093 小时前
Agent 系列02|Agent 架构全景图
人工智能·架构·prompt·agi
YangYang9YangYan3 小时前
2027供应链计划校招:数学与应用数学专业如何把预测模型讲成业务决策?
人工智能·sql·算法
jsjzsl23 小时前
递归自改进系统(RSI)的本质是自发对称破缺:智能爆炸有界—— AI研发切记盲目投入
人工智能·python·算法
天远数科4 小时前
零信任架构实战:基于天远贷前风险报告构建自动化消金信贷风控与多头共债排查网关
运维·人工智能·架构·自动化