系统是被打出来的 · 系统设计实战手册
PART 2 · 缓存与流量 · 第 7 章

重试风暴与级联故障
好心办坏事

支付渠道只是变慢,没有进程崩溃,也没有机器断电。五分钟后整个平台却一起瘫痪——每一层都想替用户再试一次,善意在调用链末端乘成了风暴。

07 · 1 值班现场19:40,一个下游开始慢;19:45,全站开始倒

创业第 2 年 2 个月,「秒抢」累计用户约 100 万。公司 95 人,后端 18 人。周五 19:40,第三方支付渠道的响应从 180ms 抖到 2.4s;健康检查仍是绿色,成功请求也仍在返回。五分钟后,订单、用户和场次接口却一起超时。

PagerDuty · P0 · 触发于 19:45:09
[CRITICAL] payment-channel P99 2.4s,timeout 41%
[CRITICAL] terminal payment attempts 54,200 QPS(入口支付 2,010 QPS)
[WARNING]  order-service worker busy 100%,pending retries 18.6 万
──────────────────────────────────────
阿凯:
「进程都活着,探活全绿,但每个服务都在等别的服务!」

你拉出调用链:网关调用订单,订单调用支付编排,支付编排调用渠道。三层客户端都配置了「失败最多尝试 3 次」,超时后立即重试,没有 backoff,也没有 jitter。入口只有约 2,010 QPS,末端却出现约 54,200 次尝试,接近 27 倍。

入口支付请求
2,010 QPS
● 没有突发流量
调用层数 / 尝试
3 层 × 3 次
● 层层重试
末端尝试
54,200 QPS
▲ 约 27 倍
真实组件故障
0 台宕机
● 全部过载等待

支付渠道的慢请求占住支付线程;支付编排的等待占住订单线程;订单超时又触发网关重试。线程池和连接池被“稍后还会再试”的请求填满,健康接口拿不到资源。故障已经不再属于支付渠道,而是沿着共享资源向上游传播。

决策点 D1

此刻是 19:45。你的第一个动作是什么?

C 是这一步的正解:先切断放大器。 熔断让新请求不再进入已变慢的渠道,停止链路内重试则立即压低末端尝试;可以切备用的支付走备用,不能切的明确失败。只要不再制造新重试,积压才有机会排空。

A 会把事情按乘法变得更糟。 3 层每层 5 次的最坏放大是 5³ = 125,不是只多两次。渠道已经慢,更多尝试会占住更多线程,让本来可能成功的请求也排到超时。

B 是把问题往下游推,不是解决它。 重启能清掉本机待发事件,但入口和上游重试仍在;新实例刚就绪就会重新接收风暴。多实例同时重连下游,还可能制造一轮同步尖峰。

D 方向没错,但救不了现在。 联系渠道是必要动作,支付线程也可能需要调优;可扩线程只会允许更多慢调用同时堆在渠道上。先熔断和停重试,再判断本地容量是否仍是瓶颈。

07 · 2 原理每层都多试一次,末端按乘法收账

调用链上的重试不是相加关系。若每一层在下游失败后都独立尝试,上一层的一次重试会重新触发下一层的整组尝试。层数越多,末端收到的请求越接近各层尝试次数的乘积。

末端放大倍数 = ∏( 每层尝试次数 )

代入现场:网关、订单、支付编排共 3 层,每层最多尝试 3 次,末端最坏放大为 3 × 3 × 3 = 27 倍。入口 2,000 QPS 便可能变成 54,000 次/秒渠道调用;一次局部变慢,被自己的“可靠性策略”放大成全链路过载。说“重试 3 次”时先问清楚是 3 次还是 4 次——工程惯例里“重试 3 次”常指首次 + 3 次重试 = 4 次尝试,三层最坏是 4³=64,不是 27;差一层就是 27 与 64 的区别。

1 层 × 3 次
3 倍
2,000 → 6,000
2 层 × 3 次
9 倍
2,000 → 18,000
3 层 × 3 次
27 倍
2,000 → 54,000
3 层 × 5 次
125 倍
D1 的错误加码

