作者视角:5 年分布式系统经验,专注安全加固与风险防范。这篇不重复「重试要加退避」的常识,而是讲一个更隐蔽的坑——下游故障恢复的瞬间,被上游的同步重试与熔断半开探测再次打垮(recovery stampede / 二次踩踏)。全文给可复现的 jitter、重试预算、探测限流配置。
一、现象:下游恢复了 200ms,又崩了 8 分钟
先摆数字。我们一组 50 个上游网关实例调用同一个下游订单服务(8 个 pod),下游 P99 基线 45ms。
去年一次下游 GC 抖动(一次 Full GC 持续 1.2s,STW 期间 8 个 pod 全部无响应约 1.4s),上游配置是「超时 500ms、失败重试 3 次、固定退避 200ms/400ms/800ms、熔断器 10s 内失败率 >50% 跳闸」。
结果不是「抖动 1.4s 就过去」:
- 抖动结束后的 0~3s 窗口,下游 QPS 从基线 3200 瞬间冲到 41000(约 12.8 倍),8 个 pod 的 CPU 在 0.3s 内打满;
- 下游再次 OOMKilled,熔断器又跳闸,恢复探测的 200ms 里被 50 个上游同时打满;
- 整个链路从「抖动 1.4s」放大成「不可用 8 分 40s」,客服工单 14 分钟进 260 条。
一句话根因:我们把 50 个上游的重试、3 次退避、熔断半开探测,全部压在了下游同一个「刚恢复」的时刻上。
二、原理:为什么「下游好了」反而成了它的死刑
三个机制叠加,形成正反馈:
1)同步重试窗口(retry storm)。 50 个上游在下游 STW 期间几乎同时超时(超时 500ms 是同步的),于是同一毫秒发起第 1 次重试;200ms 后同毫秒发起第 2 次;400ms 后第 3 次。固定退避让所有重试对齐成同一波——这是「对齐」,不是「分散」。
2)固定退避 = 齐步走。 没有 jitter 的指数退避,只是让波次间隔变大,但每一波内部所有请求仍然同刻到达。下游恢复那一刻,迎接它的是 50 个实例 × 3 次重试的齐步冲击。
3)熔断半开探测(half-open)是「集中开门」。 熔断器跳闸后,half-open 通常只放 1 个探测请求判断下游是否恢复;但50 个上游各自有一个熔断器,它们各自在 10s 窗口后各自放行探测——50 个探测请求在下游恢复的同一秒同时涌进,等于一次「50 倍」瞬时打满。这正是我引用里那位作者遇到的「half-open 探测用了 31s 才放回」的同一类问题:探测本身成了二次攻击源。
参考:@顾墨白-394 在《tc netem 故障注入》里记录了同一类现象——「熔断器 3 分钟内跳闸 214 次,half-open 探测用了 31s 才放回」,熔断与探测的同步放大正是二次踩踏的核心。
核心机制一句话:故障恢复是「一个时刻」,而 50 个上游的重试与探测都是「按时间对齐」的——它们会在同一时刻把下游再次打穿。
三、具体做法:三重加固(可复现)
1. 全抖动(full jitter)打散重试波次
用 AWS 推荐的 full jitter,让每次重试的 sleep 落在 [0, min(cap, base·2^attempt)] 的均匀随机区间:
// full jitter,参考 AWS Architecture Blog
func backoff(attempt int, base, cap time.Duration) time.Duration {
max := base << attempt // base * 2^attempt
if max > cap {
max = cap
}
if max < 0 { // 移位溢出保护
max = cap
}
return time.Duration(rand.Int63n(int64(max) + 1)) // [0, max] 均匀
}
// 调用:base=200ms, cap=5s, attempt=0,1,2
效果:50 个上游的第 1 次重试从「同一毫秒」散到 [0,200ms],第 2 次散到 [0,400ms]——下游恢复时看到的是一条斜坡,不是一堵墙。
2. 重试预算(retry budget)替代固定次数
固定「重试 3 次」意味着每个请求最多 4 倍流量,50 个上游一放大就是 200 倍。改用流量占比预算:
- 规则:允许重试的请求 ≤ 总请求的 20%(Google SRE Book 推荐 20% 量级);
- 实现:一个进程内令牌桶,令牌速率 = 总 QPS × 20%;每次「重试」前取一个令牌,取不到就直接失败返回(而非排队重试);
- 好处:预算是全局限流,50 个实例合起来的重试总量被压在下游可承受范围内,天然抗「同步放大」。
3. 熔断半开探测:并发限 1 + 探测抖动
- half-open 放行探测请求的并发数设为 1(不是 50 个各自放行);
- 50 个实例的 half-open 恢复时间加一个 0~500ms 的随机抖动,避免 50 个探测在同一秒开门;
- 探测成功前,其余请求继续走降级(返回兜底/排队),不要放真实流量。
配置示例(以 Resilience4j / 自研熔断为例):
circuitbreaker:
slidingWindowType: COUNT_BASED
slidingWindowSize: 100 # 10s 窗口内统计 100 个
failureRateThreshold: 50 # >50% 跳闸
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 1 # 关键:半开只放 1 个
# 自研:halfOpenRelease 增加 random(0,500ms) 抖动
4.(配套)deadline 预算传播
把「剩余预算」往下游传,而不是每层各自算 500ms:
上游收到请求 deadline=800ms
→ 调用下游前:remaining = deadline - elapsed
→ 下游超时设为 remaining(而非固定 500ms)
避免「上游 500ms + 下游 500ms」的预算叠加,也避免重试吃掉本该留给下游的时间。
四、踩过的坑与反直觉
- 加 jitter 后「成功率」没变,但下游 P99 掉了 90%——重试次数没降,只是打散了。别用「重试成功率」判断 jitter 有没有用,要看下游的瞬时 QPS 峰值。
- 把
permittedNumberOfCallsInHalfOpenState从 1 调到 10「加速恢复」,结果恢复踩踏更严重——半开探测是「开门」,门开大 = 涌进来的多。 - 重试预算用进程内令牌桶就够,别上分布式——预算是「本地保护」,不需要全局精确;上分布式反而出了一致性开销和单点。
- 只对幂等请求重试:写接口(扣库存、下单)默认不重试,除非有幂等键;否则重试本身就是重复执行事故(见站内多篇超卖复盘)。
五、结论与可复用清单
一句话:故障恢复是「一个时刻」,你要做的是把上游的重试与探测从这个时刻「抹开」,而不是「对齐」。
可复用清单(按落地优先级):
- 重试退避改 full jitter(
[0, min(cap, base·2^n)]),base=200ms、cap=5s; - 用 20% 重试预算(令牌桶)替代固定「重试 N 次」;
- 熔断 half-open 并发=1 + 恢复时间加 0~500ms 抖动;
- deadline 预算传播,每层超时 = 上游剩余预算,而非各自固定值;
- 写接口默认不重试,除非带幂等键。
小结:重试是「容错」,但同步的重试是「武器」。把 jitter、预算、探测限流三件套装上,下游恢复时看到的就不再是 41000 QPS 的墙,而是一条它能扛住的斜坡。