35.7 限流 (漏桶 / 令牌桶 / 滑动窗口 / 自适应)
一句话
限流是给进入系统的速率设一个上限,把过载挡在门外;四种算法在“允不允许突发、内存多大、边界准不准、要不要看反馈”上各让一步——生产上通常是入口令牌桶 + 热点接口滑动窗口 + 全局并发/自适应兜底,而不是只挑一个。
为什么必须限流:过载时吞吐会下降
排队论的结论很反直觉:系统超过某个利用率之后,吞吐不再上升,延迟开始爆炸。M/M/1 的平均响应时间 R = S / (1 − ρ)(S 是单次服务时间,ρ 是利用率):
| 利用率 ρ | 平均延迟(S=5ms) | 相对空载 |
|---|---|---|
| 0.5 | 10ms | 2× |
| 0.7 | 16.7ms | 3.3× |
| 0.8 | 25ms | 5× |
| 0.9 | 50ms | 10× |
| 0.95 | 100ms | 20× |
延迟一涨,客户端超时、重试、连接堆积就跟着来(见 35.5 超时、重试与退避),有效吞吐反而下降。不限流的系统在过载时不是“慢”,是“全错”:需求 1000 QPS、真实容量 500 QPS,无保护时几乎每个请求都超过超时阈值,有效吞吐趋近 0;限流后是 500 个成功 + 500 个明确的 429。
🔑 限流的目标不是“拒绝请求”,而是让系统停在拐点以下,保住已完成工作的价值。拒绝 50% 的请求换来 500 QPS 的稳定服务,比“100% 请求都超时”好得多。拐点怎么找见 34.2 性能建模,压测怎么定见 34.5 性能测试与负载模型。
四种算法、六种实现,一张表看全
| 算法 | 突发容忍 | 内存 | 边界精度 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 固定窗口计数 | 允许接近 2× 限额 | O(1) | 差(窗口边界) | 极低 | 粗粒度防刷、日志量限制 |
| 滑动窗口计数 | 近似平滑 | O(1) | 较好(±1 窗口误差) | 低 | 通用 API 限流 |
| 滑动窗口日志 | 精确 | O(limit) | 精确 | 中 | 低频、高价值接口 |
| 漏桶 | 不允许突发,匀速输出 | O(1)+队列 | 精确 | 低 | 流量整形、保护下游 |
| 令牌桶 | 允许 burst 大小的突发 | O(1) | 精确 | 低 | 网关、开放 API |
| 自适应 | 跟随真实容量 | O(1) | 无静态阈值 | 高 | 自我保护、容量未知 |
固定窗口:窗口边界会放过两倍流量
固定窗口的实现最简单:给每个时间窗口一个计数器,窗口切换时清零。问题出在边界——窗口末尾和下一窗口开头是两段独立的计数:
限额 100/s,窗口按整秒对齐:
第 1 秒的第 900–999ms:放行 100 个(用完了本窗口配额)
第 2 秒的第 0–99ms: 又放行 100 个(新窗口,配额重置)
→ 200 个请求挤在 200ms 内通过,瞬时速率是限额的 10 倍
实测(限额 100/s,负载含两个各 100 个、分别骑在 900–999ms 与 1000–1099ms 的突发):
固定窗口 100/s 通过 250 拒绝 100 100ms 峰值 100 1s 峰值 195
滑动窗口计数 100/s 通过 204 拒绝 146 100ms 峰值 57 1s 峰值 105
任意 1 秒滑动窗口内的最大通过数是 195,接近限额的 2 倍。这个数字不是实现 bug,是“按自然秒切窗口”的数学后果:只要流量在窗口边界附近集中,就能合法地拿到两份配额。
固定窗口只适合“被刷了也不致命”的场景:登录验证码、短信、日志条数。
滑动窗口计数:O(1) 内存换近似精度
滑动窗口计数的做法是维护当前窗口和上一窗口两个计数,用上一窗口的残留比例来近似滑动:
估计值 = 上一窗口计数 × (1 − 当前窗口已过比例) + 当前窗口计数
例:上一秒 100 个,当前窗口过了 300ms,已放 40 个
估计值 = 100 × (1 − 0.3) + 40 = 110 → 超过 100,拒绝
它假设“上一窗口的请求在窗口内均匀分布”。流量平稳时误差很小(实测 1s 峰值 105,限额 100);流量突发集中时,它会高估或低估——上一窗口的 100 个如果全挤在最后 10ms,按比例折算只算 70 个,实际窗口内已经有 170 个了。
| 维度 | 固定窗口 | 滑动窗口计数 | 滑动窗口日志 |
|---|---|---|---|
| 内存 | O(1) | O(1) | O(limit) |
| 边界精度 | 2× 超发 | 误差约 1 个窗口的折算量 | 精确 |
| 突发型流量 | 差 | 中(会低估) | 好 |
| 适用限额 | 粗 | 通用 | 低频高价值 |
💡 想要“精确 + O(1)”可以上分层时间轮或 Redis 的
ZSET按时间戳计数(日志法),代价是内存随限额线性增长:限额 10 万 QPS 就要存 10 万个时间戳,一条 16 字节就是 1.6MB/key,通常不可接受。
漏桶 vs 令牌桶:整形还是放行突发
| 维度 | 漏桶 | 令牌桶 |
|---|---|---|
| 模型 | 请求进桶,恒定速率流出 | 令牌按速率进桶,请求取走令牌才放行 |
| 输出速率 | 严格等于漏出速率 | 长期平均等于令牌速率,短期可突发 |
| 突发 | 不允许(只能排队,队列满则丢弃) | 允许,大小由桶容量决定 |
| 额外延迟 | 有(排队等待流出) | 无(有令牌就立即放行) |
| 适合 | 保护脆弱下游、流量整形 | 入口限流、开放 API |
令牌桶的两个参数各有明确语义:
rate:长期平均速率,决定“一分钟能过多少”。burst:桶容量,决定“瞬时能放过多少”。
实测同一负载、rate=100:
令牌桶 rate=100 burst=100 通过 258 拒绝 92 100ms 峰值 105 1s 峰值 159
令牌桶 rate=100 burst=10 通过 168 拒绝 182 100ms 峰值 19 1s 峰值 69
读法:
burst=100时,桶在稳态期攒满 100 个令牌,突发来了直接放行 100 个——这就是“允许突发”的含义。burst=10时,同样的突发只放过 10 个,后面的请求即使总量没超也拿不到令牌(令牌被突发吃光了)。带宽没超,但公平性变差:突发流量会饿死后续的平稳流量。burst = rate是一个危险默认值:它意味着任意 1 秒窗口最多能通过rate + burst = 2 × rate个请求(先花攒下的,再用新补的)。实测 1s 峰值 159,理论上限 200。要求严格匀速就把burst设小或改用漏桶。
⚠️ 令牌桶的
burst不是“队列长度”,是“预支额度”。把burst配成rate的 10 倍(如 rate=100、burst=1000),等于允许 10 秒的流量在一瞬间打完——下游如果没有同样的缓冲能力,限流形同虚设。
分布式限流:单机 × N 的陷阱
最容易被忽略的一条:单机限流 × 实例数 ≠ 全局限流。
| 方案 | 全局限额 | 流量倾斜时的实际效果 | 成本 |
|---|---|---|---|
| 单机限流 | 实例数 × 单机限额(不可控) | 10 台各限 100、总需求 1000 倾斜成 A=500 + 其余共 500:A 放 100 拒 400,总通过约 595,额度只用 59.5% | 0 |
| 集中式(Redis + Lua) | 精确 | 精确 | 每请求 1 次 RTT(同机房约 0.2–1ms)+ Redis 容量 |
| 分片配额 | 每实例固定份额 | 倾斜时同样浪费 | 0 |
| 配额预取 | 近似全局 | 每次预取 N 个,精度损失 ≤ N × 实例数 | Redis QPS 降到 1/N |
集中式的代价要算清楚:入口 10 万 QPS,每次限流都打 Redis,就是 10 万 QPS 的 Redis 读——单 key 撑不住,要分片;而且Redis 本身成了新的故障点:限流组件挂了,要么全放(击穿下游),要么全拒(全站不可用)。所以生产上常见的是“本地限流为主 + 全局配额为辅”。
配额预取的账:每实例每次预取 50 个令牌,10 台机器就是最多 500 个令牌闲置(已领取未使用),在 1000 QPS 的限额下相当于 50% 的额度可能被浪费;预取 5 个则 Redis QPS 只降到 1/5,但精度提高。预取粒度 = 精度与 Redis 负载的取舍。
⚠️ 集中式限流还有一个隐坑:多实例用本地时钟判断窗口,时钟漂移 100ms 就能让窗口错位、超发。要么用 Redis 的
TIME,要么把窗口号作为参数传进去(逻辑时钟)。
落到 Redis:三种算法各自怎么实现
| 算法 | Redis 结构 | 关键命令 | 必须用 Lua 吗 |
|---|---|---|---|
| 固定窗口 | string | INCR key + EXPIRE key 1 | 是:INCR 与 EXPIRE 两条命令之间进程崩了,key 就永不过期 |
| 滑动窗口 | zset | ZREMRANGEBYSCORE 清过期 → ZCARD 计数 → ZADD 记一条 | 是:三步之间插进并发请求,计数会超发 |
| 令牌桶 | hash | HSET tokens/last_ts + Lua 里按流逝时间补令牌 | 是:读-算-写必须原子 |
滑动窗口的 Lua 骨架(把“清、数、加”三步做成一个原子操作):
-- KEYS[1]=限流 key ARGV[1]=当前毫秒 ARGV[2]=窗口毫秒 ARGV[3]=限额 ARGV[4]=本次消耗
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window) -- 1) 清掉窗口外
local used = tonumber(redis.call('ZCARD', KEYS[1])) -- 2) 数窗口内
if used + tonumber(ARGV[4]) > limit then
return 0 -- 3) 超限,拒绝
end
redis.call('ZADD', KEYS[1], now, now .. '-' .. math.random(1, 1e9))
redis.call('PEXPIRE', KEYS[1], window) -- 兜底:没人再来也能自动清
return 1
三个容易踩的点:
| 坑 | 为什么 |
|---|---|
| 不用 Lua,直接三条命令 | 中间插入并发请求 → 计数偏大、窗口被“重复计数”,实测能放过远超限额的流量 |
窗口 key 不设 PEXPIRE | 低峰期没人访问,key 永久残留,内存慢慢涨 |
| zset 每条请求一个 member | 内存是 O(限额):限额 10 万就是 10 万个 member;高频接口改用固定窗口或令牌桶(O(1)) |
💡 漏斗/漏桶的现成实现是 Redis 模块 Redis-Cell:
CL.THROTTLE key max_burst count_per_period period一行返回“是否放行、剩余容量、重试等待秒数”。代价是要装模块,云托管 Redis 未必开放。
⚠️ 集群下 key 要落在同一槽:滑动窗口的 key 若被
hash到不同分片,多条命令就无法原子。用 hash tag 把同一维度锁在同一槽:{user:123}:api:list——花括号内的部分参与哈希。
并发限流比 QPS 限流更贴近保护
QPS 只是代理指标,真正压垮服务的是并发。利特尔法则:并发 = QPS × RT。
限 1000 QPS,RT 20ms → 并发 = 1000 × 0.02 = 20 (很安全)
限 1000 QPS,RT 500ms → 并发 = 1000 × 0.5 = 500 (线程池/连接池直接爆)
依赖变慢时,QPS 限额不会自动收缩,但并发会按比例膨胀。所以更稳的做法是直接限并发:并发上限 50,RT 500ms 时自动把吞吐压到 50 / 0.5 = 100 QPS,RT 恢复 20ms 时又放回 50 / 0.02 = 2500 QPS(前提是外层没有更严的 QPS 限额)——限额随健康度自动伸缩,不需要重新调参。
| 限流维度 | 限的是什么 | 适合 | 失效场景 |
|---|---|---|---|
| QPS | 到达速率 | 入口、开放 API、计费 | 依赖变慢时并发爆炸 |
| 并发(在途数) | 同时处理的请求 | 内部服务、DB 访问层 | 请求极快时限制过松 |
| 连接数 | TCP 连接 | 长连接、DB 连接池 | 短连接场景意义不大 |
| 带宽 | 字节速率 | 下载、视频、CDN | 小包场景无意义 |
自适应限流:把阈值交给反馈
固定阈值有两个死穴:容量变了(扩容、依赖降级)阈值不会变;流量形态变了(突发比例)阈值也不对。自适应限流用延迟或队列的反馈来调限额,典型是 AIMD:
每隔一个窗口(如 200ms):
实测延迟 > 目标 → 限额 ×= 0.8 (乘法回撤,过载时快速退)
实测延迟 ≤ 目标 → 限额 += 10 (加法试探,空闲时慢慢涨)
实测(下游真实容量 250 QPS、SLO 20ms、需求 450 QPS):
t= 0ms 限额 50.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=1000ms 限额 100.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=2000ms 限额 150.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=3000ms 限额 200.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=4000ms 限额 250.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=5000ms 限额 300.0 实测延迟 24.0ms ↓ 收紧 (×0.8)
稳态后限额在 240–300 之间锯齿,均值 267 QPS
四个必须理解的点:
- 它必须越界一次才能发现边界:限额 300 时延迟 24ms,超过 SLO 20ms,于是回撤到 240。这 24ms 不是 bug,是“试探”的成本——不想越界,就永远不知道容量在哪。
- 稳态是锯齿,不是定值:240–300 之间来回,均值 267,略高于真实容量 250。接受不了这个抖动,就用固定阈值 + 人工容量规划。
- 反馈有延迟:延迟是上一个窗口测的,等它反映出来,超额的请求已经进来了。窗口越短越灵敏,但测量噪声越大。
- 多实例会互相打架:10 个实例各自试探同一个下游,每个都以为是自己在打满容量,结果总限额是单实例的 10 倍。自适应要么集中决策,要么用服务端信号(CPU、队列深度)而不是客户端观测的延迟。
什么时候别用自适应:延迟测量包含网络与客户端排队(测的不是服务端)、流量极低(样本不足)、下游是多租户共享(你的“容量”别人也在用)。这些情况下,我的判断是固定阈值 + 压测数据更可靠。
拒绝也要有礼貌
| 策略 | 做法 | 适合 |
|---|---|---|
| 快速拒绝 | 429 Too Many Requests + Retry-After | 通用,客户端能退避 |
| 排队 | 有界队列等待,超时再拒 | 毫秒级突发 |
| 降级返回 | 返回缓存/默认值 | 读多写少(见 35.6 熔断与降级) |
| 随机丢弃 | 按概率丢一部分 | 过载保护,避免“全拒” |
| 优先级 | 核心接口/付费用户白名单 | 多业务共享一个入口 |
三条硬规则:
- 限流拒绝必须返回 429,不能返回 500。500 会被客户端当成故障触发重试,等于把限流变成放大器。
- 带
Retry-After,并让客户端遵守;没有它,客户端只能盲退避(见 35.5 超时、重试与退避)。 - 限流键要有业务含义:按 IP 会被 NAT 后的整栋楼连坐;按用户 ID 才能保护“每个用户”的公平性;按租户 ID 才能做配额计费。维度选择见 47.4 鉴权与限流应放在哪一层。
可运行的实现
下面这段代码把固定窗口、滑动窗口计数、两种 burst 的令牌桶跑在同一份负载上,再用 AIMD 演示自适应限额的收敛过程。
package main
import (
"fmt"
"math"
"sort"
)
type limiter interface{ Allow(now int64) bool }
// ① 固定窗口:最省内存,但窗口边界处能放过接近两倍的流量
type fixedWindow struct {
limit int
win int64
count int
}
func (f *fixedWindow) Allow(now int64) bool {
if w := now / 1000; w != f.win {
f.win, f.count = w, 0
}
if f.count >= f.limit {
return false
}
f.count++
return true
}
// ② 滑动窗口计数:用「上一窗口按比例折算 + 当前窗口」近似真实滑动窗口
type slidingCounter struct {
limit int
win int64
cur, prev int
}
func (s *slidingCounter) Allow(now int64) bool {
w := now / 1000
if w != s.win {
if w > s.win+1 {
s.prev = 0 // 中间整秒没有请求,旧窗口早已过期
} else {
s.prev = s.cur
}
s.cur, s.win = 0, w
}
elapsed := float64(now%1000) / 1000
weighted := float64(s.prev)*(1-elapsed) + float64(s.cur)
if weighted >= float64(s.limit) {
return false
}
s.cur++
return true
}
// ③ 令牌桶:桶容量决定能放过多大的突发,速率决定长期平均
type tokenBucket struct {
rate, burst, tokens float64
last int64
}
func newTokenBucket(rate, burst float64) *tokenBucket {
return &tokenBucket{rate: rate, burst: burst, tokens: burst}
}
func (b *tokenBucket) Allow(now int64) bool {
b.tokens = math.Min(b.burst, b.tokens+float64(now-b.last)/1000*b.rate)
b.last = now
if b.tokens >= 1 {
b.tokens--
return true
}
return false
}
// trace 造 3 秒的到达序列:50 QPS 稳态 + 两个各 100 个的突发,
// 两个突发恰好骑在 1s 窗口边界(900–999ms 与 1000–1099ms)上。
func trace() []int64 {
var ts []int64
for ms := int64(0); ms < 3000; ms += 20 {
ts = append(ts, ms) // 50 QPS
}
for i := int64(0); i < 100; i++ {
ts = append(ts, 900+i)
ts = append(ts, 1000+i)
}
sort.Slice(ts, func(i, j int) bool { return ts[i] < ts[j] })
return ts
}
// maxIn 返回通过时间戳里任意 width 毫秒窗口内的最大通过数
func maxIn(accepted []int64, width int64) int {
best, left := 0, 0
for right := range accepted {
for accepted[right]-accepted[left] >= width {
left++
}
if right-left+1 > best {
best = right - left + 1
}
}
return best
}
func run(name string, l limiter, ts []int64) {
var ok []int64
for _, t := range ts {
if l.Allow(t) {
ok = append(ok, t)
}
}
fmt.Printf("%-22s 通过 %4d 拒绝 %4d 100ms 峰值 %4d 1s 峰值 %4d\n",
name, len(ok), len(ts)-len(ok), maxIn(ok, 100), maxIn(ok, 1000))
}
func main() {
ts := trace()
fmt.Printf("负载:%d 个请求 / 3s,含两个骑在窗口边界上的 100 请求突发;限额 100 QPS\n\n", len(ts))
run("固定窗口 100/s", &fixedWindow{limit: 100}, ts)
run("滑动窗口计数 100/s", &slidingCounter{limit: 100}, ts)
run("令牌桶 rate=100 burst=100", newTokenBucket(100, 100), ts)
run("令牌桶 rate=100 burst=10", newTokenBucket(100, 10), ts)
// ④ 自适应:不知道下游容量,只看延迟反馈,用 AIMD 调限额
fmt.Println("\n自适应限额(下游真实容量 250 QPS,SLO 20ms,需求 450 QPS)")
limit := 50.0
var hist []float64
for step := 0; step < 30; step++ { // 每 200ms 调整一次,共 6s
offered := math.Min(450, limit)
lat := 4.0
if offered > 250 {
lat = 4 + (offered-250)*0.4 // 超过容量后延迟随超额线性上升
}
hist = append(hist, limit)
if step%5 == 0 {
verdict := "↑ 放宽 (+10)"
if lat > 20 {
verdict = "↓ 收紧 (×0.8)"
}
fmt.Printf(" t=%4dms 限额 %5.1f 实测延迟 %5.1fms %s\n", step*200, limit, lat, verdict)
}
if lat > 20 {
limit *= 0.8 // 乘法下降:过载时快速回撤
} else {
limit += 10 // 加法上升:空闲时缓慢试探
}
}
min, max, sum := hist[20], hist[20], 0.0
for _, v := range hist[20:] {
min, max, sum = math.Min(min, v), math.Max(max, v), sum+v
}
fmt.Printf(" 稳态后限额在 %.0f–%.0f 之间锯齿,均值 %.0f QPS\n", min, max, sum/float64(len(hist[20:])))
}
输出:
负载:350 个请求 / 3s,含两个骑在窗口边界上的 100 请求突发;限额 100 QPS
固定窗口 100/s 通过 250 拒绝 100 100ms 峰值 100 1s 峰值 195
滑动窗口计数 100/s 通过 204 拒绝 146 100ms 峰值 57 1s 峰值 105
令牌桶 rate=100 burst=100 通过 258 拒绝 92 100ms 峰值 105 1s 峰值 159
令牌桶 rate=100 burst=10 通过 168 拒绝 182 100ms 峰值 19 1s 峰值 69
自适应限额(下游真实容量 250 QPS,SLO 20ms,需求 450 QPS)
t= 0ms 限额 50.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=1000ms 限额 100.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=2000ms 限额 150.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=3000ms 限额 200.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=4000ms 限额 250.0 实测延迟 4.0ms ↑ 放宽 (+10)
t=5000ms 限额 300.0 实测延迟 24.0ms ↓ 收紧 (×0.8)
稳态后限额在 240–300 之间锯齿,均值 267 QPS
注意“通过数”这一列不能直接比较大小:滑动窗口计数只通过 204 个,比令牌桶少,但它的 1s 峰值是 105——它拒绝得多,是因为它把“上一窗口的 50 QPS 稳态流量”也折算进了当前窗口,更严格、更平滑。限流器没有“谁更好”,只有“谁更符合你要的形状”。
参数怎么给
| 你的情况 | 算法 | 关键参数 |
|---|---|---|
| 开放 API、按 key 计费 | 令牌桶 | rate = 套餐额度,burst = 套餐允许的瞬时突发 |
| 保护脆弱的下游(老 DB、第三方) | 漏桶 | 输出速率 = 下游容量的 70%,队列有界 |
| 通用接口防刷 | 滑动窗口计数 | 窗口 1s,限额按 P99 容量 |
| 秒杀/抢购入口 | 令牌桶 + 并发限流 | burst 小(如 1s 额度),并发上限按库存/DB 容量 |
| 内部服务互调 | 并发限流 | 上限 = 线程池/连接池大小 × 0.8 |
| 容量不确定、经常扩容 | 自适应 | 目标延迟 = SLO × 0.7,窗口 100–500ms |
跨篇相关:限流与熔断的分工(入口 vs 出口)见 35.6 熔断与降级;过载时先限流还是先扩容,取决于拐点与成本,见 34.5 性能测试与负载模型;入口层该不该做限流、放在网关还是服务内,见 47.4 鉴权与限流应放在哪一层;DDoS 场景的流量清洗见 13.2 DDoS 防护。
反模式
| 反模式 | 后果 |
|---|---|
| 固定窗口当精确限流 | 窗口边界放过 2× 流量(实测 195/100) |
burst = rate 且以为“每秒最多 rate 个” | 任意 1s 窗口可过 2× rate |
burst 配成 rate 的 10 倍 | 10 秒流量一瞬间打完,下游被击穿 |
| 单机限流 × 10 台当成全局限流 | 全局限额随实例数漂移:扩到 20 台就变成 2000;流量倾斜时额度只能用 60%,热点实例还在拒请求 |
| 限流拒绝返回 500 | 客户端当故障重试,限流变成放大器 |
限流不带 Retry-After | 客户端盲重试,退避全靠猜 |
| 只限 QPS 不限并发 | 依赖变慢时并发膨胀,线程池爆 |
| 按 IP 限流做用户级公平 | NAT 后整栋楼共享一个 IP,一人超限全员被封 |
| 本地时钟做分布式窗口 | 时钟漂移导致窗口错位、超发 |
| 限流组件本身无降级 | Redis 挂了要么全放要么全拒 |
| 自适应在多实例上各自为战 | 总限额 = 单实例 × 实例数,下游被打穿 |
自检
Q1 限额 100 QPS 的固定窗口,实测任意 1 秒内最多通过了 195 个。是计数器写错了吗?如果业务要求“任意 1 秒都不超过 100”,怎么改?
▸ 答案:不是 bug,是窗口边界。固定窗口按整秒切分,第 1 秒末尾用掉 100 个额度、第 2 秒开头额度重置又能放 100 个,两批请求挤在 200ms 内通过,任意 1s 滑动窗口就能看到接近 200 个。要求“任意 1 秒”必须用真正的滑动窗口:滑动窗口计数(O(1),近似,实测峰值 105)或滑动窗口日志(精确,O(limit) 内存);令牌桶的 burst 也要压到很小,否则它允许的突发同样会突破 1s 限额。
Q2 10 台实例,每台配 100 QPS 单机限流,全局限额是多少?如果流量倾斜(A 实例吃 500 QPS,其余几乎为 0),实际能通过多少?
▸ 答案:理论上限 1000 QPS,但不可控。总需求 1000、倾斜成「A 吃 500,其余 9 台共 500」时:A 只能放 100、拒 400,其余 9 台共放约 495,总计通过约 595,全局限额只被用掉 59.5%——同时 A 上的 400 个请求被白白拒绝。反过来,流量均匀时它又允许 1000,等于限额随分布浮动。要精确控制得用集中式(Redis + Lua)或配额预取;后者的代价是预取的令牌可能闲置(精度损失 ≤ 预取粒度 × 实例数)。
Q3 你们按 QPS 限流,限额 1000。某天下游 RT 从 20ms 涨到 500ms,限流器显示“未超限”,但服务线程池爆了。为什么?
▸ 答案:QPS 限的是到达速率,不是同时处理的请求数。并发 = QPS × RT:正常时 1000 × 0.02 = 20,RT 涨到 500ms 后 1000 × 0.5 = 500 个请求同时在途,线程池/连接池被占满,新请求连排队的机会都没有。限流器看不到这个变化,因为它只数“每秒进来多少”。修法是加并发限流(在途数上限 50),它会在 RT 变长时自动把吞吐压到 100 QPS,RT 恢复后自动放开。
Q4 自适应限流实测在 240–300 之间锯齿,还出现过一次 24ms(超过 SLO 20ms)。这是实现得不好吗?怎么让它更稳?
▸ 答案:不是。自适应必须越界一次才能知道边界在哪——限额 250 时延迟还是 4ms(容量刚好 250),它继续加到 300 才看到 24ms,然后乘法回撤到 240。锯齿来自“加法试探 + 乘法回撤”的组合,是有意的:加法保证慢速逼近,乘法保证过载时快速退出。想更稳可以:① 缩小试探步长(+2 而不是 +10);② 用延迟的移动分位数而不是单窗口值;③ 加“死区”(延迟在目标的 0.8–1.0 倍之间不调整);④ 多实例改成集中决策,避免各自试探叠加。
Q5 限流拒绝时返回 429 和返回 500,对系统的影响有什么本质区别?为什么 Retry-After 不是可选项?
▸ 答案:429 表示“请求合法,但你现在太快了”,客户端应按退避重试;500 表示“服务出错了”,客户端会当成故障,可能立即重试或告警,把限流变成流量放大器——本来只想拒 20%,结果 20% 的拒绝触发了 3 倍重试。Retry-After 把“该等多久”从客户端的猜测变成服务端的指令;没有它,客户端只能指数退避,在限流持续时会产生大量无效重试(见 35.5 超时、重试与退避)。
Q6 用 Redis 做滑动窗口限流,为什么“清过期 + 计数 + 写入”必须放进一个 Lua 脚本?
▸ 答案:因为三条命令之间不是原子的。并发请求会在 ZCARD 之后、ZADD 之前插进来,两个请求都读到“未超限”,于是都放行——限额 100 实际放过 120。Lua 在 Redis 里单线程执行整段脚本,把“清、数、加”变成一个原子操作,才不会有这个窗口。
Q7 滑动窗口限流的内存为什么是 O(限额),什么场景该换算法?
▸ 答案:因为窗口内每个请求都要留一条 member(ZADD 一条),窗口内最多 limit 条,所以内存随限额线性增长:限额 10 万 ≈ 10 万个 member 加 zset 结构开销。高频、大限额的接口应改用固定窗口(INCR,O(1))或令牌桶(hash 存 tokens/last_ts,O(1)),用少量精度换内存。