主题
限流算法详解
一句话:限流用一个人为设定的速率上限保护系统,算法决定「超出上限的请求是排队、拒绝还是允许短时突发」,参数决定误杀和过载的边界。
适用:Nginx 1.24+、Sentinel 1.8+、Guava 33.x、Redis 7.x;默认值以各版本文档为准。
为什么需要
下游的处理能力有上限。流量超过上限时,如果不主动拒绝,延迟会先上升,然后连接池耗尽、线程堆积,最终整个服务不可用。限流的作用是在过载前把请求挡掉一部分,让剩下的请求还能在可接受延迟内完成。
限流不是「越多越好」:阈值太低会误杀正常用户,太高则起不到保护作用。它需要和容量、排队策略、降级方案一起设计。
核心机制
固定窗口与临界突刺
每秒限 100 次,计数器按自然秒重置。如果第 1 秒的最后 0.2 秒来了 100 个请求,第 2 秒的前 0.2 秒又来了 100 个,0.4 秒内实际通过了 200 个,是限制值的两倍。
窗口切得再小,边界仍然存在。只要窗口是离散且独立的,临界时刻就可能叠加两个窗口的额度。
滑动窗口
把 1 秒切成 N 个小格,统计最近 1 秒内所有格子的计数,旧格子滑出后不再计入。Sentinel 默认用 2 个格子、总窗口 1000ms;格子越细,统计越平滑,内存和计算也越多。
Sentinel 的 LeapArray 用环形数组复用格子:时间前进时重置旧格子,O(1) 定位和统计,不需要后台线程。
漏桶:恒定速率流出
请求先进入桶(队列),桶以固定速率把请求放行。桶满后新请求被拒绝或丢弃。
- 出口速率恒定,能把突发流量削平。
- 不允许突发:即使下游有空闲,也只能按固定速率走。
- 队列会引入延迟;队列越长,超时和内存风险越大。
Nginx 的 limit_req 是漏桶模型:rate 是流出速率,burst 是允许排队的突发量;加 nodelay 后,突发量会立即放行而不是排队,行为更接近「允许一定突发的令牌桶」。不加 nodelay 时,超出速率的请求会排队,超过 burst 才拒绝。
令牌桶:允许突发
系统以固定速率往桶里放令牌,桶有容量上限。请求拿到令牌才能通过;没有令牌就拒绝或等待。
- 流量低谷时令牌会攒起来,高峰时可以短时透支,所以允许突发。
- 平均速率仍受令牌生成速率限制,突发上限是桶容量。
- Guava
RateLimiter是令牌桶:SmoothBursty默认最多存 1 秒的令牌(maxBurstSeconds=1.0),SmoothWarmingUp在此基础上加预热。
四种算法对照
| 算法 | 突发流量 | 出口形态 | 典型实现 |
|---|---|---|---|
| 固定窗口 | 临界时刻可翻倍 | 离散计数 | 简单计数器 |
| 滑动窗口 | 可调,较平滑 | 最近窗口统计 | Sentinel 默认 |
| 漏桶 | 不允许,超出排队/拒绝 | 恒定速率 | Nginx limit_req、队列 |
| 令牌桶 | 允许,上限为桶容量 | 平均速率 + 突发 | Guava RateLimiter、Gateway RedisRateLimiter |
选型先问:下游能不能接受突发?能,用令牌桶;不能,用漏桶。需要精确统计最近窗口,用滑动窗口。
分布式限流
单机限流每个节点各算各的,节点数一多,全局额度就是「单机额度 × 节点数」,流量倾斜时还会误杀。全局限流要把计数放到共享存储,常见做法是 Redis + Lua 令牌桶:
lua
-- KEYS[1]=limit key; ARGV: rate/s, capacity, now_ms, requested
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or capacity)
local last = tonumber(redis.call('HGET', KEYS[1], 'last') or now)
local delta = math.max(0, now - last)
tokens = math.min(capacity, tokens + delta * rate / 1000)
if tokens >= requested then
redis.call('HSET', KEYS[1], 'tokens', tokens - requested)
redis.call('HSET', KEYS[1], 'last', now)
redis.call('PEXPIRE', KEYS[1], 60000)
return 1
end
return 0每次请求多一次 Redis 往返,延迟和 Redis 可用性都成了新问题。折中是「本地令牌桶 + 定期从 Redis 同步配额」,精度换性能;同步间隔越长,各节点越容易短暂超额。
实战场景
一、固定窗口的临界突刺,把下游打回半秒一个坑
现象:网关对某接口配“100 QPS”,实现是自然秒计数重置。大促开始那一刻,前一秒最后 200ms 通过 100 个请求、新一秒前 200ms 又通过 100 个——0.4 秒内实际放行 200 个。下游服务的连接池只有 30,直接报 cannot obtain connection,熔断打开,用户侧出现成片失败。
排查:窗口离散且独立时,临界叠加是必然,与窗口切多小无关。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 实际通过速率 | 网关放行日志按 100ms 粒度统计,而非按秒 | 秒内出现 2× 即命中临界突刺 |
| 计数实现 | 是按自然秒重置还是滑动窗口 | 重置式计数即固定窗口 |
| 下游承载曲线 | 连接池等待、线程池拒绝与突刺时间点 | 对齐即因果成立 |
| 窗口粒度成本 | 格子越细统计越平滑,内存与计算也越多 | 不能靠“把窗口切到毫秒”无限做 |
| 单机 vs 集群 | 单机阈值 × 实例数是否远超下游容量 | 见第三场景 |
text
Sentinel 默认把 1000ms 总窗口切成 2 个格子(LeapArray 环形数组复用,
时间前进时重置旧格子,O(1) 定位与统计);格子越细越平滑,成本越高。对症做法:
yaml
# 1. 换滑动窗口统计(Sentinel 已是默认形态),不要用自研自然秒计数
flow:
- resource: order-create
grade: 1 # QPS
count: 100
controlBehavior: 2 # 匀速排队用于削峰,快速失败用于保护(见下)nginx
# 2. Nginx 层用漏桶 + burst 控制瞬时并发
limit_req_zone $server_name zone=api:10m rate=100r/s;
location /api/order {
limit_req zone=api burst=80 nodelay; # 允许 80 的突发立即放行,超出才拒绝
limit_req_status 429; # 明确 429,别返回 500 触发客户端重试
}text
3. 阈值按下游容量倒算,而不是按“接口现在的量”:
允许并发 ≈ 下游连接池大小 × 可接受排队时间 ÷ 单请求耗时,
留 20%~30% 余量(经验值,需压测校准)。
4. 阈值要能在运行时改:来自配置中心,变更灰度到部分实例后全量;
写死的阈值等于在真实故障面前没有工具。复盘要点:判据是“下游连接池等待率不因为瞬时突刺上升”,并配一条“亚秒级通过速率”指标(每 100ms 一个桶)。把“按秒统计的 QPS”当成健康口径是这类问题的来源——它天然把突刺抹平,只有更细的粒度才能暴露临界叠加。
二、漏桶排队把超时窗口耗尽,客户端重试雪上加霜
现象:为避免突发,接口用“匀速排队”模式(漏桶),队列上限 5000、恒定放行 200/s。流量高峰时队列排到 3000+,请求平均等待 15 秒,而网关超时是 5 秒:大量请求在网关侧超时返回,客户端按“超时=可重试”再发一遍,队列进一步加深,最终拒绝数暴涨。
排查:漏桶出口速率恒定,队列长度直接变成用户等待时间。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 队列长度与等待 | 限流组件的排队数、平均等待时长 | 等待接近超时的 1/3 即危险 |
| 超时与排队关系 | 客户端超时 vs maxQueueingTimeMs | 排队上限必须小于上游超时,否则白排 |
| 失败语义 | 被限流的响应码与是否带 Retry-After | 返回 500 会触发无条件重试 |
| 重试放大 | 同一 traceId 的下游调用次数 | >1 说明超时被重试放大 |
| 线程占用 | 排队是否占用容器线程 | 排队型实现会把线程拖满 |
| 下游空闲度 | 下游实际利用率 | 漏桶不允许突发,空闲也用不上 |
对症做法:
yaml
# 1. 排队时间上限必须显著小于上游超时;超过就直接拒绝,而不是继续排队
flow:
- resource: report-export
count: 200
controlBehavior: 2 # 匀速排队
maxQueueingTimeMs: 800 # 上游超时 5s,这里只允许排 800msjava
// 2. 拒绝要给出确定语义:429 + Retry-After,客户端据此退避
@ExceptionHandler(BlockException.class)
ResponseEntity<Void> onBlock(BlockException e) {
metrics.blocked(e.getClass().getSimpleName());
return ResponseEntity.status(429)
.header("Retry-After", String.valueOf(ThreadLocalRandom.current().nextInt(1, 4)))
.build();
}text
3. 需要“下游空闲时能吃突发”的场景不要选漏桶,用令牌桶:
桶容量决定允许的突发量,速率决定长期上限。
4. 排队型限流不占业务线程才安全:
在入口层排队(网关/中间件),而不是在应用线程里 sleep 等待。
5. 重试策略要区分:429 按 Retry-After 退避且最多 1 次,
5xx/超时的重试要检查幂等键——限流期间的重试是放大器。复盘要点:判据是“被限流请求的客户端重试率接近 0”,这条依赖响应语义(429 + Retry-After)而不是依赖培训。同时要说清算法取舍:漏桶削峰最稳但不允许突发、队列带来延迟与内存风险;令牌桶允许突发但瞬时压力交给下游;滑动窗口计数简单但需要配拒绝策略。选错算法的表现是“限流生效了但业务更差”。
三、单机阈值 × 实例数:扩容把总量放大成三倍
现象:应用侧限流配“单机 50 QPS”,4 个实例合计 200 QPS,与下游容量匹配。弹性扩容到 12 个实例后,总放行量变成 600 QPS,下游数据库连接池被打满,报表服务与在线交易一起超时。缩容后又恢复正常——问题只在扩容当天出现。
排查:单机阈值的总量随实例数漂移,这不是限流失效,而是语义错配。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 总放行量 | 按服务聚合的放行 QPS vs 单机阈值 × 实例数 | 相等即单机语义在起作用 |
| 实例数变化 | HPA 事件与总量上升时间是否对齐 | 对齐即扩容引入 |
| 下游容量 | 连接池大小、Threads_running、等待队列 | 决定总量上限的真实依据 |
| 限流层级 | 网关层是否有总量控制 | 只有应用层单机限流必然漂移 |
| 集群限流成本 | Token Server 可用性与额外一跳 | 精确总量的代价 |
对症做法:
yaml
# 方案 1(最省事):网关按服务维度做总量限流,与实例数无关
routes:
- id: report-api
predicates: [ Path=/api/report/** ]
filters:
- name: RequestRateLimiter
args: { redis-rate-limiter.replenishRate: 200, redis-rate-limiter.burstCapacity: 260,
key-resolver: "#{@tenantKeyResolver}" }
# 方案 2:需要全局精确值时用集群限流,接受 Token Server 的可用性成本
flow:
- resource: report-query
count: 200
clusterMode: true
clusterFailFallback: true # Token Server 不可用时退回单机阈值,不能整体拒绝
clusterThresholdType: 1 # 全局阈值text
3. 单机阈值要按“实例数上限”折算,而不是按当前实例数:
单机阈值 = 下游容量 ÷ maxReplicas × 安全系数(例如 0.8,经验值需校准)。
4. 与弹性伸缩联动:改 HPA 的 maxReplicas 时同步评审限流阈值,
两者写在同一份容量配置里。
5. Token Server 故障时“退回单机”优于“整体拒绝”,
但要保证退回后的总量仍不超过下游能承受的上限,
否则宁可降级为部分请求失败。复盘要点:判据是“下游观测到的入口 QPS 有硬上限,且不随实例数变化”。这条最好由网关或集群限流承担——把总量控制放在应用层单机上,等于把容量约束交给“当前有几个实例”这个最易变的量。多租户场景还要按租户维度限流,避免单客户吃满全局额度,见 单元化架构。
最小上手
Nginx 按 IP 限流(漏桶,429 而不是默认 503):
nginx
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;
server {
location /api/ {
limit_req zone=api burst=200 nodelay;
limit_req_status 429;
}
}Guava 令牌桶:
java
RateLimiter limiter = RateLimiter.create(100.0); // 100 个令牌/秒
if (!limiter.tryAcquire(50, TimeUnit.MILLISECONDS)) {
// 返回 429 或降级结果
}验证:用 ab/wrk 或脚本并发打满,观察 200 和 429 的比例;nodelay 去掉后再压一次,观察延迟是否被排队拉长。
生产动作
- 状态码:限流拒绝返回 429,并带
Retry-After;Nginx 默认limit_req_status是 503,要显式改成 429。 - 阈值来源:先压测出单实例安全 QPS,再留 20%–30% 余量;不要直接抄别人的数字。
- 排队策略:能接受延迟就排队(漏桶),不能就快速拒绝(令牌桶/滑动窗口);排队要有最大等待时间,否则请求会堆到超时。
- 预热:新实例刚启动时用
SmoothWarmingUp或逐步放量,避免冷系统被瞬时打满。 - 维度:按 IP、用户、API、租户分别限流;只按 IP 会误伤 NAT 后的用户,只按用户挡不住匿名流量。
- 分布式:Redis 限流要监控延迟和失败;Redis 不可用时降级到本地限流,不能直接放行。限流 key 的 TTL 要大于桶从空到满的时间,否则过期重置会放大额度。
- 排障:先看限流命中数和被限流的 key 分布,再判断阈值是否过低、key 是否倾斜。
边界
- 限流不等于熔断:限流控制入口速率,熔断根据错误率/延迟拒绝下游调用;两者要配合。
- 隔离(线程池/信号量)也不等于限流:隔离限制某类请求占用的并发资源,限流限制速率;一个慢依赖仍可能拖垮共享线程池,需要单独隔离。
- 漏桶不会让流量「更快」,只会让它更平滑;下游空闲时也不会加速。
- 令牌桶的突发上限是桶容量,不是无限透支;桶太大等于没有限流。
- 单机限流在弹性扩缩容下会漂移;全局精确限流又要接受 Redis 延迟。
- 固定窗口的临界突刺不会因为「调小阈值」消失,只是把问题挪到更细的边界。
讲清楚
限流先看下游能不能接受突发。固定窗口实现最简单,但窗口边界可能通过两倍额度;滑动窗口把窗口切细,统计最近一段时间的请求,Sentinel 默认就是这个思路。漏桶让请求排队、以恒定速率流出,适合保护不能被打突发打崩的下游;令牌桶按固定速率放令牌、允许攒到桶容量,适合网关这种需要容忍短时突发的入口。Guava RateLimiter 是令牌桶,Nginx limit_req 是漏桶。多节点要全局限流就用 Redis + Lua,但要接受每次请求多一次网络往返,或者用本地桶加定期同步配额做折中。