下游只是抖了一下,我那个"失败就立即重试三次"的客户端却把它彻底打垮了、还陷入越重试越崩的恶性循环,我对着这场重试风暴排查了大半天的复盘
这是一个让我对"重试"这件事彻底改观的故事。我的服务要调用一个下游服务,为了"提高成功率",我给客户端加了一个看起来很负责任的逻辑:调用失败,就立即重试,最多重试 3 次。我当时还挺得意,觉得这下"更健壮"了。可有一天,下游服务只是因为一次发布,短暂地抖动、变慢了几秒——这本该是个小问题、自己很快能恢复;可结果,却演变成了一场大灾难:下游不但没恢复,反而被彻底打垮、长时间完全不可用;我的服务也跟着大面积超时。我盯着监控,发现一个诡异又可怕的现象:在下游开始抖动的那一刻,打向它的请求量,不降反升、瞬间暴涨了好几倍!一个"小抖动",怎么会引发"请求暴涨",还把下游"越压越崩"?
我顺着这个"请求暴涨"的线索深挖,才终于揭开真相,补上了我对"重试"一个最致命的认知漏洞:问题的核心,是我的重试逻辑,引发了一场"重试风暴(retry storm)"。我一直想当然地以为,"重试是好事,失败了再试试,能提高成功率";可真相是:当下游整体性地抖动/变慢时,所有正在调用它的请求,会几乎同时失败;而它们,又会几乎同时、立即地发起重试——于是,原本 1 倍的流量,瞬间因为重试,放大成了 3 倍、4 倍,如潮水般涌向那个本就脆弱、正在喘息的下游。这就形成了一个致命的恶性循环:下游变慢 → 大量请求超时失败 → 它们立即重试、流量翻倍 → 下游被更大的流量压得更慢、更崩 → 导致更多的超时失败 → 引发更多的重试……就这样,一个本该"抖一下就恢复"的小故障,被我的重试逻辑,活活放大、维持成了一场"谁也无法靠自己恢复"的持续性雪崩。更糟的是,我的重试还有几个火上浇油的设计:第一,"立即重试"——没有任何等待(退避),失败了毫秒级就再次冲上去;第二,"同时重试"——大家步调一致地在同一时刻重试,形成"惊群";第三,对所有错误都重试——哪怕是"参数错误"这种重试一万次也不会成功的错,也在白白地、徒劳地消耗下游。我这才痛彻地明白:"重试",是一把双刃剑:用对了,它能从瞬时抖动中优雅恢复;用错了,它会成为压垮下游的"帮凶",把小故障,放大成大灾难。一个负责任的重试,绝不能是"失败就无脑立即重试";它必须配上一整套"克制"的机制:退避(backoff)(失败后等待、且等待时间逐次拉长)、抖动(jitter)(加入随机,打散惊群)、上限(限制重试次数和总量)、熔断(下游持续失败时干脆别打了)、以及只对"该重试的错误"重试。
故障现场:无退避、无抖动、无差别的重试,放大故障
我把这场"重试风暴"的现场,用伪代码摊开给你看:
// ✗ 灾难: 失败就立即重试 3 次, 无退避/无抖动/无差别, 放大故障
func callDownstream(req Request) (Response, error) {
for i := 0; i < 3; i++ { // ✗ 最多重试 3 次
resp, err := doCall(req)
if err == nil {
return resp, nil
}
// ✗ 立即重试! 没有任何等待
// ✗ 对所有错误都重试(包括参数错误这种重试也没用的)
}
return Response{}, errors.New("failed after 3 retries")
}
// 重试风暴怎么形成的(恶性循环):
// 1. 下游抖动变慢 → 大量请求几乎同时超时失败。
// 2. 它们几乎同时、立即重试 → 流量瞬间放大 3~4 倍。
// 3. 本已脆弱的下游, 被翻倍流量压得更慢、更崩。
// 4. 更慢 → 更多超时 → 更多重试 → 流量更大 ... 循环放大!
// → 一个"抖一下就该恢复"的小故障, 被重试维持成持续雪崩。
// 三个火上浇油的设计:
// ✗ 立即重试(无退避): 失败后毫秒级就再冲, 不给下游喘息时间。
// ✗ 同时重试(无抖动): 大家步调一致在同一刻重试 → "惊群"。
// ✗ 无差别重试: 对 4xx 参数错误也重试(永远不会成功, 纯浪费)。
// 更隐蔽: 重试的"层层叠加"
// - A调B重试3次, B调C也重试3次 → C 实际承受 3*3=9 倍流量!
// - 多层调用链上, 每层都重试 → 流量指数级放大, 最底层被打爆。
// 根因: 重试无退避无抖动无上限、对不可重试错误也重试,
// 下游抖动时重试放大流量、形成恶性循环, 把小故障放大成雪崩。
看着这段"看似负责、实则危险"的重试,我才算彻底想明白了根源。问题的核心,是我的重试无退避、无抖动、无差别,在下游抖动时放大了故障。重试风暴的恶性循环是:下游抖动变慢 → 大量请求几乎同时超时失败 → 它们几乎同时立即重试、流量瞬间放大 3~4 倍 → 本已脆弱的下游被翻倍流量压得更慢更崩 → 更多超时 → 更多重试……循环放大,把"抖一下就该恢复"的小故障,维持成了持续雪崩。而我有三个火上浇油的设计:立即重试(无退避,不给下游喘息)、同时重试(无抖动,形成"惊群")、无差别重试(对 4xx 参数错误也重试,永远不会成功、纯浪费)。还有个更隐蔽的:重试的层层叠加——A 调 B 重试 3 次、B 调 C 也重试 3 次,C 实际承受 3×3=9 倍流量;多层调用链上每层都重试,流量指数级放大、最底层被打爆。归根结底:重试无退避无抖动无上限、对不可重试错误也重试,下游抖动时重试放大流量、形成恶性循环,把小故障放大成雪崩——这,就是根源。
第一件事:搞懂重试为什么会放大故障
定位到根源,我必须把"重试为什么会从'救命'变成'帮凶'"从根上彻底搞清楚:
重试是双刃剑: 救瞬时抖动, 但会放大整体性故障
# 重试什么时候有用?
# - 偶发的、瞬时的、独立的失败(网络偶尔丢包、某次超时)。
# - 这类失败"重试一下大概率就好了", 重试能提高成功率。
# 重试什么时候变成灾难?
# - 下游"整体性"故障(过载/变慢/挂了)时:
# * 所有请求几乎同时失败 → 同时重试 → 流量翻倍 → 压死下游。
# - 重试在这种时候, 不但救不了, 还"火上浇油"。
# 三个放大效应:
# 1. 流量放大: 重试 N 次 = 最多 N 倍请求量。
# 2. 惊群效应: 大家同时失败、同时(立即)重试 → 流量尖峰。
# 3. 链路叠乘: 多层调用每层都重试 → N^层数 倍放大(最致命)。
# 健康重试的几个要素(缺一不可):
# - 退避(backoff): 失败后等一会再试, 给下游喘息(指数退避: 1s,2s,4s...)。
# - 抖动(jitter): 等待时间加随机, 打散惊群(别都在同一秒重试)。
# - 上限: 限重试次数 + 限并发重试总量(别无限重试)。
# - 熔断(circuit breaker): 下游持续失败 → 直接快速失败, 别再打了。
# - 区分错误: 只重试"可能成功的"(超时/5xx/网络), 别重试 4xx/参数错。
# - 幂等: 只对幂等操作重试(否则重复下单/扣款, 见幂等篇)。
# 关键认知: 重试要"克制"。它是为"瞬时抖动"设计的, 不是为"整体故障"。
# - 整体故障时, 最该做的是"少打甚至不打"(熔断), 而不是"使劲重试"。
# 核心: 重试救瞬时抖动但会放大整体故障(流量放大+惊群+链路叠乘);
# 健康重试必须有退避+抖动+上限+熔断+区分错误+幂等。
原理终于清晰了。重试什么时候有用?——偶发的、瞬时的、独立的失败(网络偶尔丢包、某次超时),这类"重试一下大概率就好",重试能提高成功率。重试什么时候变灾难?——下游"整体性"故障(过载/变慢/挂了)时:所有请求几乎同时失败 → 同时重试 → 流量翻倍 → 压死下游,这时重试不但救不了,还火上浇油。它有三个放大效应:流量放大(重试 N 次 = 最多 N 倍请求)、惊群效应(同时失败同时立即重试、形成流量尖峰)、链路叠乘(多层调用每层都重试 → N^层数 倍放大,最致命)。而健康重试的要素(缺一不可):退避(失败后等一会、指数拉长 1s/2s/4s)、抖动(等待加随机、打散惊群)、上限(限次数+限总量)、熔断(下游持续失败就快速失败、别再打)、区分错误(只重试超时/5xx/网络,别重试 4xx)、幂等(只对幂等操作重试)。由此,我刻下一个关键认知:重试要"克制"——它是为"瞬时抖动"设计的、不是为"整体故障";整体故障时,最该做的是"少打甚至不打"(熔断),而不是"使劲重试"。归根结底:重试救瞬时抖动但会放大整体故障(流量放大+惊群+链路叠乘);健康重试必须有退避+抖动+上限+熔断+区分错误+幂等。
第二件事:正解——退避 + 抖动 + 熔断 + 区分错误
搞懂了原理,正解就清晰了:给重试装上一整套"刹车"——指数退避 + 随机抖动 + 重试上限 + 熔断 + 只重试该重试的错误。
// ✓ 正解: 指数退避 + 随机抖动 + 上限 + 区分错误 的重试
func callWithRetry(req Request) (Response, error) {
maxRetry := 3
base := 100 * time.Millisecond
for i := 0; i <= maxRetry; i++ {
resp, err := doCall(req)
if err == nil {
return resp, nil
}
// ✓ 1. 区分错误: 只重试"可能成功的"(超时/5xx/网络), 不重试 4xx
if !isRetryable(err) {
return resp, err // 参数错误等, 立即返回, 别重试
}
if i == maxRetry {
return resp, err // 重试到上限, 放弃
}
// ✓ 2. 指数退避 + 随机抖动: 等待时间逐次拉长 + 加随机, 打散惊群
backoff := base * time.Duration(1<
修复的方向,是给重试装上"克制"的全套机制。正解的核心:第一,区分错误——isRetryable 只对"可能成功的"错误(超时/连接拒绝/5xx)重试,4xx 参数错误立即返回、绝不重试(重试一万次也不会成功);第二,指数退避 + 随机抖动——失败后等待时间逐次拉长(100ms→200ms→400ms)、再加一段随机抖动,既给下游喘息时间,又打散了"大家同一刻重试"的惊群;第三,重试上限——到了上限就放弃。更关键的,是配合熔断器:当下游持续失败(失败率超阈值),熔断器就"打开"、让后续请求直接快速失败、不再打下游,给下游喘息和恢复的空间;过段时间"半开"试探,恢复了再"关闭"。这是对付"整体故障"的关键武器——整体故障时,最该做的不是使劲重试,而是干脆别打了。此外还要:只对幂等操作重试(否则重复下单/扣款)、控制重试的总并发量(别让重试本身打满连接池)。归根结底:重试要 指数退避 + 随机抖动(打散惊群)+ 上限 + 区分错误(只重试可恢复的)+ 熔断(持续失败就别打)+ 幂等;把"无脑立即重试"变成"克制的智能重试"。
第三件事:重试与它的"战友"——超时、熔断、限流、降级
这次踩坑让我意识到,重试不是孤立的,它要和一整套容错机制配合,才能真正保护系统。我把它们梳理清楚了:
服务容错的一套"组合拳"(重试只是其中一环)
# 1. 超时(timeout): 一切的基础(见无超时雪崩篇)
# - 别无限等; 设合理超时, 快速失败, 才有机会重试/降级。
# 2. 重试(retry): 应对瞬时抖动(本文)
# - 退避+抖动+上限+区分错误+幂等; 整体故障时要克制。
# 3. 熔断(circuit breaker): 应对持续故障
# - 下游连续失败 → 直接快速失败, 不再打 → 保护下游也保护自己。
# - 三态: 关闭(正常)→ 打开(熔断, 不打)→ 半开(试探恢复)。
# 4. 限流(rate limit): 保护自己不被打垮
# - 限制进来的请求速率/并发, 超出的拒绝/排队 → 不让洪峰冲垮自己。
# 5. 隔离(bulkhead): 故障隔离
# - 不同下游/资源用独立的线程池/连接池 → 一个下游挂了不拖垮全部。
# 6. 降级(fallback): 兜底
# - 实在不行时返回"默认值/缓存/友好提示", 别让用户看到崩溃。
# 它们怎么配合(典型链路):
# 请求 → 限流(进得来吗) → 熔断(下游能打吗) → 超时控制下调用
# → 失败且可重试? → 退避抖动后重试 → 仍失败 → 降级兜底
# 关键认知: 单一机制救不了系统; 容错是"超时+重试+熔断+限流+隔离+降级"的体系。
# - 重试只解决"瞬时抖动"; "持续过载"要靠熔断+限流; "彻底不可用"要靠降级。
# 核心: 重试只是容错体系的一环, 要和超时/熔断/限流/隔离/降级配合;
# 瞬时抖动靠重试, 持续故障靠熔断限流, 彻底不可用靠降级兜底。
这套"组合拳",让我对"服务容错"有了体系化的认识。超时是一切的基础(别无限等、快速失败才有机会重试/降级);重试应对瞬时抖动(本文);熔断应对持续故障(下游连续失败就快速失败、不再打,三态:关闭→打开→半开);限流保护自己(限请求速率/并发、不让洪峰冲垮自己);隔离(bulkhead)做故障隔离(不同下游用独立线程池/连接池、一个挂了不拖垮全部);降级(fallback)兜底(实在不行返回默认值/缓存/友好提示)。它们配合起来是一条典型链路:请求 → 限流(进得来吗) → 熔断(下游能打吗) → 超时控制下调用 → 失败且可重试?→ 退避抖动后重试 → 仍失败 → 降级兜底。由此,我刻下一个关键认知:单一机制救不了系统;容错是"超时+重试+熔断+限流+隔离+降级"的体系——瞬时抖动靠重试,持续过载靠熔断+限流,彻底不可用靠降级。归根结底:重试只是容错体系的一环,要和超时/熔断/限流/隔离/降级配合;瞬时抖动靠重试,持续故障靠熔断限流,彻底不可用靠降级兜底。
下面这张图,是这次"重试风暴"的成因与解法:
第四件事:重试策略的几种退避方式对比
这次踩坑后,我把几种重试退避策略,横向比了一遍,按场景对号入座。
| 退避策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 立即重试(无退避) | 失败马上再试 | — | ✗ 惊群+放大故障(本文的坑) |
| 固定间隔 | 每次等固定时间(如1s) | 简单 | 大家仍可能同步, 惊群 |
| 指数退避 | 等待逐次翻倍(1s,2s,4s) | 给下游越来越多喘息 | 无抖动仍可能同步惊群 |
| 指数退避+抖动 | 翻倍+加随机 | ★★★ 推荐, 错峰打散惊群 | 实现稍复杂 |
| +熔断 | 持续失败干脆不打 | 整体故障时的关键保护 | 需维护熔断器状态 |
把它们排在一起,选择就清楚了。最该用的,是"指数退避 + 抖动":指数退避让等待时间逐次翻倍、给下游越来越多的喘息时间;抖动则加入随机、把"大家同步重试"的惊群打散开——两者结合,既给了下游恢复空间,又避免了流量尖峰。而那些不该用的:立即重试(本文的坑,惊群+放大);固定间隔(简单但大家仍可能同步、惊群);纯指数退避无抖动(给了喘息但仍可能同步)。对付"整体故障",还要加上 熔断——下游持续失败时干脆不打了,这是退避也救不了时的最后保护。它给我的启发是:"重试退避"看似只是"等多久再试"的细节,实则是区分"帮下游恢复"和"把下游压垮"的关键;而那个最容易被忽略的"抖动(jitter)",恰恰是打破"惊群"、避免流量尖峰的点睛之笔——它提醒我们:在分布式系统里,"让大家步调不一致",有时反而是一种重要的稳定性设计。
第五件事:重试的几个其他常见误区
这次踩坑后,我把重试相关的其他常见误区,也一并梳理清楚了。
| 误区 | 问题 | 正解 |
|---|---|---|
| 对非幂等操作重试 | 重复下单/扣款 | 只对幂等操作重试(或加幂等键) |
| 对 4xx 错误也重试 | 参数错重试也没用, 纯浪费 | 只重试超时/5xx/网络等可恢复错误 |
| 多层调用每层都重试 | 流量 N^层数 倍放大 | 只在最外层/最合适的一层重试 |
| 重试没有总量控制 | 重试打满自己的连接池/线程池 | 限制重试并发, 配隔离 |
| 超时设太短+疯狂重试 | 下游还在处理就被重试, 雪上加霜 | 超时要合理, 配退避 |
| 整体故障还使劲重试 | 把下游彻底压垮 | 熔断, 整体故障时少打/不打 |
这张表,几乎是一份"重试避坑总纲"。最危险的几个:对非幂等操作重试(会重复下单/扣款,要配幂等键——见幂等篇);对 4xx 重试(参数错重试也没用、纯浪费);多层调用每层都重试(流量 N^层数 倍放大、最致命,应只在最外层或最合适的一层重试);重试没有总量控制(重试本身打满自己的连接池/线程池);整体故障还使劲重试(把下游彻底压垮、要靠熔断)。它们的共同教训是:重试这件"看起来很简单、加个 for 循环就行"的事,其实处处是雷;它牵一发而动全身,要同时考虑幂等性、错误类型、调用层级、流量总量、超时配合、整体故障。它给我的最大启发是:在分布式系统里,几乎没有"简单"的功能——一个看似无害的"失败重试",背后牵连着幂等、惊群、熔断、流量放大等一系列深刻的问题;而一个工程师的成长,很大程度上,就是逐渐认识到这些"看似简单"之事背后的复杂性,并学会用一整套成熟的机制(而非一个简单的 for 循环)去周全地应对它。对分布式系统里的每一个"小功能",都多一分敬畏。
第六件事:给一个调用加重试时,我现在会怎么决策
现在,每当我准备给一个调用加重试,脑子里都会过一遍这张决策图——核心就两问:这操作能重试吗?重试会放大故障吗?
这张图的灵魂,是两个必问的前置问题。第一问:这操作幂等吗?——不幂等且无幂等键的,别重试(会重复下单/扣款),要先做幂等再重试。第二问:这个错误值得重试吗?——4xx/参数错/业务错,别重试、立即返回;只有超时/5xx/网络抖动这类"可能恢复"的才重试。确定可以重试后,要克制:指数退避 + 随机抖动、限重试次数 + 限总并发、配熔断(下游持续失败就快速失败);多层调用链要只在最合适的一层重试、别层层叠加;再配合超时/限流/降级。最后,也是最关键的验证:压测——主动模拟下游故障,验证你的重试不会把它放大成雪崩,而不是等真出事了才发现"重试反而把下游打死了"。
我立下的几条规矩
这场"重试风暴"的事故,换来了我做分布式调用时,刻进骨子里的几条铁律:
- 重试必须退避 + 抖动。绝不立即重试;指数退避给下游喘息,随机抖动打散惊群,这是底线。
- 只重试"可能恢复"的错误。超时/5xx/网络抖动可重试;4xx/参数错/业务错立即返回,重试也没用。
- 只对幂等操作重试。非幂等的要先加幂等键,否则重试=重复下单/扣款。
- 重试要有上限和总量控制。限次数、限并发,别让重试打满自己的连接池,也别无限重试。
- 整体故障靠熔断,不靠重试。下游持续失败时使劲重试是火上浇油;该做的是快速失败、给它喘息。
- 多层调用别层层重试。每层重试会让流量 N^层数 倍放大,只在最合适的一层重试。
- 重试是容错体系一环。要和超时/熔断/限流/隔离/降级配合;并压测验证不会放大成雪崩。
附:一个带退避+抖动+熔断的健壮重试封装
把前面讲的退避、抖动、上限、区分错误、熔断揉到一起,这是我现在封装"健壮重试"的骨架,可以直接参照:
type Breaker struct {
mu sync.Mutex
failures int
openUntil time.Time
threshold int // 连续失败多少次就熔断
cooldown time.Duration // 熔断后多久进入半开
}
func (b *Breaker) Allow() bool {
b.mu.Lock(); defer b.mu.Unlock()
return time.Now().After(b.openUntil) // 熔断期内直接拒绝
}
func (b *Breaker) Report(success bool) {
b.mu.Lock(); defer b.mu.Unlock()
if success {
b.failures = 0 // 成功 → 重置
} else if b.failures++; b.failures >= b.threshold {
b.openUntil = time.Now().Add(b.cooldown) // 失败超阈值 → 熔断
b.failures = 0
}
}
// ✓ 健壮重试: 熔断检查 + 区分错误 + 指数退避 + 抖动 + 上限
func robustCall(req Request, br *Breaker) (Response, error) {
if !br.Allow() { // ✓ 熔断中, 快速失败, 不打下游
return fallback(req), nil // 直接降级兜底
}
const maxRetry = 3
base := 100 * time.Millisecond
var lastErr error
for i := 0; i <= maxRetry; i++ {
resp, err := doCall(req)
br.Report(err == nil) // ✓ 每次结果上报给熔断器
if err == nil {
return resp, nil
}
lastErr = err
if !isRetryable(err) { // ✓ 不可重试的错误, 立即返回
return resp, err
}
if i < maxRetry {
backoff := base * time.Duration(1<
这个封装,把前面所有的原则,拧成了一个可复用的"健壮重试单元"。它的几个关键设计:第一,先查熔断器(br.Allow())——熔断期内直接快速失败、降级兜底,根本不打下游,这是对付"持续故障"的关键;第二,每次调用结果都上报给熔断器(br.Report),让它动态感知下游健康度、连续失败超阈值就熔断;第三,区分错误(isRetryable 不可重试的立即返回);第四,指数退避 + 抖动(给下游喘息、打散惊群);第五,重试耗尽就降级兜底(fallback),而不是直接把错误抛给用户。这一个函数里,就同时实现了"瞬时抖动靠退避重试自愈、持续故障靠熔断快速失败、最终失败靠降级兜底"的完整容错逻辑。这,正是我想用这段骨架,留给每一个做分布式调用的人的最后一课:"健壮的重试",从来不是"加个 for 循环"那么简单;它是退避、抖动、上限、熔断、降级这一整套机制的有机结合。把这套逻辑,封装成一个可复用的组件(或直接用成熟的库,如 resilience4j、go 的 failsafe 等),让"正确的容错",成为团队里每个网络调用的默认标配,而不是依赖每个人临时手写那个危险的"立即重试"——这,才是让分布式系统真正健壮、并能从抖动中优雅自愈的工程之道。
写在最后
回头看,这场由"失败就立即重试"引发的、把小抖动放大成大雪崩的事故,真正教给我的,是一个比"重试要退避"本身更深的道理:在分布式系统里,每一个"局部看起来理性、负责"的行为,叠加起来,却可能导致一个"全局非理性、灾难性"的结果。我那个"失败就重试三次"的逻辑,站在单个客户端的角度看,是无比合理、甚至值得表扬的——"失败了再努力试试,提高成功率",这有什么错?可问题在于,当成千上万个客户端,同时做出这个"个体理性"的决定时,它们汇聚成的洪流,却反而摧毁了它们共同依赖的下游——这,正是分布式系统里经典的"合成谬误":个体最优之和,未必是整体最优。这让我深刻地领悟到:设计分布式系统的行为,不能只站在"单个节点"的视角去想"我这么做合理吗",更要站在"全局、群体"的视角去想"如果所有节点都这么做,会发生什么"。很多分布式的灾难(重试风暴、缓存雪崩、惊群),都源于"大量节点在同一时刻,做出了同样的'个体理性'决定"。所以,好的分布式设计,常常需要引入一些"反同步"的智慧:用抖动打散步调、用熔断主动退让、用限流克制自己——让每个节点都"顾全大局地克制一点",才能成就整个系统的稳定。从"个体理性"升维到"群体理性"——这,是我用一次"重试风暴"的事故,换来的、关于分布式系统、也关于"协作与克制"的、最朴素也最深刻的领悟。如果这篇复盘,能让你在下一次写下重试逻辑时,多想一句"如果大家都这么重试,下游受得了吗",那我对着那场雪崩熬的这大半天,就值了。
—— 别看了 · 2026