backoff 和 jitter 改变的是到达时间,不是数学上已经允许的尝试总量。立即重试会让请求挤在同一小段时间里;指数 backoff 把第 2、3 次尝试推迟,峰值下降、波形拉宽;jitter 再给等待加随机扰动,把同一时刻超时的客户端打散。

策略 末端波形 累计尝试 解决什么
立即重试 同一时刻堆出陡峭尖峰 接近完整预算 只适合极短、极低概率的瞬态失败
backoff 峰值降低,波形向时间轴摊开 总量变化不大 给下游恢复时间,降低瞬时压力
backoff + jitter 同步波峰被随机打散,尖刺更低 仍受重试预算约束 避免客户端在相同退避点集体醒来

重排波形还不够,系统需要三类护栏:

护栏 机制 关键状态或预算
熔断器 失败率越线后停止调用,等待后少量探测 Closed 正常 → Open 快速失败 → Half-open 探测恢复
舱壁 为支付渠道、订单查询等依赖分开线程池与并发槽 一个依赖耗尽自己的舱位,不借走健康功能资源
重试预算 把额外尝试限制为原始请求的一小部分 按时间窗统计 retry / original,超预算便停止重试

熔断不是限流。限流在依赖健康时也按容量控制进入量;熔断根据近期失败判断某条依赖是否应暂时停止调用。舱壁也不负责判断成败,它只保证支付渠道的等待不会借走用户资料接口的线程。三者叠在一起,才构成传播边界。

可靠性机制也要有爆炸半径。重试若没有单一责任层、端到端预算和随机退避,就不是保险,而是一张允许系统在最脆弱时追加负债的信用卡。

第 5 章的冷缓存死亡螺旋与重试风暴属于同一族:触发点消失后,系统自己制造的负载仍可能让它留在坏状态。附录 No.16「亚稳态故障」会给这种“不再需要外部推力也无法恢复”的状态单独建档。第 14 章则会教你从 metrics、traces 和 logs 里辨认是谁先慢、请求在哪里被放大。

07 · 3 病历卡放大、同步与逐层熄灭

三张卡经常连着出现:先有重试量自持续爬升,再有同一退避时刻的惊群尖刺,最后共享资源耗尽,服务从下游向上游逐层熄灭。处方必须同时限制总量、打散时间并隔离资源。

重试风暴

病历卡 No.07
Retry Storm · 失败请求产生更多失败请求
机理
下游变慢或超时后,上游立即重试;多层独立重试按乘法放大末端请求。新增尝试继续占住线程与连接,造成更多超时,又生成下一轮重试。
症状
▁▂▄█████ 自持续爬升型。原始入口流量稳定,末端 QPS、待发重试和超时率却持续上升;触发方恢复后,积压也可能让高位维持一段时间。
易发场景
调用链多层配置默认重试;超时后无 backoff;非幂等请求也重试;客户端、网关和服务各自只看局部成功率,没有端到端预算。
处方
① 指定重试责任层:链路只保留一个主要重试者;② 重试预算:限制额外尝试占比;③ backoff + jitter:降低同步峰;④ 熔断:下游持续失败时快速停止调用。
案例
「秒抢」入口支付约 2,010 QPS,三层各尝试 3 次后,渠道端出现约 54,200 QPS;待发重试升到 18.6 万,原始流量没变,系统却被自己产生的尝试压满。

惊群效应

病历卡 No.08
Thundering Herd · 大量等待者在同一时刻醒来
机理
大量请求同时超时,使用相同固定退避后又在同一时刻醒来;它们争抢刚恢复的少量资源,再次打满下游,形成周期性同步冲击。
症状
▁▁█▁▁█▁ 周期同步尖刺型。QPS 和连接建立数按固定间隔出现尖峰,峰与统一超时、缓存 TTL、定时任务或固定 backoff 周期对齐。
易发场景
客户端使用相同固定重试间隔;服务恢复时积压请求同时放行;锁释放后大量等待线程竞争;同批 TTL 或定时任务在整点触发。
处方
① 随机抖动:在退避时间上加入扰动;② 慢放量:恢复后逐步提高并发;③ 请求合并:相同工作只让一个执行者完成;④ 错峰:TTL 与定时任务加入随机分布。
案例
「秒抢」第一版客户端统一等待 1 秒再试,渠道 QPS 每秒冒出一根尖刺;加入指数 backoff 后尖峰摊开,再加入 ±50% jitter,客户端不再在同一个刻度集体醒来。

