我给下游调用加了失败自动重试本想让系统更可靠,结果某次下游只是变慢,重试却把流量放大了好几倍直接把它压垮:一次重试风暴拖垮整个链路的深度复盘
那次雪崩,是我"好心加的重试"亲手点燃的。我有个服务要调下游的一个接口,之前偶尔会因为下游抖动而失败,我想"加个重试吧,失败了自动重试 3 次,成功率能高不少、系统更可靠",于是加上了。平时确实好用:偶发失败重试一下就成功了。可那天下午,下游服务因为一次 GC 卡顿、只是变慢了(没挂,只是响应从 50ms 涨到了 2 秒),接着我就眼睁睁看着:下游从"变慢"在几十秒内被打成了"彻底挂掉",我的服务也跟着雪崩,整条链路瘫痪。复盘监控时我倒吸一口凉气:问题出在我那个"看似让系统更可靠"的重试上。下游一变慢,大量请求开始超时/失败;每个失败的请求,都立刻重试,而且重试 3 次;于是本来 1 倍的流量,瞬间变成了 3~4 倍(原始请求 + 每个都重试几次)——下游本来只是"有点慢、还撑得住",被这突然翻几倍的流量,直接彻底压垮;压垮后失败更多→重试更多→流量更大→更压垮……形成了正反馈的"重试风暴(retry storm)",几十秒就雪崩了。根本原因是:我加的重试没有退避、没有全局上限、没有熔断,在下游"已经过载"时,重试不仅没帮忙,反而放大了流量、加速了它的崩溃——本该减压的时候,我反而加了压。问题的根,是重试在故障/过载时放大了负载(本来该退让却加压),且无退避、无上限、无熔断,形成正反馈把链路拖垮。这篇就把这次"重试风暴"的坑,从头到尾复盘一遍。
故障现场:好心的重试,放大成了灾难
问题在于重试在过载时放大流量、形成正反馈:
// 我加的"重试", 看起来很合理, 出问题时却是灾难放大器:
public Response callDownstream(Request req) {
int maxRetry = 3;
for (int i = 0; i <= maxRetry; i++) {
try {
return httpClient.call(downstreamUrl, req); // 调下游
} catch (TimeoutException | IOException e) {
if (i == maxRetry) throw e;
// ✗ 立刻重试, 没有任何等待(退避), 没有判断下游是不是已经过载!
log.warn("调用失败, 立刻重试第{}次", i + 1);
}
}
throw new IllegalStateException();
}
/*
灾难是怎么发生的(下游因GC只是变慢, 响应50ms→2s):
1. 下游变慢 → 我的调用开始超时(假设我超时设1s, 下游要2s) → 大量请求"失败";
2. 每个失败的请求 → 立刻重试, 重试还失败 → 再重试 → 共发4次(1原始+3重试);
3. 于是: 本来100 QPS的流量, 瞬间变成 ~400 QPS 压向"已经很吃力"的下游;
4. 下游本来只是慢(还能勉强处理), 被这4倍流量直接压垮 → 真的挂了;
5. 挂了之后, 所有请求都失败 → 都重试4次 → 流量持续4倍 → 下游永远起不来;
6. 我的服务: 每个请求都要重试4次、每次等超时, 线程/连接全被占满 → 我也雪崩;
7. 正反馈: 失败越多→重试越多→流量越大→失败更多……"重试风暴"几十秒拖垮整条链路。
致命点: 重试是"局部"决策(我这一个请求想成功), 但当"所有请求"在下游过载时
都做这个决策, 汇总成的"全局"效果是: 在系统最脆弱时, 给它狠狠加了几倍的压。
重试本该是"偶发失败"的解药, 却成了"系统性过载"的毒药——它放大了负载、加速了崩溃。
*/
看着监控上那条"下游 QPS 在它变慢的瞬间不降反升、飙到平时 4 倍"的曲线,我又懊悔又后怕:"我加重试是想让系统更可靠啊,怎么反倒成了压垮它的最后一根稻草?早知道重试在过载时会这样放大流量……"这个坑最反直觉的地方在于:重试这个措施,动机和单次效果都是好的(提高单个请求的成功率),在偶发、独立的故障下也确实有效;它的危害只在"系统性过载/下游变慢"这个特定场景下才暴露,而且是以"正反馈放大"的形式急剧爆发——平时它是解药,关键时刻它是毒药。下面就来拆解,重试到底该怎么加。
第一件事:搞懂重试风暴与"过载时该退让而非加压"
我顺着这次事故,把重试的正确姿势彻底理清了。
重试为什么会变成"风暴"? 怎么加才安全?
【核心: 重试在"系统性过载"时会放大流量、形成正反馈雪崩; 安全的重试要有退避+抖动、次数上限、
熔断、只重试幂等+可重试错误、重试预算——过载时系统该"退让减压"而非"加压"】
1. 重试风暴的成因: 局部理性, 全局灾难
- 单个请求重试: 理性(想成功); 但所有请求在下游过载时都重试 → 全局流量翻几倍;
- 偏偏这时下游最脆弱 → 翻倍流量压垮它 → 更多失败 → 更多重试 → 正反馈雪崩。
2. 安全重试的几个关键要素:
① 退避(backoff): 别立刻重试, 等一会再试; 用"指数退避"(等1s、2s、4s…), 给下游喘息时间;
② 抖动(jitter): 退避时间加随机, 避免所有客户端"同时"重试造成新的流量尖峰(惊群);
③ 次数上限: 重试2~3次就够, 别无限重试; 总耗时也要有上限(别让一个请求重试到天荒地老);
④ 熔断(circuit breaker): 下游连续失败到一定比例, 直接"熔断"——一段时间内不再调用(快速失败),
给下游恢复时间; 这是"过载时主动退让"的关键, 防止重试风暴;
⑤ 只重试该重试的: 只对"幂等操作"+"可重试错误(超时、503、连接失败)"重试;
对"非幂等操作(如扣款)"或"明确的业务错误(如参数错误400)"重试是有害/无用的;
⑥ 重试预算(retry budget): 限制"重试流量占总流量的比例"(如不超过10%), 从全局上限制放大倍数。
3. 更深的原则: 过载时, 系统该"退让"而非"加压"
- 健康的分布式系统, 在某部分过载时, 应该"减少对它的压力"(让它恢复), 而不是"加大压力";
- 重试、无脑切换、客户端不断刷新, 都是"加压"行为, 会让过载恶化;
- 限流、熔断、退避、降级, 都是"减压/退让"行为, 帮系统恢复——这才是过载时该做的。
4. 重试 vs 不重试的权衡:
- 重试提高单次成功率, 但增加下游负载和延迟; 不是越多越好;
- 对"读、幂等、偶发失败": 适度重试(带退避)有益;
- 对"系统性故障(下游挂了/过载)": 重试无益且有害, 该靠熔断快速失败 + 告警 + 降级。
一句话: 重试在系统性过载时会放大流量、正反馈雪崩(重试风暴); 安全的重试必须带指数退避+抖动、次数上限、
熔断、只重试幂等+可重试错误、重试预算; 根本原则是"过载时系统该退让减压, 而非加压"。
这套认知,是整个坑的根。重试风暴的成因:单个请求重试是理性的,但所有请求在下游过载时都重试,全局流量翻几倍,偏偏这时下游最脆弱→压垮→更多失败→更多重试→正反馈雪崩。安全重试的关键要素:指数退避(给喘息)+抖动(避免惊群)、次数上限、熔断(过载时主动退让)、只重试幂等+可重试错误、重试预算(限制放大倍数)。更深的原则:过载时系统该"退让减压"(限流/熔断/退避/降级)而非"加压"(重试/无脑切换/狂刷新)。重试 vs 不重试的权衡:对读/幂等/偶发失败适度重试有益,对系统性故障重试有害、该熔断快速失败。一句话:重试在系统性过载时会放大流量、正反馈雪崩(重试风暴);安全的重试必须带指数退避+抖动、次数上限、熔断、只重试幂等+可重试错误、重试预算;根本原则是"过载时系统该退让减压,而非加压"。
第二件事:正解——退避 + 抖动 + 上限 + 熔断
知道了重试风暴的成因,正解就清楚了:让重试"克制",并在过载时靠熔断退让。
// 正解1: 指数退避 + 抖动, 限制次数(别立刻重试、别同时重试)
public Response callWithBackoff(Request req) {
int maxRetry = 2; // 上限2次(不是越多越好)
long base = 100; // 基础退避100ms
for (int i = 0; i <= maxRetry; i++) {
try {
return httpClient.call(downstreamUrl, req);
} catch (RetryableException e) { // 只对"可重试错误"重试
if (i == maxRetry) throw e;
long backoff = base * (1L << i); // 指数: 100、200、400ms
long jitter = (long) (backoff * Math.random() * 0.5); // 抖动: 加随机, 避免惊群
sleep(backoff + jitter); // 等一会再试, 给下游喘息
}
}
throw new IllegalStateException();
}
// 正解2: 熔断器——过载时主动"退让", 快速失败, 给下游恢复时间
// (用 Resilience4j / Sentinel 等成熟组件, 别自己造)
CircuitBreaker cb = CircuitBreaker.of("downstream", config);
public Response callWithCircuitBreaker(Request req) {
return cb.executeSupplier(() -> {
// 熔断器统计失败率: 失败率超阈值(如50%) → 打开熔断 →
// 一段时间内直接快速失败(不再真正调下游), 让下游喘息恢复 →
// 半开状态试探性放几个请求 → 成功则关闭熔断、恢复正常。
return httpClient.call(downstreamUrl, req);
});
}
// 正解3: 只重试"幂等 + 可重试"的; 非幂等/业务错误不重试
boolean shouldRetry(Throwable e, Request req) {
if (!req.isIdempotent()) return false; // 非幂等(如扣款): 不重试, 避免重复执行
if (e instanceof TimeoutException) return true; // 超时: 可重试
if (e instanceof HttpException he) {
int code = he.statusCode();
return code == 503 || code == 502 || code == 429; // 服务不可用/限流: 可重试
// 400参数错误、401未授权、404: 重试也没用, 不重试。
}
return false;
}
// 正解4: 治本——连接复用 + 限流 + 降级(同547连接复用、355超时)
// - 设合理超时(355), 别让请求堆积; 复用连接(547);
// - 入口限流: 保护自己不被打垮; 降级: 下游不可用时返回兜底数据/默认值。
// 核心: 重试要"克制"(退避+抖动+上限+只重试该重试的); 过载时靠"熔断"主动退让快速失败;
// 配合限流、降级、合理超时——让系统在故障时"减压恢复", 而不是"加压雪崩"。
这套正解的关键,是让重试从"无脑立刻重试"变成"克制、退让"。指数退避 + 抖动:别立刻重试(给下游喘息)、退避加随机(避免所有客户端同时重试造成新尖峰)。次数上限:重试 2~3 次就够,别无限重试。熔断:这是过载时主动退让的关键——失败率超阈值就熔断、一段时间快速失败,给下游恢复时间。只重试该重试的:只对幂等操作 + 可重试错误(超时/503/限流)重试,非幂等(扣款)和业务错误(400)不重试。而治本之策,是配合合理超时(避免堆积)、连接复用、入口限流、降级兜底,让系统在故障时整体走向"减压恢复"而非"加压雪崩"。
第三件事:其他几个"过载时反而加压"的坑
顺着重试风暴,我把"本想救火、反而浇油"的几类操作也一并理了:
几类"过载时不该做、却本能想做"的加压行为:
坑1: 无脑故障切换(failover)——主挂了所有流量切到从, 从也被同样的流量打垮(多米诺)。
正解: 切换也要配合限流; 考虑流量本身就是病因时, 切换只是换个受害者。
坑2: 客户端疯狂刷新/用户狂点重试按钮——页面卡了用户狂刷新, 流量更大, 服务更卡。
正解: 前端也要退避(按钮置灰、提示稍后)、错误页别自动刷新。
坑3: 健康检查太激进——下游慢, 健康检查判定不健康、摘除节点, 流量压到更少节点上, 更快垮。
正解: 健康检查阈值要宽容, 别因短暂变慢就大面积摘除。
坑4: 缓存击穿后全部打到DB——热点key过期瞬间, 所有请求同时回源DB, 压垮DB(同缓存击穿)。
正解: 互斥重建缓存(只让一个请求回源)、逻辑过期、空值缓存。
坑5: 超时设太长 + 不重试也堆积——下游慢, 请求都卡在那等超时, 线程/连接耗尽(355无超时)。
正解: 合理超时 + 快速失败 + 隔离(舱壁模式), 别让一个慢下游拖垮整个服务。
共同的根: 系统过载/故障时, 很多"本能的、想提高可用性的"动作(重试、切换、刷新、回源),
其实是在"加压", 会让过载恶化、形成正反馈雪崩; 正确的方向是"减压/退让"
(退避、熔断、限流、降级、隔离), 给系统恢复的空间。
这些坑看似不同,根却是同一个:系统过载时,人(和系统)的本能反应往往是"加压"(重试、切换、刷新、回源——都是"更努力地想成功");可过载的系统需要的恰恰相反——它需要"被减压、被退让",才能恢复。认清这个根("过载时该退让减压,而非加压"),就能对各种"救火反浇油"的操作有警觉了。
第四件事:无脑重试 vs 安全重试——一张对照表
我把"我之前的无脑重试"和"安全的重试"逐项对照,整理成一张表,贴在了团队的稳定性规范里:
| 维度 | 无脑重试(灾难) | 安全重试(正解) |
|---|---|---|
| 重试时机 | 失败了立刻重试 | 退避后重试(指数 + 抖动) |
| 重试次数 | 固定多次 / 无上限 | 2~3 次上限,总耗时也限 |
| 哪些错误重试 | 所有错误都重试 | 只重试可重试错误(超时/503/限流) |
| 幂等性 | 不区分,扣款也重试 | 只对幂等操作重试 |
| 过载保护 | 无,过载时照样狂重试 | 熔断:连续失败就快速失败退让 |
| 全局放大 | 流量瞬间翻几倍 | 重试预算限制放大比例 |
| 过载时的行为 | 加压(雪上加霜) | 退让减压(给系统恢复空间) |
| 故障时结果 | 正反馈,几十秒雪崩 | 快速失败 + 降级,系统存活 |
这张表里,我犯的几乎是左边每一条:失败立刻重试、固定 3 次、所有错误都重、不分幂等、没有熔断、没有预算——于是过载时每一条都在"加压"。右边的每一条,核心都指向一件事:让重试"克制、有节制",并在系统过载时能主动退让(熔断)而非一味加压。
第五件事:重试相关的几组容易混淆的概念
这次事故也让我厘清了几组关于重试与可靠性的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| 加重试 = 系统更可靠 | 不当的重试在过载时会放大故障、降低可用性 |
| 重试次数越多越容易成功 | 过载时重试越多压力越大,越不可能成功 |
| 失败了就该立刻重试 | 立刻重试会瞬间加压,应退避后再试 |
| 所有失败都值得重试 | 非幂等操作、业务错误重试有害或无用 |
| 下游慢,我多试几次总能成 | 下游慢往往因过载,你的重试正是过载之源 |
| 熔断是放弃,不够努力 | 熔断是主动退让,是让系统整体存活的智慧 |
| 稳定性靠每个请求都拼命成功 | 稳定性靠系统在故障时优雅降级、快速失败 |
这张表里,我栽的是第一行和第五行:把"加重试"等同于"更可靠",又在下游变慢时以为"多试几次总能成",却没意识到我的重试正是把它从"慢"推向"挂"的推手。厘清这些,核心是一个意识:可靠性不是"每个请求都拼命成功"堆出来的,而是"系统在局部故障/过载时,能优雅地退让、降级、快速失败,保住整体不雪崩"换来的;有时候,"果断地放弃一个请求(熔断/快速失败)",恰恰是为了"救活整个系统"。
第六件事:加重试/做容错时,我现在的自检习惯
现在每当我要给一个调用加重试、或做容错设计,我都会先按这张图问自己:
这张图的精髓,是"幂等才重试、退避加抖动、设上限、加熔断、过载时减压而非加压"。先问幂等吗、再问退避抖动了吗、有上限吗、有熔断吗、最后过载时是加压还是减压。这套习惯,让我从"失败了就多试几次"变成了"克制地重试、过载时退让"——核心始终是:重试在系统性过载时会放大流量、正反馈雪崩;安全的重试要带指数退避+抖动、次数上限、熔断、只重试幂等+可重试错误;根本原则是过载时系统该退让减压而非加压。
我立下的几条规矩
这场"好心的重试酿成雪崩"的事故,换来了我做容错设计时,刻进骨子里的几条铁律:
- 重试在系统性过载时会放大流量、形成正反馈雪崩(重试风暴),好心可能办坏事。
- 重试必须带指数退避 + 抖动,别失败了立刻重试、别所有客户端同时重试。
- 重试次数和总耗时都要有上限,2~3 次足矣,别无限重试。
- 只对幂等操作 + 可重试错误(超时/503/限流)重试;非幂等、业务错误不重试。
- 用熔断器:连续失败就快速失败、暂停调用,给下游恢复时间(过载时主动退让)。
- 配合合理超时、连接复用、入口限流、降级兜底、舱壁隔离做整体容错。
- 系统过载时,默认方向是"减压退让"而非"加压努力"——重试/切换/刷新要警惕。
附:容错的一套配置清单
借这次重试风暴的坑,我把团队里调用下游时的容错配置,整理成了一份清单,作为新接口接入的检查项。
# ====== 调用下游的容错配置清单 ======
downstream-call:
timeout:
connect: 500ms # 连接超时(355篇: 必须设, 别无限等)
read: 1s # 读超时(根据下游P99设, 别设太长导致堆积)
retry:
max-attempts: 2 # 重试上限2次(不是越多越好)
backoff:
type: exponential # 指数退避: 100ms、200ms...
base: 100ms
jitter: true # 加抖动, 避免所有客户端同时重试(惊群)
retry-on: [timeout, 503, 502, 429] # 只重试可重试错误
retry-only-idempotent: true # 只对幂等操作重试
circuit-breaker:
failure-rate-threshold: 50% # 失败率超50%打开熔断
wait-duration-in-open: 10s # 熔断后10s快速失败, 给下游恢复
half-open-calls: 5 # 半开放5个试探
bulkhead:
max-concurrent: 50 # 舱壁隔离: 限制并发, 别让一个下游占满所有线程
fallback:
enabled: true # 降级: 下游不可用时返回兜底数据
这份清单的每一项,都对应着这次事故的一个教训:合理超时(不堆积)、克制重试(退避+抖动+上限+只重试该重试的)、熔断(过载退让)、舱壁(隔离)、降级(兜底)。把这些固化成"接入下游的标准配置",比每个人各自踩一遍重试风暴的坑,要划算得多。
写在最后
回头看,这场由"一个好心加的重试"引发的、把下游从"慢"推向"挂"的雪崩,真正教给我的,远不止"重试要带退避和熔断"这一个技巧。它让我对"一个'局部看完全理性、动机完全善良'的行为, 当'所有个体在同一时刻都这么做'时, 汇总出的'全局后果'可能是灾难性的; 个体理性 ≠ 集体理性",有了一次刻骨的体会。我栽跟头,是因为我只站在"单个请求"的视角看重试——对那一个请求来说,"失败了多试几次以求成功"完全合理;可我没看到:当下游过载、"所有"请求都在"多试几次"时,这些"各自理性"的重试汇总成了一股把下游彻底压垮的洪流;每个请求都在"为自己的成功而努力", 合起来却"让所有请求都更不可能成功"——这是一场典型的"公地悲剧"。这让我领悟到一个关于"局部与全局、个体与集体"的深刻认知:评估一个行为/策略的好坏,不能只看"它对单个个体、在孤立情况下的效果",更要看"当大量个体在相关联的情况下都采取它时, 汇总出的系统级效果"——很多在"个体、局部、平时"层面正确的事(重试、抢购、踩刹车、囤货), 在"群体、全局、危机"层面会因为'同步、放大、正反馈'而走向反面;"每个人都做对自己最有利的事", 不一定导向"对整体最好的结果", 有时恰恰相反。这给了我一种设计系统(乃至理解社会现象)时的清醒:设计任何"每个个体都会执行的策略/机制"时,一定要做一次"从单体视角切换到群体视角"的推演——问自己"如果所有节点/用户/请求在同一时刻(尤其是系统已经出问题的时刻)都执行这个策略, 会发生什么?会不会形成正反馈、相互放大?";"跳出单体视角、用'群体同时行动'的镜头审视每一个看似无害的局部策略, 并为'危机时刻的集体行为'设计退让机制(退避、熔断、限流)",是避免'个体理性汇成集体灾难'的关键。认清个体理性不等于集体理性、局部正确的策略群体同步时可能酿成灾难、要从群体视角推演并为危机设计退让——这,是我用一次重试风暴的雪崩,换来的、关于分布式系统、也关于如何理解局部与全局之关系的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给某个调用加重试时,顺手想一想"下游过载、所有请求都重试会怎样"、并给它配上退避和熔断,那我对着那条"变慢瞬间 QPS 不降反升 4 倍"的曲线复盘的这段时间,就值了。
—— 别看了 · 2026