下游服务只是抖了一下,我们配的"失败就重试三次"反而把它彻底打死了,而且越打越死、再也起不来:一次重试风暴压垮下游的深度复盘
那次雪崩是从一次小抖动开始、却演变成长时间不可用的:我们的服务调用一个下游服务,我"很负责任"地给调用配了重试——"失败了就重试,最多三次",觉得这样更健壮、能扛住下游的临时抖动。可线上某次,下游只是因为一次 GC/小尖峰短暂抖动了一下(变慢、少量失败),结果诡异的事发生了:下游不但没缓过来,反而被彻底打死、雪崩了,而且越打越死、迟迟起不来,影响面远超那次小抖动本身。我复盘整个过程,才看明白,后背发凉:是我那个"失败就重试三次",在下游抖动时变成了一场"重试风暴(retry storm)",亲手把下游推向了雪崩。机制是一个致命的正反馈:下游抖动→部分请求失败/超时→上游对每个失败的请求重试(最多 3 次)→瞬间请求量放大到原来的好几倍;而本就脆弱(刚抖动、处理能力下降)的下游,被这突然放大几倍的流量,从"抖一下"直接打成了"大面积失败";失败更多→重试更多→流量更大→下游更崩……形成正反馈:越重试越压垮、越压垮越失败、越失败越重试;更糟的是,如果调用链有多层、每层都配了重试,放大是指数级的(3×3×3=27 倍),下游被瞬间冲垮、且持续承受着重试洪流、根本没机会恢复。根本原因是:简单粗暴的"失败就重试"(固定次数、无退避、不区分错误、多层叠加),在下游已经过载/抖动时,会用成倍放大的流量把它彻底压垮,并形成阻碍其恢复的正反馈。问题的根,是盲目重试(固定次数、无退避、不分错误类型、多层叠加)把下游的小抖动放大成了压垮它的重试风暴,正反馈致雪崩且难恢复。这篇就把这次"重试风暴"的坑,从头到尾复盘一遍。
故障现场:失败就重试,把下游越打越死
问题在于简单的"失败就重试"在下游抖动时放大流量、形成正反馈:
// ✗ 出问题的重试: 失败就立即重试, 固定3次, 不区分错误、无退避
public Response call(Request req) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
return downstream.invoke(req); // 调下游
} catch (Exception e) {
// ✗ 失败了立刻重试(无退避), 不管是什么错误都重试
if (i == maxRetry - 1) throw e;
}
}
throw new RuntimeException();
}
// 雪崩的正反馈过程:
// 1. 下游抖动(GC/尖峰), 变慢、部分请求失败/超时;
// 2. 上游对每个失败请求立即重试(最多3次) → 瞬间请求量放大到 ~3倍;
// 3. 本就处理能力下降的下游, 被突然3倍的流量进一步压垮 → 更多请求失败;
// 4. 更多失败 → 更多重试 → 流量更大 → 下游更崩 → ... 正反馈雪崩!
// 5. 下游持续被重试洪流冲击, 一直缓不过来 → 长时间不可用(越打越死)。
// 雪上加霜:
// - 多层调用链每层都重试: A→B→C, 每层3次 → 到C是 3×3=9倍甚至更多(指数放大);
// - 无退避: 失败后立刻重试, 洪峰更集中、更猛;
// - 不分错误类型: 连"4xx参数错误/业务失败"这种重试也没用的, 也重试(纯属浪费+放大);
// - 不可重试的操作重试: 非幂等操作重试还可能重复执行(同541篇)。
// 关键: "失败就重试"看似增强健壮性, 但在下游抖动/过载时, 重试会成倍放大流量、把下游彻底压垮,
// 并形成"越重试越崩、越崩越重试"的正反馈雪崩 —— 盲目重试反而是雪崩的放大器。
第一次想明白"是我的重试把一次小抖动放大成了压垮下游的洪流"时,我又懊恼又警醒:"我配重试,本意是让系统更能扛下游的抖动、更健壮;完全没想到它在下游最脆弱的时候,反而成了压垮它的最后一根稻草、还越压越死。"这个坑最反直觉的地方在于:重试本是"提高健壮性"的好意,可它在"下游已经过载"这个特定时刻,从"救命"变成了"催命";而且平时(下游健康)重试确实有用、没问题,只在下游抖动时才暴露其破坏性,且一旦触发就正反馈失控。下面就来拆解,重试该怎么做才不会变成风暴。
第一件事:搞懂重试为什么会变成风暴
我顺着这次事故,把重试的双刃性和正确姿势彻底理清了。
重试为什么会从"提高健壮性"变成"压垮下游的风暴"?
【核心: 重试在下游抖动时成倍放大流量、形成正反馈雪崩; 要用退避+jitter、限次数、只对可重试错误、配熔断、控重试预算、别多层叠加】
1. 重试的本意 vs 失控:
- 本意: 应对"偶发的、瞬时的"失败(网络抖动、个别超时), 重试一下常能成功 → 提高成功率;
- 失控: 当失败是"下游过载/系统性"的, 重试不但救不了, 反而火上浇油。
2. 重试风暴的正反馈:
- 下游过载 → 请求失败 → 上游重试(放大流量) → 下游更过载 → 更多失败 → 更多重试 → ...
- 这是一个【正反馈环】: 一旦进入, 会自我强化、越来越糟, 且阻碍下游恢复;
- 多层调用链每层重试 → 放大是【指数级】的(N层各重试k次 → k^N倍)。
3. 盲目重试的几个问题:
- 无退避(立刻重试): 失败的请求扎堆瞬间重试, 洪峰集中、更猛;
- 不区分错误: 4xx(参数错/业务拒绝)重试也没用, 还白白放大; 只该重试"可能瞬时恢复"的错(超时/5xx/连接失败);
- 固定多次+多层叠加: 放大倍数大;
- 不可重试操作重试: 非幂等操作重复执行(同541篇)。
4. 正确的重试姿势:
- ① 指数退避 + 随机抖动(jitter): 重试间隔逐次拉长(如1s,2s,4s)+加随机, 错开重试、给下游喘息、避免同步洪峰;
- ② 限制重试次数: 别无限重试; 次数少(如1-2次)且配合退避;
- ③ 只对"可重试错误"重试: 超时/5xx/连接失败可重试; 4xx/业务失败别重试;
- ④ 配合熔断器: 下游连续失败到阈值就"熔断"(一段时间内直接快速失败、不再打下游), 给它恢复时间;
- ⑤ 重试预算/限流: 限制"重试流量占总流量的比例"(如不超过10%), 防止重试洪流;
- ⑥ 别每层都重试: 调用链上选一层重试(通常靠近边缘/客户端), 别层层叠加指数放大;
- ⑦ 重试的操作要幂等(同541篇)。
5. 更底层的理念: 重试是"乐观"手段, 要给系统"减压"留余地, 而非"加压"
- 系统过载时, 正确的反应是"减少压力"(熔断、降级、限流), 而不是"重试加压"。
一句话: 盲目重试(无退避/不限次/不分错误/多层叠加)会在下游过载时成倍放大流量、形成正反馈雪崩;
正确做法是指数退避+jitter、限次数、只重试可重试错误、配熔断、控重试预算、别每层都重试、操作要幂等。
这套认知,是整个坑的根。重试的本意 vs 失控:本意是应对偶发瞬时失败(重试常能成功);失控是当失败是下游过载/系统性的,重试反而火上浇油。重试风暴的正反馈:下游过载→失败→重试放大流量→更过载→更多失败→更多重试,是个自我强化的正反馈环,多层调用链每层重试更是指数级放大(k^N 倍)。盲目重试的问题:无退避(扎堆瞬间重试)、不区分错误(4xx 重试也没用)、固定多次+多层叠加、不可重试操作重试。正确姿势:①指数退避+随机 jitter ②限制次数 ③只对可重试错误(超时/5xx)重试 ④配熔断器 ⑤重试预算/限流 ⑥别每层都重试 ⑦操作要幂等。更底层:系统过载时正确反应是"减压"(熔断/降级/限流)而非"重试加压"。一句话:盲目重试(无退避/不限次/不分错误/多层叠加)会在下游过载时成倍放大流量、形成正反馈雪崩;正确做法是指数退避+jitter、限次数、只重试可重试错误、配熔断、控重试预算、别每层都重试、操作要幂等。
第二件事:正解——指数退避+jitter、限次数、只重试可重试错误、配熔断
搞懂了原理,正解就清晰了:重试用指数退避+随机抖动、限制次数、只对可重试错误重试;配合熔断器(连续失败就快速失败给下游喘息);控制重试预算;别每层都重试。
// ====== 正解一: 指数退避 + jitter + 只重试可重试错误 ======
public Response call(Request req) {
int maxRetry = 2; // 次数少
long base = 200; // 基础退避200ms
for (int i = 0; i <= maxRetry; i++) {
try {
return downstream.invoke(req);
} catch (Exception e) {
if (!isRetryable(e) || i == maxRetry) throw e; // ★ 只重试可重试错误; 用尽次数就抛
long backoff = base * (1L << i) // 指数退避: 200,400,800ms
+ ThreadLocalRandom.current().nextLong(100); // ★ 加随机jitter, 错开重试
sleep(backoff);
}
}
throw new RuntimeException();
}
// 只重试"可能瞬时恢复"的错误: 超时/连接失败/5xx; 不重试4xx/业务失败
boolean isRetryable(Exception e) {
return e instanceof TimeoutException || e instanceof ConnectException
|| (e instanceof HttpException he && he.status() >= 500);
}
# ====== 正解二: 配合熔断器(给下游恢复的机会) ======
# - 用熔断器(如Resilience4j/Sentinel/Hystrix)包住对下游的调用:
# * 统计失败率/慢调用率; 连续失败到阈值 → "熔断"(打开): 一段时间内【直接快速失败, 不再打下游】;
# * 给下游一段"没有流量冲击"的时间去恢复; 之后半开探测, 恢复了再关闭熔断;
# - 熔断的本质: 在下游撑不住时, 主动"掐断"流量给它减压, 而非用重试给它加压。
# ====== 正解三: 重试预算 + 别多层重试 ======
# - 重试预算(retry budget): 限制"重试请求"占总请求的比例(如<10%), 用token bucket控制;
# → 即使大量失败, 重试也不会无限放大(超预算就不再重试);
# - 调用链 A→B→C: 只在【一层】(通常最外层/客户端)做重试, 别每层都重试(否则指数放大);
# 或下游传递"已重试"标记, 内层不再重试。
# ====== 配套 ======
# - 重试的操作必须幂等(同541篇): 非幂等操作重试会重复执行;
# - 配超时(同355篇): 没超时重试无从谈起;
# - 降级: 重试/熔断后仍失败, 返回降级结果(缓存/默认值/友好提示), 别硬扛。
# 核心: 重试要"克制而智能"——指数退避+jitter错开洪峰、限次数、只重试可重试错误; 配熔断在下游过载时减压、
# 控重试预算防放大、别每层重试、操作幂等; 系统过载时要减压(熔断/降级)而非重试加压。
修复的核心,是"重试要克制智能:退避+jitter、限次数、只重可重试错误,配熔断减压"。正解一:指数退避+jitter+只重试可重试错误——退避间隔逐次拉长(200/400/800ms)+随机 jitter 错开重试,只重试超时/连接失败/5xx,不重试 4xx/业务失败。正解二:配合熔断器——连续失败到阈值就熔断(一段时间直接快速失败不打下游),给下游恢复机会;熔断是给过载下游减压,而非重试加压。正解三:重试预算+别多层重试(限制重试流量占比、调用链只在一层重试避免指数放大)。配套:重试操作必须幂等、配超时、重试熔断后仍失败就降级。归根结底:重试要"克制而智能"——指数退避+jitter 错开洪峰、限次数、只重试可重试错误;配熔断在下游过载时减压、控重试预算防放大、别每层重试、操作幂等;系统过载时要减压(熔断/降级)而非重试加压。
第三件事:服务韧性与容错的其他常见坑
排查后我把服务韧性、容错相关的其他坑也系统梳理了一遍。
服务韧性与容错的其他常见坑
# 1. 重试风暴(本文): 盲目重试放大流量压垮下游。→ 退避+jitter+熔断+限预算。
# 2. 没超时(同355篇): 慢调用拖垮自己, 线程/连接耗尽。→ 连接+读超时必配。
# 3. 没熔断: 下游挂了还一直打它, 自己也被拖死。→ 熔断快速失败。
# 4. 没降级: 依赖失败时整个功能瘫痪。→ 降级到缓存/默认值/友好提示。
# 5. 没限流: 流量尖峰直接打垮系统。→ 限流(令牌桶/漏桶)保护。
# 6. 重试非幂等操作(同541篇): 重复执行(重复下单/扣款)。→ 操作幂等。
# 7. 同步退避用Thread.sleep占线程: 大量重试占满线程池。→ 异步重试/合理隔离。
# 8. 没有舱壁隔离: 一个慢依赖占满共享线程池, 拖垮所有调用。→ 线程池/信号量隔离(bulkhead)。
# 共同根源: 分布式系统里, 依赖一定会"偶尔失败/变慢"; 容错机制(超时/重试/熔断/降级/限流/隔离)是为了
# "让局部的失败不扩散成全局的雪崩"; 但容错机制【用错了】(如盲目重试)反而会成为雪崩的放大器——
# 关键是这些机制要"在系统过载时帮它减压、而非加压", 且互相配合成一套完整的韧性方案。
# 核心: 容错不是"加个重试"这么简单, 要成体系——超时打底、重试克制(退避/限次/可重试错误)、熔断减压、
# 降级兜底、限流保护、隔离防扩散、操作幂等; 核心原则是"过载时减压, 别加压", 让局部失败不雪崩成全局。
排查让我把服务韧性的其他坑也梳理清了。一、重试风暴(本文)。二、没超时。三、没熔断。四、没降级。五、没限流。六、重试非幂等操作。七、同步退避占线程。八、没有舱壁隔离。它们的共同根源是:分布式系统里依赖一定会偶尔失败/变慢;容错机制(超时/重试/熔断/降级/限流/隔离)是为了"让局部的失败不扩散成全局的雪崩";但容错机制用错了(如盲目重试)反而会成为雪崩的放大器——关键是这些机制要"在系统过载时帮它减压、而非加压",且互相配合成一套完整的韧性方案。核心是:容错不是"加个重试"这么简单,要成体系——超时打底、重试克制(退避/限次/可重试错误)、熔断减压、降级兜底、限流保护、隔离防扩散、操作幂等;核心原则是"过载时减压,别加压",让局部失败不雪崩成全局。下面这张图,是这次重试风暴坑的成因与解法:
第四件事:盲目重试 vs 智能重试对比表
这次踩坑后,我把"盲目重试"和"智能重试"对比成一张表。
| 维度 | 盲目重试(本文) | 智能重试 |
|---|---|---|
| 间隔 | 立即重试(无退避) | 指数退避+随机 jitter |
| 错误类型 | 所有错误都重试 | 只重试可重试错误(超时/5xx) |
| 次数/层级 | 固定多次, 每层都重试 | 限次数, 只一层重试 |
| 下游过载时 | 放大流量、压垮(加压) | 配熔断快速失败(减压) |
| 结果 | 正反馈雪崩 | 容错且不放大 |
这张表把两种重试钉清了。核心是:同样是"重试",盲目的和智能的结果天差地别——区别在于"有没有考虑'对方(下游)的承受能力'":盲目重试只顾自己"我要成功"地拼命重试,不管下游死活;智能重试会"体谅"下游(退避给它喘息、过载时熔断减压);好的容错, 不是"我单方面地拼命争取成功", 而是"在争取自己成功的同时, 不把对方(系统)逼垮"。它给我的最大启发是:一个"为自己争取好结果"的行为(重试求成功),如果完全不考虑它对'所处的整体/对方'的影响,就可能损人不利己、甚至把整体拖垮(把下游打死, 自己也没成功)——"局部的、自私的努力",在一个相互依赖的系统里,需要被"对整体影响的考量"所约束;"我要成功" 必须让位于 "我们(整个系统)别崩"。这给了我一种设计系统行为的清醒:设计任何"个体为达成自己目标而采取的行为"(重试、争抢资源、轮询)时,要加入"对整体/对依赖方的影响"的约束——问"如果大家都这么做、或在对方已经很吃力时还这么做, 会不会拖垮整体?",并设计"体谅对方、为整体减压"的机制(退避、熔断、限流);"让个体行为兼顾整体健康、过载时懂得减压退让",是构建一个不会被'各自拼命'拖垮的健壮系统的核心智慧。认清盲目重试只顾自己会拖垮整体、容错要兼顾对方承受力与整体健康——是这个坑带给我的认知。
第五件事:这次事故暴露的"正反馈循环"的危险
这次让我反思更深一层:重试风暴最可怕的,是它那个"越崩越重试、越重试越崩"的正反馈。我把"正反馈"和"负反馈"对比成表。
| 维度 | 正反馈(重试风暴) | 负反馈(熔断/退避) |
|---|---|---|
| 失败时的反应 | 更用力(更多重试) | 退让(减少流量) |
| 对系统的影响 | 放大波动、自我强化 | 抑制波动、趋向稳定 |
| 结果 | 失控、雪崩 | 稳定、自愈 |
| 方向 | 恶性循环(越来越糟) | 自我修正(拉回平衡) |
| 本质 | 火上浇油 | 釜底抽薪 |
这张表道出了最深的机理。核心是:重试风暴之所以能把"小抖动"演变成"大雪崩",是因为它构成了一个"正反馈循环"——"结果(失败)"反过来加剧了"原因(流量过大)"(失败→更多重试→更多流量→更多失败);正反馈会放大、自我强化任何初始的扰动,把小问题滚成大灾难;而负反馈(熔断退避: 失败时反而减少流量)则会抑制扰动、把系统拉回稳定。它给我的深刻启发是:系统里"反馈循环的方向"是决定其稳定性的关键——"正反馈(结果加剧原因)"导致失控和雪崩,"负反馈(结果抑制原因)"导致稳定和自愈;很多系统性的灾难(雪崩、踩踏、挤兑、振荡), 本质都是某个'正反馈环'被触发、失控放大;而健壮的系统, 往往内建了'负反馈机制'(过载就减压、过热就降速)来自我稳定;"识别并打破恶性的正反馈、建立良性的负反馈",是让系统稳定的关键。这给了我一种设计稳定系统的视角:设计系统时,要主动审视"有没有潜在的正反馈环(某个结果会反过来加剧其原因)?"——有就想办法打破它(如失败时减少而非增加压力);并主动引入"负反馈机制"(过载时自动减压:熔断、限流、退避、自动扩缩容),让系统面对扰动时能自我修正、趋向稳定,而非自我放大、走向崩溃;"用负反馈对抗正反馈、让系统具备自稳定能力",是构建抗扰动、能自愈的健壮系统的深层原则。认清正反馈致雪崩、用负反馈机制(过载减压)让系统自稳定——是这个重试风暴坑带给我的认知。
第六件事:要给调用加重试时,我现在的自检习惯
现在每当我要给一个对外调用加重试,我都会先按这张图问自己:
这张图的精髓,是"重试要克制:只重可重试错误、退避 jitter、限次数、配熔断、操作幂等"。4xx别重试、超时5xx退避+jitter+限次数、只在一层重试、配熔断减压、操作要幂等。这套习惯,让我从"失败就重试三次了事"变成了"智能克制地重试+熔断兜底"——核心始终是:重试用指数退避+jitter、限次数、只对可重试错误重试、配熔断在下游过载时减压、别每层重试、操作幂等,系统过载时要减压而非加压。
我立下的几条规矩
这场"重试把下游越打越死"的事故,换来了我做服务调用容错时,刻进骨子里的几条铁律:
- 盲目重试(无退避/不限次/不分错误/多层叠加)在下游过载时会放大流量、压垮它。
- 重试风暴是正反馈雪崩:越崩越重试、越重试越崩,且阻碍下游恢复。
- 重试用指数退避+随机 jitter,错开洪峰、给下游喘息。
- 只对可重试错误(超时/5xx/连接失败)重试,4xx/业务失败别重试。
- 限制重试次数,调用链只在一层重试(别指数放大)。
- 配熔断器:下游连续失败就快速失败减压;系统过载时要减压而非加压。
- 重试的操作要幂等;识别并打破恶性正反馈、引入负反馈让系统自稳定。
写在最后
回头看,这场由"一个善意的重试"引发的、把下游越打越死的雪崩,真正教给我的,远不止"用退避和熔断"这几个技巧。它让我对"当一个对象'已经陷入困境、正需要喘息'时, 我们出于好意'更用力地去要求它', 反而会把它彻底压垮; 有时, '退一步、给它空间'才是真正的帮助",有了一次刻骨的体会。我栽跟头,是因为我对待"下游失败"的本能反应,是"更用力"——"它失败了?那我多试几次, 总能成功!";这个反应在"下游只是偶尔抽风"时是对的;可当下游是"已经过载、正在拼命挣扎、急需喘口气"时,我这"更用力地反复要求",就成了压垮它的最后一击;它本来还有机会缓过来, 却被我一遍遍的重试洪流死死按住、不给它任何恢复的余地, 最终彻底崩溃;我"想救它"的用力, 恰恰"害了它"。这让我领悟到一个关于"压力与退让"的深刻认知:当一个系统(或人、或关系)已经处于过载、脆弱、挣扎的状态时,"继续施加压力(哪怕是出于善意/好结果的施压)"往往是雪上加霜,而"主动减压、退让、给它恢复的空间"才是让它重新站起来的关键;"越是危急时刻, 越需要懂得'退'而非一味地'进'";这就是熔断、降级、限流这些机制的深层智慧——它们的本质是"在系统最脆弱时, 主动地为它减负、给它喘息"。这给了我一种构建韧性系统的根本心法:设计系统的"应对失败/过载"的行为时,要把"给陷入困境的部分减压、留出恢复空间"放在和"努力达成目标"同等重要的位置——不要在系统已经撑不住时还一味加压(重试、堆请求),而要设计'感知到过载就主动退让、减压'的负反馈机制;"懂得在过载时为系统减压、退一步给它生路",是构建一个能在压力下自愈、而非被压垮的韧性系统的核心智慧。认清过载时施压是雪上加霜、危急时刻要懂得减压退让给系统恢复空间——这,是我用一次重试风暴的事故,换来的、关于服务韧性、也关于如何在压力下'以退为进'的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给调用加重试时,顺手配上退避、jitter 和熔断,在下游撑不住时懂得"松手",那我对着那被重试打死、迟迟起不来的下游复盘的这段时间,就值了。
—— 别看了 · 2026