级联故障

病历卡 No.09
Cascading Failure · 一个依赖过载拖走整条调用链
机理
下游变慢占住上游线程、连接和队列;上游无法服务其他请求,又让更上游等待与重试。共享资源把局部故障传播成多服务不可用,即使各进程仍能通过探活。
症状
███▇▅▃▁ 上游先亡、逐层熄灭型。调用链靠近故障点的服务先出现长尾,随后上游可用率按依赖顺序下降;探活可能仍绿,业务吞吐却接近归零。
易发场景
服务共用线程池或连接池;下游无超时和熔断;同步调用链过长;重试风暴放大流量;健康检查不覆盖真实业务依赖。
处方
① 熔断:失败越线后停止调用;② 舱壁隔离:每个依赖拥有独立资源上限;③ 限流降级:在传播前减量;④ 端到端超时:上游放弃后取消下游工作。
案例
「秒抢」支付渠道只是变慢,支付、订单、网关却依次耗尽工作线程;用户资料等无关接口也因共享池排队。没有组件宕机,平台仍在五分钟内从局部慢退化为全站超时。

07 · 4 模拟器好心如何乘成一场风暴

把入口请求沿调用链逐层展开:尝试次数在末端相乘,backoff 与 jitter 只重排请求到达时间,不会凭空消掉请求。

RETRY STORM SIMULATOR · 秒抢调用链 02:47:00
末端到达 QPS 期望放大倍数(虚线)
期望放大
—
末端瞬时到达
—
累计末端请求
—
待发重试事件
—

🧪 3 个实验(按顺序做)

  1. 看乘法。保持每层 3 次、故障率 100%,把层数设为 1、2、3 并分别注入 → 末端放大依次为 3、9、27(误差小于 5%)。重试是指数放大,不是逐层相加。
  2. 摊开尖峰。保持 3 层 × 3 次,先关闭 backoff 注入,再重置、开启 backoff 注入 → 累计请求数变化不到 10%,但峰值下降超过 40%。backoff 治瞬时峰,不治总量。
  3. 打散同步。保持 backoff 开启,再打开 jitter 并重置注入 → 峰值严格低于只开 backoff 时。±50% 扰动打散同刻重发,是惊群效应的解药。

⚖️ 这个模型简化了什么

这些简化是故意的:模拟器的目的是隔离一个因果关系,不是预测生产数值。 实验清单里的每条现象都由 tools/simN-*-model-test.js 的断言保证, 但保证的是这个模型里的现象。

07 · 5 权衡重试权应该交给谁

重试本身不是错误;没有明确责任层才危险。入口统一重试便于控制总预算,每层重试能处理局部瞬态,指定最接近失败边界的一层则减少重复。三个选择都能成立,前提是幂等、deadline 和预算写得足够清楚。

决策点 D2

支付调用链的重试应该放在哪里?

A · 只在入口层:末端放大容易计算,重试次数、deadline 和用户反馈集中治理。 账单:入口离具体失败最远,可能分不清网络抖动、业务拒绝与不可重试错误;一次入口重试会重做整条链。适合调用链短、内部服务稳定、错误语义可统一映射的场景。

B · 每层都重试:各层可以就近处理独立的短暂网络故障,局部恢复快。 账单:必须共享端到端尝试预算、幂等键和剩余 deadline,否则立刻回到乘法放大;治理与观测复杂。适合层数少、故障域独立、重试协议成熟的内部基础设施。

