随机化与近似结构
限流器
四种算法,先看它们错在哪
限流的目标是「每秒最多 N 次」。四种常见实现,差别不在复杂度,在边界行为:
| 算法 | 每个 key 的状态 | 主要问题 |
|---|---|---|
| 固定窗口 | 2 个数 | 🚨 边界处能放行 2 倍 |
| 滑动日志 | limit 个时间戳 | 精确,但空间随限额线性增长 |
| 令牌桶 | 2 个数 | 允许突发(有时正是想要的) |
| 漏桶 | 2 个数 | 计量器版就是令牌桶 |
下面每一条都有实测。
🚨 固定窗口:边界处放行 2 倍
最直白的写法:记一个窗口起点和一个计数器,过了窗口就清零。
function fixedWindow(limit, windowMs) {
let start = 0, count = 0;
return (now) => {
if (now - start >= windowMs) { start = now - (now - start) % windowMs; count = 0; }
return count < limit ? (count++, true) : false;
};
}
限额 100 次/秒。在第 999 毫秒打满 100 次,再在第 1000 毫秒打满 100 次:
t = 999ms 放行 100
t = 1000ms 放行 100
--------------------------
相邻 2 毫秒内共放行 200 —— 而限额是 100/秒
⚠️ 两次都「没超过窗口限额」,但它们跨了窗口边界,于是在任意 1 秒的滑动区间里 实际放行了 2 倍。同样场景下滑动日志只放行 100 次。
🚨 这个缺陷在压测里很难发现:均匀打流量时它完全正常,只有请求恰好聚在 边界两侧才暴露 —— 而真实流量(整点任务、秒杀开始)恰恰爱聚在整秒。
滑动日志:精确,但空间是代价
存下每次放行的时刻,查询时把窗口外的丢掉:
function slidingLog(limit, windowMs) {
const q = [];
return (now) => {
while (q.length && q[0] <= now - windowMs) q.shift();
return q.length < limit ? (q.push(now), true) : false;
};
}
⭐ 它没有边界问题,因为窗口是跟着当前时刻滑动的,不存在「跨窗口」。
⚠️ 代价是每个 key 要存 limit 个时间戳。限额 1000/秒就是 1000 个数, 而限流通常是「每个用户一个 key」——用户量一上来,这个空间不可接受。
📌 折中做法是滑动窗口计数:把窗口切成若干小格,只存每格的计数, 按当前时刻在格内的比例加权。空间降到格数,精度介于两者之间。
令牌桶:cap 就是允许的突发量
按固定速率往桶里放令牌,桶满则弃;每个请求取走一个,没有就拒绝。
function tokenBucket(rate, cap) {
let tokens = cap, last = 0;
return (now) => {
tokens = Math.min(cap, tokens + (now - last) / 1000 * rate);
last = now;
return tokens >= 1 ? (tokens -= 1, true) : false;
};
}
⭐ 桶容量直接等于允许的瞬时突发量。驱动方式:t=0 一次性打 200 个请求, 之后每毫秒发一个,直到 t=3000(这个口径要写出来,换一种驱动数字就变):
rate= 10/s cap=10 瞬时放行 10 3 秒共放行 40 期望 rate×3+cap = 40 ✅
rate= 10/s cap= 1 瞬时放行 1 3 秒共放行 31 期望 = 31 ✅
rate= 10/s cap=50 瞬时放行 50 3 秒共放行 80 期望 = 80 ✅
rate=100/s cap=20 瞬时放行 20 3 秒共放行 319 期望 = 320 ❌ 差 1
⭐ 三行严丝合缝,放行总量 = rate×秒数 + cap,这是个能直接拿去算容量的式子。
🚨 最后一行差的那 1 次不是算法的性质,是浮点误差 —— 把令牌数改成整数刻度 (放大 1000 倍)重跑,这一格就是 320。下面「漏桶」一节会说清这个坑, 它在这一篇里一共冒头三次。
📌 cap 和 rate 是两个独立旋钮:rate 管长期平均,cap 管能容忍多大的瞬时尖峰。
想完全禁止突发就把 cap 设成 1。
⚠️ 注意长期速率会比 rate 略高:每毫秒发一个跑 10 秒,实测放行 110 次而不是 100 ——
多出来的正好是初始的一桶 cap=10。算容量时要把它算进去。
⭐ 漏桶:计量器版就是令牌桶
很多文章把漏桶和令牌桶讲成两种不同的算法。「计量器版」漏桶不是。
计量器版漏桶:水位随时间匀速下降,每个请求加一滴水,超过容量就拒绝。 把它和令牌桶并排写:
// 令牌桶:令牌随时间增加,请求消耗
tokens = Math.min(cap, tokens + Δ); if (tokens >= 1) tokens -= 1;
// 漏桶(计量器版):水位随时间减少,请求增加
water = Math.max(0, water - Δ); if (water + 1 <= cap) water += 1;
⭐ 令 水位 = 容量 − 令牌,两式逐项相等 —— 它们是同一个算法的两种说法。
实测印证。6 种驱动 × 6 组 (rate, cap) 参数(10/10、10/1、10/50、
100/20、7/3、1000/100)逐次比对。「随机到达」是这样生成的:
function mulberry32(a) { // 每组实验各起各的种子
return () => { a = (a + 0x6d2b79f5) | 0; let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
function arrivals(seed, endMs, maxGap) { // 间隔取 [0, maxGap) 的随机整数毫秒
const r = mulberry32(seed), out = [];
for (let t = 0; t < endMs; ) { t += Math.floor(r() * maxGap); out.push(t); }
return out; // 种子 1/2:endMs=10000 maxGap=5
} // 种子 3:endMs=60000 maxGap=20
驱动 决策数 浮点分歧 整数刻度分歧
每 1ms 一次,10 秒 60006 0.59% (354) 0.00% (0)
每 1ms 一次,60 秒 360006 0.65% (2354) 0.00% (0)
每 3ms 一次,60 秒 120006 0.36% (438) 0.00% (0)
随机到达(种子1),10 秒 30090 1.13% (340) 0.00% (0)
随机到达(种子2),10 秒 30402 1.86% (566) 0.00% (0)
随机到达(种子3),60 秒 37926 2.70% (1025) 0.00% (0)
⭐ 整数刻度那一列六种驱动全是 0.00% —— 这一列才是结论: 它们确实是同一个算法。而浮点那一列 0.36% ~ 2.70% 全随驱动变, 报单独一个百分比没有意义。
📌 分歧不是算法差异,是浮点累积误差在 tokens >= 1 这条边界上来回翻。
⭐ 而且翻得很有规律:rate=10 cap=10 每 1ms 那组 195 次分歧里,
97 对是相邻两拍、方向相反、互相抵消的(t=300 令牌桶放行/漏桶拒,
t=301 反过来),净差只有 1 次。
👉 「分歧率 2%」听着吓人,落到长期放行总量上只差 1 次 ——
看指标要看净效应,别看翻转次数。
⚠️ 顺带一个实用结论:限流器这类「反复累加小增量再比阈值」的代码, 用整数刻度比用浮点稳 —— 否则同样的输入在不同机器、不同调用顺序下可能给出不同结果。
真正与令牌桶不同的是队列版漏桶:请求先入队,出口严格按固定速率放行。 它的特点是出口绝对匀速(保护下游),代价是请求要排队等待, 而令牌桶是「要么立刻放行、要么立刻拒绝」。
长期速率对不对
每毫秒都发一个请求(t=0 到 t=10000,共 10001 个),跑 10 秒,目标 10/s:
浮点实现 整数刻度
固定窗口 101 次 → 10.1/s 101 次 (不涉及浮点)
滑动日志 101 次 → 10.1/s 101 次 (不涉及浮点)
令牌桶 110 次 → 11.0/s 110 次 (多的 10 次是初始那一桶)
漏桶 109 次 → 10.9/s 110 次 ← 浮点下比令牌桶少 1
🚨 漏桶那个 109 别当成算法差异。 上一节刚证明它和令牌桶是同一个算法, 这里却少放行 1 次 —— 差的正是那 195 次成对分歧没抵消干净的最后一次。 整数刻度下两者都是 110,严丝合缝。
⭐ 这是这一篇里同一个浮点误差第三次冒头(前两次:3 秒表的 319、分歧率 2%)。 📌 判据:当两个你已经证明等价的实现给出不同的数,先怀疑数值实现,别怀疑等价性。
⭐ 四种的长期平均都对得上。它们的区别从来不在长期速率,而在短期形状 —— 选哪个取决于你能不能接受突发,以及能为每个 key 花多少内存。
怎么选
| 场景 | 选 |
|---|---|
| 单机、限额小、要精确 | 滑动日志 |
| 单机、限额大 | 滑动窗口计数(分格) |
| API 网关,允许合理突发 | ⭐ 令牌桶 |
| 保护下游(数据库、第三方) | 队列版漏桶 —— 出口匀速才是目的 |
| 只要能扛住、实现越简单越好 | 固定窗口,但必须知道它边界会放 2 倍 |
🚨 最后一行是重点:固定窗口不是不能用,是用之前要知道它的实际上限是 2N 而不是 N。 按 2N 去规划下游容量,它就是个够用的选择。
⚠️ 这一篇没有配套题
力扣上的限流题(日志速率限制器 359、敲击计数器 362)都是会员题,本站不收录。 而且限流在面试里几乎总是以问答出现,不是让你在判题器上写。
📌 想练的话,最有效的方式是把上面四种各实现一遍,然后复现那几个数字:
固定窗口在边界处放行 2 倍、令牌桶的瞬时突发恰好等于 cap、
放行总量 = rate×秒数 + cap、计量器版漏桶与令牌桶在整数运算下分歧为 0。
自己量出来一次,比背四种算法的定义有用得多。
⭐ 量的时候记住两条 —— 把驱动方式写下来(每毫秒一个?还是一次打 200 个?数字完全不同), 同时跑浮点版和整数版(对不上的那几处,多半是浮点不是算法)。
本章小结
四篇讲完了。回看这一章的主线 —— 每一个结构都放弃了一点确定性:
| 放弃 | 换来 | 代价可量化吗 | |
|---|---|---|---|
| 跳表 | 结构确定 | 免掉旋转 | ✅ 层高分布 = (1-p)p^(k-1) |
| 布隆过滤器 | 答案精确 | 空间 1/42 | ✅ 误判率 = (1-e^(-kn/m))^k |
| 一致性哈希 | 分布均匀 | 迁移 0 冗余 | ✅ 非必要迁移恒为 0 |
| 限流器 | 计数精确 | O(1) 状态 | ✅ 突发上限 = cap |
⭐ 最后一列才是关键。这些结构之所以能用在生产上,不是因为「差不多够」, 而是因为代价有公式、公式与实测对得上。 面试里被问到任何一个, 能把这个公式和它的实测偏差说出来,比背定义有说服力得多。