这是一次"好心办坏事"的典型事故,也是我对"重试"这个看似无害的机制,彻底改观的一次。事情的起因很小:我们依赖的一个下游服务,某天出现了短暂的抖动——它有那么几秒钟变慢了、有少量请求超时了。这本来是件小事,下游抖一下、缓一缓,通常几秒钟就自己恢复了。可那天,它不仅没恢复,反而被彻底打挂了,而且一垮就是好久,连带把我们整个服务也拖垮了。事后复盘,真凶让我大跌眼镜——把下游打死的,不是别人,正是我们自己为了"提高成功率"而精心设计的"失败自动重试"机制。
这就是分布式系统里一个凶名赫赫、却常被低估的现象——重试风暴(Retry Storm)。它的逻辑是这样一个致命的正反馈循环:下游抖动 → 我们这边一批请求失败 → 失败了就触发"自动重试 3 次" → 于是原本就吃力的下游,突然被涌入了三倍的重试请求 → 下游彻底被压垮 → 更多请求失败 → 触发更多重试 → 更多流量涌入……就这样,重试像滚雪球一样不断放大流量,把一个下游"本来抖一下就能自愈的小毛病",活活拖成了"被流量洪峰彻底压死、且无法恢复的大雪崩"。我们本意是用重试去"救"那些失败的请求,结果却是用重试,亲手把下游(以及自己)推向了万劫不复。这篇文章,就从这次"重试把下游打死"的事故讲起,把"重试"这个我们天天用、却很少深究其凶险一面的机制,讲清楚。
故障现场:好心的重试,变成了压垮骆驼的洪水
先把那段"好心"的重试代码还原一下,它简化后是这样的:
// 看起来很负责任的重试: 失败了就重试, 最多3次, 提高成功率
public Result callDownstream(Request req) {
int maxRetry = 3;
for (int i = 0; i <= maxRetry; i++) {
try {
return downstreamClient.call(req); // 调用下游
} catch (Exception e) {
if (i == maxRetry) throw e;
// 失败了, 立刻无脑重试 (没有任何间隔、没有任何条件)
log.warn("第{}次失败, 立刻重试", i + 1);
}
}
}
// 平时: 下游好好的, 极少失败, 重试几乎不触发, 岁月静好
// 下游抖动时: 大量请求失败 → 每个都立刻重试3次 → 瞬间3-4倍流量砸向下游!
看出这段"负责任"的代码,在下游抖动时是怎么变成"帮凶"的了吗?平时下游健康,请求极少失败,这个重试逻辑几乎从不触发,看起来人畜无害、还很贴心。可一旦下游抖动、开始有大量请求失败——这段代码就会让每一个失败的请求,立刻、毫无间隔地再发起 3 次重试。于是,在下游最虚弱、最需要喘息的那一刻,它非但没得到喘息,反而被瞬间涌入的、3 到 4 倍于平时的流量(原始请求 + 重试)给死死淹没了。这就好比一个人快被压垮了,你不去减轻他的负担,反而往他身上又压了三倍的担子——他不被压死才怪。
而更可怕的是这个过程的正反馈特性:下游被重试流量压得更慢、失败更多 → 更多的失败触发了更多的重试 → 流量进一步放大 → 下游更慢、失败更多……这个循环一旦启动,就会自我强化、急剧恶化,把下游死死地摁在"过载"状态,使它根本没有任何机会自我恢复。本来下游只是"抖一下"(短暂、可自愈),却被我们的重试风暴,生生变成了"挂了且起不来"(持续、不可自愈)。我们的重试,从"挽救个别失败请求"的善意,异化成了"压垮整个下游"的洪水——这就是重试风暴最阴险的地方:它在系统最脆弱的时刻,以"好心"的名义,给出了最致命的一击。
第一件事:理解重试是把"双刃剑"——它会放大流量
要驾驭重试,首先必须建立一个清醒的认知:重试是一把双刃剑。它在"下游只是偶发、独立的瞬时故障"时,确实能有效提高成功率(再试一次就成功了);但在"下游整体性地过载、变慢"时,重试会变成"火上浇油"——它非但救不了请求,反而会成倍地放大流量,把已经吃力的下游彻底压垮。关键区别,在于下游故障的"性质"。
重试的两面性, 取决于下游故障的性质:
场景A: 下游"偶发、独立"的瞬时抖动(如某个网络包丢了)
→ 重试有用! 再试一次, 大概率就成功了, 成功率确实提高了
场景B: 下游"整体性"的过载/变慢(它已经扛不住了)
→ 重试有害! 此时下游需要的是"减少流量、喘口气",
而重试反而给它增加了数倍流量, 把它彻底压垮 → 重试风暴
致命的是: 你的重试逻辑, 通常分不清现在是 A 还是 B,
于是在最不该重试的 B 场景(下游过载), 它也照样疯狂重试。
关键认知是:重试的价值,建立在"故障是偶发的、独立的、重试时下游有余力处理"这个隐含前提上;一旦这个前提不成立(下游是整体性过载),重试就从"良药"变成了"毒药"。而最棘手的问题在于:一段简单的重试逻辑,根本无法分辨"现在下游是偶发抖动(该重试)还是整体过载(不该重试)"——它只看到"请求失败了",就无脑地重试,于是在下游过载这个最不该重试的时刻,它反而贡献了最猛烈的重试流量。所以,无脑的、无条件的、无节制的重试,是危险的;真正安全的重试,必须是"克制的、有条件的、能在下游过载时主动收手的"。这,正是后面所有解法的出发点。
第二件事:正解之一——指数退避 + 随机抖动
第一个、也是最基础的解药,是改掉"失败了立刻、密集重试"这个最要命的行为,改用指数退避(exponential backoff)+ 随机抖动(jitter):每次重试前,都等待一段时间;而且这个等待时间随重试次数指数级增长(第一次等 1 秒、第二次 2 秒、第三次 4 秒……),再叠加一点随机抖动。
// 正解: 指数退避 + 随机抖动, 给下游喘息的时间, 也错开重试洪峰
public Result callWithBackoff(Request req) {
int maxRetry = 3;
for (int i = 0; i <= maxRetry; i++) {
try {
return downstreamClient.call(req);
} catch (Exception e) {
if (i == maxRetry) throw e;
// 指数退避: 等待时间随次数指数增长 (1s, 2s, 4s...)
long base = (long) (1000 * Math.pow(2, i));
// 加随机抖动: 避免所有失败请求"同时"重试, 错开洪峰
long jitter = ThreadLocalRandom.current().nextLong(1000);
Thread.sleep(base + jitter);
}
}
}
这两招分别解决了重试风暴的两个要害:指数退避解决了"重试太密集、不给下游喘息"的问题——每次重试都等待,而且越等越久,这就给了过载的下游宝贵的恢复时间,而不是在它喘不过气时还一个劲地猛捶;随机抖动则解决了"大量请求同时重试形成尖峰"的问题——如果没有抖动,那一批同时失败的请求会在同一时刻一起重试,形成一个新的流量尖峰(同步的重试洪峰),而加上随机抖动,就把这些重试在时间上打散了,变成平缓的细流。"指数退避 + 随机抖动"是安全重试的标配,它的核心思想是:重试要"越来越克制、且彼此错开",给下游留出恢复的空间,而不是火上浇油。(这和之前聊缓存雪崩时"过期时间加随机抖动"是同一个削峰智慧。)我把"无脑重试"和"退避重试"的对比画成图:
这张图把两种重试策略的命运对比得很清楚:无脑重试形成"失败→立刻猛重试→压垮→更多失败"的恶性正反馈;退避重试则通过"等待 + 错开",给下游留出了自愈的窗口,打破了那个致命的循环。同样是重试,一个把下游推向死亡,一个帮下游走向恢复——差别就在"克制"二字。
第三件事:正解之二——熔断器,在下游垮掉时果断"断电"
退避重试缓解了密集度,但还不够。因为退避之后该重试的还是会重试,如果下游是"持续地、整体地"挂了,那再怎么退避,这些重试也都是徒劳的、且仍在给下游(它可能正在重启恢复)添乱。这时需要一个更果断的机制——熔断器(Circuit Breaker):当它检测到对某个下游的失败率高到一定程度时,就果断地"跳闸"——在接下来一段时间里,直接拒绝所有对该下游的请求(快速失败),根本不发出去。
// 熔断器: 下游故障率过高时, 直接"断电", 不再发请求(也就没有重试了)
if (circuitBreaker.isOpen()) { // 熔断器已"跳闸"(检测到下游持续故障)
return fallback(); // 直接走降级逻辑, 快速失败, 不打扰下游
}
try {
Result r = downstreamClient.call(req);
circuitBreaker.recordSuccess(); // 成功, 给熔断器记一笔
return r;
} catch (Exception e) {
circuitBreaker.recordFailure(); // 失败, 累积失败计数
// 失败率超阈值时, 熔断器会自动"跳闸"(Open), 后续请求直接被拒
throw e;
}
// 熔断器状态机: 关闭(正常)→ 失败率过高 → 打开(跳闸,拒绝请求)
// → 过一段时间 → 半开(试探性放一个请求) → 成功则关闭, 失败则继续打开
熔断器的价值,在于它提供了重试所缺乏的那个关键能力——"在下游确实垮了的时候,主动地、彻底地停止打扰它"。它像电路里的保险丝:平时正常导通(放行请求);一旦检测到下游持续故障(失败率超阈值),就"跳闸"(Open 状态),在接下来一段时间里直接拒绝所有请求、快速失败,给下游一个完全没有流量打扰的、纯净的恢复环境;过一段时间后,它会进入"半开"状态,试探性地放一个请求过去——如果成功了,说明下游恢复了,就"合闸"恢复正常;如果还失败,就继续保持跳闸。熔断器和退避重试是绝佳搭档:退避重试应对"偶发抖动"(温柔地多试几次),熔断器应对"持续故障"(果断地停止打扰)——前者治小病,后者防大灾。正是熔断这一道"该断就断"的防线,才能从根本上掐灭重试风暴那个自我强化的恶性循环。
第四件事:重试还有几条不可破的纪律
退避和熔断之外,安全的重试还有几条同样重要的纪律,缺一不可。它们决定了"什么该重试、什么绝不能重试、重试到什么程度就该收手"。
// 纪律1: 只重试"幂等"的操作! 非幂等操作重试会造成重复(重复扣款、重复下单)
// 查询(GET)幂等, 可放心重试
// 而"创建订单""扣款"等非幂等操作, 重试前必须配合幂等设计(如幂等键)
if (isIdempotent(req)) { retry(); } else { /* 不重试 或 带幂等键重试 */ }
// 纪律2: 只对"可重试的错误"重试! 不是所有失败都该重试
// 超时、连接失败、503 等 → 可能是瞬时问题, 可重试
// 400 参数错误、401 没权限、404 不存在 → 重试一万次也还是错, 别重试!
if (isRetriable(error)) { retry(); }
// 纪律3: 限制重试次数 + 设置总超时, 别无限重试
// 最多重试 N 次; 整个调用(含所有重试)有一个总的超时上限
// 纪律4: 别在调用链的每一层都重试! 否则重试会"逐层相乘"放大
// A重试3次 × B重试3次 × C重试3次 = 对最底层27倍的放大! → 重试只在一层做
这四条纪律,每一条都是血泪教训:纪律一(只重试幂等操作)极其重要——查询类操作重试无害,但"扣款""下单"这类有副作用的非幂等操作,重试可能导致重复扣款、重复下单!所以非幂等操作要么别重试,要么必须配合幂等设计(带幂等键)才能安全重试。纪律二(只对可重试的错误重试)——超时、连接失败、503 这类"瞬时故障"重试有意义;但 400(参数错误)、404(不存在)这类"确定性错误",你重试一万次结果也一样,纯属浪费、还放大流量。纪律三(限次数+总超时)——重试次数和总耗时都要有上限,绝不无限重试。纪律四(别逐层重试)尤其阴险:如果调用链 A→B→C 每一层都配了"重试 3 次",那最底层的 C 实际会承受 3×3×3=27 倍的放大!所以重试只应该在调用链的某一层做,绝不能层层都做。我把这些纪律和前面的退避、熔断汇总成一张"安全重试要素表":
| 要素 | 作用 | 不做的后果 |
|---|---|---|
| 指数退避 + 抖动 | 给下游喘息, 错开洪峰 | 密集重试压垮下游 |
| 熔断器 | 下游持续故障时果断停手 | 对垮掉的下游持续添乱 |
| 只重试幂等操作 | 避免重复副作用 | 重复扣款/下单 |
| 只重试可重试错误 | 不浪费在确定性错误上 | 白白放大流量 |
| 限次数 + 总超时 | 给重试一个止损边界 | 无限重试拖垮自己 |
| 只在一层重试 | 避免逐层相乘放大 | 底层承受指数级放大 |
第五件事:重试、熔断、限流——一套组合拳
讲到这儿你会发现,要安全地处理"下游故障",光靠重试是远远不够的,它得和熔断、限流、降级等机制配合,组成一套完整的"容错组合拳"。它们各司其职、互相补位。我把这几个常被混淆的机制,理一张对照表:
| 机制 | 解决什么 | 一句话 |
|---|---|---|
| 重试(退避) | 下游"偶发"抖动 | 温柔地多试几次, 提成功率 |
| 熔断 | 下游"持续"故障 | 果断停手, 给下游恢复空间 |
| 限流 | 流量超过自身/下游容量 | 主动拒掉超额请求, 保住核心 |
| 降级 | 依赖不可用时的兜底 | 返回默认值/旧数据, 别整个崩 |
| 超时 | 防止被慢下游拖死 | 等够了就放弃, 释放资源 |
这套组合拳的配合大致是:超时是底线(任何调用都要有,别被慢下游拖死);重试(带退避)应对偶发抖动;熔断在下游持续故障时果断断开、止损;限流保护自己和下游不被超额流量冲垮;降级则在依赖确实不可用时,给用户一个兜底的、不至于整个崩掉的结果。它们不是互相替代的,而是协同作战的——一个健壮的、能扛住下游故障的服务,这几道防线往往都得有。而我那次事故的根本教训,正是只配了"重试"这一招、还配得很糟(无脑、无退避、无熔断),于是它在下游故障时不仅没起到保护作用,反而成了压垮一切的元凶。容错,从来不是单一机制能搞定的,而是这一套组合拳的协同;只用重试、且用错了重试,比不用还危险。
一张"要不要重试、怎么重试"的决策图
把这次踩坑沉淀成一张图。每当你想给一个调用加重试时,照着它走一遍:
这张图先用两道判断"该不该重试"(幂等吗?错误可重试吗?)拦掉不该重试的情况,再用一串"怎么重试"的纪律(退避、抖动、限次、熔断、只一层)约束该重试的情况。核心是:重试不是"失败了就无脑再来",而是一个需要层层判断和约束的、克制的行为。把这张图过一遍成为习惯,重试风暴这个坑就能在设计时被你拦下。
我立下的几条重试规矩
这次"重试把下游打死"的事故后,团队的规范里加了这么几条:
- 重试必带退避抖动:绝不无间隔密集重试,用指数退避 + 随机抖动,给下游喘息、错开重试洪峰。
- 必配熔断:对外部依赖的调用配熔断器,下游持续故障时果断断开、快速失败,别持续添乱。
- 只重试幂等操作:非幂等操作(扣款、下单)不重试,或必须配合幂等键才重试,防重复副作用。
- 只重试瞬时错误:只对超时、连接失败、5xx 等可能瞬时的错误重试;4xx 确定性错误不重试。
- 限次数 + 总超时:重试次数和整体耗时都设上限,绝不无限重试拖垮自己。
- 重试只在一层做:调用链上只在某一层加重试,严禁层层重试导致逐层相乘放大。
- 组合拳而非单点:超时、重试、熔断、限流、降级配套使用,别指望单靠重试就能容错。
这几条里,第二条"必配熔断"是我觉得最该和重试绑在一起的——重试和熔断,简直就是一对必须成双出现的搭档:重试负责"乐观地多试几次",而熔断负责"在试不通时及时收手";只有重试没有熔断,就像一辆只有油门没有刹车的车,在下游故障时只会一路油门踩到底、直到撞墙(把下游撞死)。我那次的惨剧,根源正是"有重试、无熔断"——重试这只脚一个劲地踩油门,却没有熔断这只脚在关键时刻踩刹车,于是径直冲下了悬崖。记住:凡是加了重试的地方,都问一句"它的刹车(熔断)在哪?"——没有刹车的重试,是危险品。
写在最后:善意的机制,也要为"最坏情况"而设计
这次"重试风暴"的事故,给我最深的触动,是它彻底改变了我设计"容错机制"时的思维方式。在那之前,我设计重试,脑子里想的全是"正常情况":"请求偶尔失败一下,我重试几次,把它救回来,提高成功率"——这个出发点是善意的、也是对的。可我犯的错误,是只为"正常的、乐观的"情况设计了这个机制,却完全没有考虑它在"最坏情况"(下游整体性过载)下会变成什么样。而恰恰是在那个我没考虑到的最坏情况下,我这个善意的重试机制,露出了它狰狞的另一面——从"救火队员"变成了"纵火犯"。
想通这一点,我领悟到设计任何"容错机制"时的一条铁律:容错机制本身,必须经得起"最坏情况"的考验;你不能只设计它在"小毛病"下怎么帮忙,更要想清楚它在"大灾难"下会不会帮倒忙、甚至会不会成为灾难的放大器。这其实有点反直觉——我们设计重试、熔断这些东西,本就是为了应对故障的;可如果设计得不好,这些"为应对故障而生"的机制,自己反而会在严重故障时成为压垮系统的最后一根稻草(就像我的重试)。所以,衡量一个容错机制好不好,不是看它在"小故障"时表现如何,而是看它在"大故障""极端过载"时,是帮着系统稳住、还是推着系统加速崩溃。真正成熟的容错设计,都带着一种"为最坏情况而设计"的悲观与审慎——它假设最坏的情况一定会发生,并确保自己在那一刻,是减压阀而非放大器。
所以,如果你也在设计重试、熔断、缓存、降级这类"容错/保护"机制,我想把这次踩坑最想说的话送给你:设计它们时,别只陶醉于"它在正常情况下能帮多大忙",更要冷静地推演一遍——"当最坏的情况(下游全挂、流量暴增、缓存全失效)发生时,我这个机制,会让情况变好,还是变得更糟?"给重试配上熔断这只刹车,给缓存配上对雪崩的防护,给一切善意的机制,都套上"最坏情况下不至于反噬"的安全绳。因为系统的韧性,从来不是由它"顺境时跑多快"决定的,而是由它"逆境时、在那些你最不愿面对的最坏时刻,会不会自我崩溃"决定的。那场由善意的重试引发的雪崩,最终教给我的,正是这份"为最坏情况而设计"的清醒——它让我从此在设计每一个保护机制时,都会多问一句那句最重要的话:"它在最坏的时候,到底是帮手,还是凶手?"愿你我设计的每一道防线,都能在风暴来临时,真正地护住系统,而不是亲手将它推向深渊。
—— 别看了 · 2026