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

四个必须理解的点:

  1. 它必须越界一次才能发现边界:限额 300 时延迟 24ms,超过 SLO 20ms,于是回撤到 240。这 24ms 不是 bug,是“试探”的成本——不想越界,就永远不知道容量在哪。
  2. 稳态是锯齿,不是定值:240–300 之间来回,均值 267,略高于真实容量 250。接受不了这个抖动,就用固定阈值 + 人工容量规划。
  3. 反馈有延迟:延迟是上一个窗口测的,等它反映出来,超额的请求已经进来了。窗口越短越灵敏,但测量噪声越大。
  4. 多实例会互相打架:10 个实例各自试探同一个下游,每个都以为是自己在打满容量,结果总限额是单实例的 10 倍。自适应要么集中决策,要么用服务端信号(CPU、队列深度)而不是客户端观测的延迟。

什么时候别用自适应:延迟测量包含网络与客户端排队(测的不是服务端)、流量极低(样本不足)、下游是多租户共享(你的“容量”别人也在用)。这些情况下,我的判断是固定阈值 + 压测数据更可靠。

拒绝也要有礼貌

策略 做法 适合
快速拒绝 429 Too Many Requests + Retry-After 通用,客户端能退避
排队 有界队列等待,超时再拒 毫秒级突发
降级返回 返回缓存/默认值 读多写少(见 35.6 熔断与降级)
随机丢弃 按概率丢一部分 过载保护,避免“全拒”
优先级 核心接口/付费用户白名单 多业务共享一个入口

三条硬规则:

  1. 限流拒绝必须返回 429,不能返回 500。500 会被客户端当成故障触发重试,等于把限流变成放大器。
  2. 带 Retry-After,并让客户端遵守;没有它,客户端只能盲退避(见 35.5 超时、重试与退避)。
  3. 限流键要有业务含义:按 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)),用少量精度换内存。