C · 单一责任层:最了解错误码和副作用的调用者决定是否重试,既避免入口盲试,也避免层层叠加。 账单:要在架构评审中明确 owner,跨团队变更可能让责任漂移;该层仍需预算、backoff 与 jitter。适合支付、库存等错误语义复杂且副作用明确的链路。

「秒抢」的选择:采用 C。支付编排是渠道错误语义的 owner,只允许它对明确可重试的网络失败尝试一次;网关与订单层不再自动重试支付。理由是支付有副作用,入口无法判断“超时但已扣款”,而每层重试的 27 倍账单已经在 19:45 交过一次。

07 · 6 复盘先停重试,再等系统喘气

回放 19:45 的正确剧本——若顺序倒过来,任何扩容和重启都会被新的重试重新淹没:

19:45

切断放大。对异常渠道打开熔断,关闭网关与订单层支付重试;末端尝试从约 54,200 QPS 在一分钟内降到 7,400 QPS。

19:47

隔离舱壁。将支付渠道调用移回独立线程池,限制并发;用户资料、场次和普通订单查询释放共享线程,非支付接口成功率回到 98.1%。

19:51

排空积压。不重启服务,让待发事件按新预算自然过期;pending retries 从 18.6 万降到 1.2 万,order-service P99 从 6.2s 回到 480ms。

19:58

半开探测。熔断器进入 Half-open,只放少量带 jitter 的探测;连续成功后逐步恢复渠道流量,未出现同步尖峰。

壹
尝试次数要按链路算。某个服务“只试三次”听起来克制,三层叠起来却是 27 倍;评审必须画到末端。
貳
backoff 降峰,预算降总量。只有退避而没有预算,请求只是晚一点到;两者再配 jitter,才同时控制总数与同步波形。
叁
共享资源就是传播通道。为每个高风险依赖设置舱壁、熔断和超时,让支付变慢时只失去支付,不要把用户资料也一起拖走。

07 · 7 自测你的好心会放大多少倍

Q1 · 看形状诊断

入口流量稳定,末端请求与超时率却呈 ▁▂▄█████,触发故障停止后仍在高位一段时间。最可能是?

A。原始流量没涨,系统内部尝试却自持续爬升,是 No.07 的核心证据。B 很诱人,因为击穿也会产生尖峰,但击穿通常是单个热点 key 的瞬时竖刺,不是待发尝试维持的高平台。

Q2 · 放大算术

4 层调用链,每层最多尝试 2 次。末端最坏放大倍数是多少?

C。2 × 2 × 2 × 2 = 16。A 是最诱人的错误项,它把 4 层 × 2 次做成了加法;重试会重新触发后续整条链,所以必须乘起来。

Q3 · 熔断三态

熔断器在 Open 一段时间后,只允许少量请求探测下游是否恢复。此时是什么状态?

C。Half-open 用少量探测决定回到 Closed 还是重新 Open。B 很诱人,因为熔断仍未完全恢复,但 Open 应快速拒绝常规调用,不承担恢复探测流量。

Q4 · jitter 的价值

已经用了指数 backoff,为什么还要加入 jitter?

B。相同 backoff 仍会让同时失败的请求在相同刻度醒来,jitter 用随机扰动拆散这一群。A 是诱人的误解:jitter 改的是时间分布,不负责取消已获准的尝试,总量仍要靠重试预算控制。

🎯 面试映射 · 本章直接覆盖的高频追问

「重试为什么会把小故障放大?」 答题骨架:画调用层 → 写乘积公式 → 代入 3×3×3=27 → 解释资源占用制造更多超时 → 给单一责任层、预算与幂等前提。
「熔断和限流有什么区别?」 答题骨架:限流按容量控制进入量 → 熔断按失败状态停止某依赖 → Closed/Open/Half-open → 两者与降级、舱壁组合。
「backoff 的 jitter 有什么用?」 答题骨架:backoff 把峰向后摊开 → 相同参数仍会同步醒来 → jitter 随机打散 → 说明它不减少总量。反直觉加分点:预算与 jitter 分别管总数和波形。