支付渠道只是变慢,没有进程崩溃,也没有机器断电。五分钟后整个平台却一起瘫痪——每一层都想替用户再试一次,善意在调用链末端乘成了风暴。
创业第 2 年 2 个月,「秒抢」累计用户约 100 万。公司 95 人,后端 18 人。周五 19:40,第三方支付渠道的响应从 180ms 抖到 2.4s;健康检查仍是绿色,成功请求也仍在返回。五分钟后,订单、用户和场次接口却一起超时。
你拉出调用链:网关调用订单,订单调用支付编排,支付编排调用渠道。三层客户端都配置了「失败最多尝试 3 次」,超时后立即重试,没有 backoff,也没有 jitter。入口只有约 2,010 QPS,末端却出现约 54,200 次尝试,接近 27 倍。
支付渠道的慢请求占住支付线程;支付编排的等待占住订单线程;订单超时又触发网关重试。线程池和连接池被“稍后还会再试”的请求填满,健康接口拿不到资源。故障已经不再属于支付渠道,而是沿着共享资源向上游传播。
C 是这一步的正解:先切断放大器。 熔断让新请求不再进入已变慢的渠道,停止链路内重试则立即压低末端尝试;可以切备用的支付走备用,不能切的明确失败。只要不再制造新重试,积压才有机会排空。
A 会把事情按乘法变得更糟。
3 层每层 5 次的最坏放大是 5³ = 125,不是只多两次。渠道已经慢,更多尝试会占住更多线程,让本来可能成功的请求也排到超时。
B 是把问题往下游推,不是解决它。 重启能清掉本机待发事件,但入口和上游重试仍在;新实例刚就绪就会重新接收风暴。多实例同时重连下游,还可能制造一轮同步尖峰。
D 方向没错,但救不了现在。 联系渠道是必要动作,支付线程也可能需要调优;可扩线程只会允许更多慢调用同时堆在渠道上。先熔断和停重试,再判断本地容量是否仍是瓶颈。
调用链上的重试不是相加关系。若每一层在下游失败后都独立尝试,上一层的一次重试会重新触发下一层的整组尝试。层数越多,末端收到的请求越接近各层尝试次数的乘积。
代入现场:网关、订单、支付编排共 3 层,每层最多尝试 3 次,末端最坏放大为 3 × 3 × 3 = 27 倍。入口 2,000 QPS 便可能变成 54,000 次/秒渠道调用;一次局部变慢,被自己的“可靠性策略”放大成全链路过载。说“重试 3 次”时先问清楚是 3 次还是 4 次——工程惯例里“重试 3 次”常指首次 + 3 次重试 = 4 次尝试,三层最坏是 4³=64,不是 27;差一层就是 27 与 64 的区别。
backoff 和 jitter 改变的是到达时间,不是数学上已经允许的尝试总量。立即重试会让请求挤在同一小段时间里;指数 backoff 把第 2、3 次尝试推迟,峰值下降、波形拉宽;jitter 再给等待加随机扰动,把同一时刻超时的客户端打散。
| 策略 | 末端波形 | 累计尝试 | 解决什么 |
|---|---|---|---|
| 立即重试 | 同一时刻堆出陡峭尖峰 | 接近完整预算 | 只适合极短、极低概率的瞬态失败 |
| backoff | 峰值降低,波形向时间轴摊开 | 总量变化不大 | 给下游恢复时间,降低瞬时压力 |
| backoff + jitter | 同步波峰被随机打散,尖刺更低 | 仍受重试预算约束 | 避免客户端在相同退避点集体醒来 |
重排波形还不够,系统需要三类护栏:
| 护栏 | 机制 | 关键状态或预算 |
|---|---|---|
| 熔断器 | 失败率越线后停止调用,等待后少量探测 | Closed 正常 → Open 快速失败 → Half-open 探测恢复 |
| 舱壁 | 为支付渠道、订单查询等依赖分开线程池与并发槽 | 一个依赖耗尽自己的舱位,不借走健康功能资源 |
| 重试预算 | 把额外尝试限制为原始请求的一小部分 | 按时间窗统计 retry / original,超预算便停止重试 |
熔断不是限流。限流在依赖健康时也按容量控制进入量;熔断根据近期失败判断某条依赖是否应暂时停止调用。舱壁也不负责判断成败,它只保证支付渠道的等待不会借走用户资料接口的线程。三者叠在一起,才构成传播边界。
第 5 章的冷缓存死亡螺旋与重试风暴属于同一族:触发点消失后,系统自己制造的负载仍可能让它留在坏状态。附录 No.16「亚稳态故障」会给这种“不再需要外部推力也无法恢复”的状态单独建档。第 14 章则会教你从 metrics、traces 和 logs 里辨认是谁先慢、请求在哪里被放大。
三张卡经常连着出现:先有重试量自持续爬升,再有同一退避时刻的惊群尖刺,最后共享资源耗尽,服务从下游向上游逐层熄灭。处方必须同时限制总量、打散时间并隔离资源。
把入口请求沿调用链逐层展开:尝试次数在末端相乘,backoff 与 jitter 只重排请求到达时间,不会凭空消掉请求。
weight 表示期望请求量,不是逐个离散请求。波形图上的数值是一条连续期望曲线 → 真实系统里请求是离散事件,会有到达相关性和批次效应 → 因此不要把图上的整数刻度当成某一毫秒的真实并发请求数,它演示的是放大倍数的形状,不是精确计数。
这些简化是故意的:模拟器的目的是隔离一个因果关系,不是预测生产数值。
实验清单里的每条现象都由 tools/simN-*-model-test.js 的断言保证,
但保证的是这个模型里的现象。
重试本身不是错误;没有明确责任层才危险。入口统一重试便于控制总预算,每层重试能处理局部瞬态,指定最接近失败边界的一层则减少重复。三个选择都能成立,前提是幂等、deadline 和预算写得足够清楚。
A · 只在入口层:末端放大容易计算,重试次数、deadline 和用户反馈集中治理。 账单:入口离具体失败最远,可能分不清网络抖动、业务拒绝与不可重试错误;一次入口重试会重做整条链。适合调用链短、内部服务稳定、错误语义可统一映射的场景。
B · 每层都重试:各层可以就近处理独立的短暂网络故障,局部恢复快。 账单:必须共享端到端尝试预算、幂等键和剩余 deadline,否则立刻回到乘法放大;治理与观测复杂。适合层数少、故障域独立、重试协议成熟的内部基础设施。
C · 单一责任层:最了解错误码和副作用的调用者决定是否重试,既避免入口盲试,也避免层层叠加。 账单:要在架构评审中明确 owner,跨团队变更可能让责任漂移;该层仍需预算、backoff 与 jitter。适合支付、库存等错误语义复杂且副作用明确的链路。
「秒抢」的选择:采用 C。支付编排是渠道错误语义的 owner,只允许它对明确可重试的网络失败尝试一次;网关与订单层不再自动重试支付。理由是支付有副作用,入口无法判断“超时但已扣款”,而每层重试的 27 倍账单已经在 19:45 交过一次。
回放 19:45 的正确剧本——若顺序倒过来,任何扩容和重启都会被新的重试重新淹没:
切断放大。对异常渠道打开熔断,关闭网关与订单层支付重试;末端尝试从约 54,200 QPS 在一分钟内降到 7,400 QPS。
隔离舱壁。将支付渠道调用移回独立线程池,限制并发;用户资料、场次和普通订单查询释放共享线程,非支付接口成功率回到 98.1%。
排空积压。不重启服务,让待发事件按新预算自然过期;pending retries 从 18.6 万降到 1.2 万,order-service P99 从 6.2s 回到 480ms。
半开探测。熔断器进入 Half-open,只放少量带 jitter 的探测;连续成功后逐步恢复渠道流量,未出现同步尖峰。
入口流量稳定,末端请求与超时率却呈 ▁▂▄█████,触发故障停止后仍在高位一段时间。最可能是?
A。原始流量没涨,系统内部尝试却自持续爬升,是 No.07 的核心证据。B 很诱人,因为击穿也会产生尖峰,但击穿通常是单个热点 key 的瞬时竖刺,不是待发尝试维持的高平台。
4 层调用链,每层最多尝试 2 次。末端最坏放大倍数是多少?
C。2 × 2 × 2 × 2 = 16。A 是最诱人的错误项,它把 4 层 × 2 次做成了加法;重试会重新触发后续整条链,所以必须乘起来。
熔断器在 Open 一段时间后,只允许少量请求探测下游是否恢复。此时是什么状态?
C。Half-open 用少量探测决定回到 Closed 还是重新 Open。B 很诱人,因为熔断仍未完全恢复,但 Open 应快速拒绝常规调用,不承担恢复探测流量。
已经用了指数 backoff,为什么还要加入 jitter?
B。相同 backoff 仍会让同时失败的请求在相同刻度醒来,jitter 用随机扰动拆散这一群。A 是诱人的误解:jitter 改的是时间分布,不负责取消已获准的尝试,总量仍要靠重试预算控制。