一、为什么需要指数退避重试
网络请求失败了,第一反应是什么?重试。但如果立即重试呢?下游服务正好在过载,你的重试只会给它增加负担。更糟的是,如果同时有成千上万个客户端都在失败后立即重试,就会形成”重试风暴”——所有客户端在同一时刻冲击正在恢复的服务,把它再次打挂。这就是惊群效应(Thundering Herd)。
惊群效应的破坏力可以用数字来感受。假设一个服务正常处理 1000 QPS,某次故障导致 5000 个客户端同时失败。如果所有客户端都在 1 秒后重试,那么 1 秒后这 5000 个请求同时涌入——是正常负载的 5 倍。服务刚恢复一点,又被这波重试打回原形,形成恶性循环。
另一种极端是直接放弃。一次网络超时就彻底失败,用户体验很差。很多故障是瞬时的:网络抖动、服务重启、GC 暂停——等一小会儿就好了。区分瞬时故障(Transient Fault)和永久故障(Permanent Fault)是重试策略的前提。瞬时故障包括网络超时、服务端 503、429 限流、连接被拒绝等,它们有一个共同特点:过一会儿再试,大概率能成功。永久故障则不同——400 Bad Request 说明请求本身有问题,401 Unauthorized 说明认证失败,404 Not Found 说明资源不存在。这些错误重试一万次也不会变成成功,应该立即暴露给调用方。
指数退避重试在”立即重试”和”直接放弃”之间找到平衡:失败后等一会儿再试,如果还是失败,等更久再试。加上随机抖动,让所有客户端的重试时间错开,避免同步冲击。它只对瞬时故障生效,对永久故障快速失败——这是正确使用重试的前提。
二、现实类比
给一家忙线的餐厅打电话。第一次打,占线;等一分钟再打,还是占线;等两分钟;然后四分钟。你不会每隔一秒就重拨——那样只会占着电话线,让自己和餐厅都不得安宁。而且你不会精确地在整分钟重拨,会稍微错开一点时间,这样所有被占线的人不会在同一秒回拨。
这个类比揭示了两个关键点:等待时间要递增(否则就是骚扰),重试时间要错开(否则就是集体冲击)。指数退避解决第一个问题,抖动解决第二个问题。
三、核心思想
核心公式很简单:第 n 次重试的等待时间 = baseDelay * 2^n + jitter。每次失败后等待时间翻倍,直到达到上限或重试次数耗尽。抖动(jitter)是随机偏移,防止多个客户端同步重试。
时间 ─────────────────────────────────────────────────────►尝试 1 ✗ ├─┤ 1s尝试 2 ✗ ├───┤ 2s尝试 3 ✗ ├───────┤ 4s尝试 4 ✗ ├───────────────┤ 8s尝试 5 ✗ ├───────────────────────────────┤ 16s (上限)尝试 6 ✓每条横杠 = 下次重试前的等待时间(每次翻倍)+ 抖动:在每个等待窗口内随机偏移,避免惊群效应mermaidflowchart TD Request[发起请求] --> Result{成功?} Result -->|是| Return[返回结果] Result -->|否| Check{达到最大重试次数?} Check -->|是| Fail[抛出异常] Check -->|否| Wait[等待 baseDelay × 2^attempt + jitter] Wait --> Request3.1 抖动策略
抖动不是随便加个随机数就行,不同的抖动策略效果差异很大。AWS 的架构博客曾专门对比过三种常见策略:
固定抖动:在指数延迟上加一个固定范围的随机值,比如 delay = baseDelay * 2^n + random(0, baseDelay)。这种方式简单,但高次重试时抖动范围相对延迟本身太小,打散效果有限。
全抖动(Full Jitter):直接在 0 到指数延迟之间随机取值,即 delay = random(0, baseDelay * 2^n)。AWS 推荐这种策略,打散效果最好——所有客户端的重试时间均匀分布在整个窗口内,不会出现聚集。代价是延迟方差较大。
去相关抖动(Decorrelated Jitter):每次的延迟基于上一次的实际延迟计算,delay = random(baseDelay, prevDelay * 3)。延迟增长更平滑,避免了全抖动偶尔出现的”第一次重试就等很久”的问题。gRPC 默认使用这种策略。
实际选择时,如果下游服务对突发流量非常敏感(比如 AWS API),用全抖动;如果希望延迟增长更可预测,用去相关抖动。固定抖动一般不推荐。
3.2 为什么需要 maxDelay 上限
指数增长的速度远超直觉。baseDelay = 1s 时,第 10 次重试的等待时间是 512 秒——超过 8 分钟。第 15 次是 16384 秒,接近 5 小时。没有上限的话,等待时间会增长到荒谬的程度。
设置 maxDelay 的另一个原因是业务容忍度。大多数场景下,用户不会等超过 30-60 秒。即使系统愿意等,用户早就刷新页面了。maxDelay 把等待时间封顶在合理范围内,超过不如直接失败。
3.3 总等待时间的计算
假设 baseDelay = 1s,maxRetries = 5,maxDelay = 30s,不考虑抖动,总等待时间是 1 + 2 + 4 + 8 + 16 = 31 秒。加上抖动后,实际总等待时间在 15-46 秒之间浮动。这个计算在设计 SLA 时很重要——你需要知道最坏情况下用户等多久。
关键参数:
| 参数 | 典型值 | 说明 |
|---|---|---|
baseDelay | 1 秒 | 首次重试的等待时间 |
maxDelay | 30-60 秒 | 单次等待上限,防止无限增长 |
maxRetries | 3-10 次 | 最大重试次数 |
jitter | 0-50% | 随机偏移比例 |
操作复杂度:
| 属性 | 值 | 说明 |
|---|---|---|
| 延迟增长 | 指数级 | 每次翻倍,上限封顶 |
| 总尝试次数 | 有限 | 3-10 次,避免无限循环 |
| 空间 | O(1) | 只需存储当前尝试次数和配置 |
四、变体与对比
| 策略 | 等待时间 | 突发保护 | 适用场景 |
|---|---|---|---|
| 固定间隔重试 | 恒定 | 无 | 调试、简单场景 |
| 线性退避 | 线性增长 | 弱 | 低并发场景 |
| 指数退避 + 抖动 | 指数增长 + 随机 | 强 | 生产环境标配 |
| 熔断器 | 不重试,直接拒绝 | 最强 | 下游明确不可用 |
固定间隔重试是最简单的方案,但也是最容易引发惊群效应的。想象 1000 个客户端都以固定 2 秒间隔重试,它们会形成周期性的请求脉冲,每 2 秒冲击一次下游。线性退避(每次加固定值,如 1s、2s、3s、4s)比固定间隔好一些,但增长太慢——在高并发场景下,前几次重试的间隔太短,仍然可能形成冲击。
指数退避通过递增延迟减轻压力,抖动进一步打散重试时间。指数增长的好处是前几次重试间隔短(快速恢复),后续间隔指数级拉长(给下游恢复时间)。这种”先快后慢”的节奏恰好匹配故障恢复的典型模式:很多瞬时故障在 1-2 秒内恢复,少数需要更长时间。
熔断器是更激进的策略——它根本不重试,直接拒绝请求。当下游失败率超过阈值时打开熔断器,所有请求直接失败;过一段时间后放少量请求试探(半开状态),成功就关闭熔断器,否则继续断开。
实际中,指数退避重试和熔断器经常搭配使用:重试处理瞬时故障,熔断器处理持续故障。当熔断器跳闸时,重试逻辑应该尊重熔断器的状态,不再尝试。两者配合,既不放过瞬时故障的恢复机会,也不在持续故障时浪费资源。
五、多语言实现
5.1 Go 实现
package backoff
import ( "context" "errors" "math" "math/rand" "time")
type Config struct { MaxRetries int // 最大重试次数 BaseDelay time.Duration // 基础延迟 MaxDelay time.Duration // 单次延迟上限 Jitter float64 // 抖动比例 (0-1)}
// Retry 带指数退避的重试func Retry(fn func() error, cfg Config) error { var lastErr error for attempt := 0; attempt <= cfg.MaxRetries; attempt++ { lastErr = fn() if lastErr == nil { return nil // 成功,返回 } if attempt == cfg.MaxRetries { break // 最后一次也失败了,不再等待 }
// 计算指数延迟 + 抖动 exp := float64(cfg.BaseDelay) * math.Pow(2, float64(attempt)) jitter := exp * cfg.Jitter * rand.Float64() delay := time.Duration(math.Min(exp+jitter, float64(cfg.MaxDelay)))
time.Sleep(delay) } return lastErr}
// RetryWithContext 支持上下文取消的重试func RetryWithContext(ctx context.Context, fn func() error, cfg Config) error { var lastErr error for attempt := 0; attempt <= cfg.MaxRetries; attempt++ { select { case <-ctx.Done(): return ctx.Err() // 上下文取消,立即返回 default: }
lastErr = fn() if lastErr == nil { return nil } if attempt == cfg.MaxRetries { break }
exp := float64(cfg.BaseDelay) * math.Pow(2, float64(attempt)) jitter := exp * cfg.Jitter * rand.Float64() delay := time.Duration(math.Min(exp+jitter, float64(cfg.MaxDelay)))
select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): // 等待结束,继续重试 } } return lastErr}
// 可重试错误集合var ( ErrTimeout = errors.New("timeout") ErrRateLimited = errors.New("rate limited") ErrUnavailable = errors.New("service unavailable") ErrBadRequest = errors.New("bad request") ErrUnauthorized = errors.New("unauthorized"))
// RetryableError 判断错误是否可重试func RetryableError(err error) bool { // 瞬时错误 → 可重试 // 429/503/超时 → 可重试 // 400/401/403 → 不可重试 return errors.Is(err, ErrTimeout) || errors.Is(err, ErrRateLimited) || errors.Is(err, ErrUnavailable)}Go 版本把退避逻辑和重试逻辑放在一起,简洁直观。RetryableError 是一个重要的辅助函数——不是所有错误都值得重试。实际项目中,错误分类通常更细致:HTTP 层面,429 和 5xx 可重试,4xx 不可重试(408 Request Timeout 例外);TCP 层面,连接拒绝和连接重置可重试,但 DNS 解析失败通常不可重试。
RetryWithContext 是生产环境必备的变体。没有上下文取消的重试是危险的——如果上游已经超时,你的重试还在傻等,白白浪费 goroutine 和下游资源。用 select 监听 ctx.Done(),可以在上下文取消时立即退出。
5.2 TypeScript 实现
interface BackoffConfig { maxRetries: number; // 最大重试次数 baseDelay: number; // 基础延迟(毫秒) maxDelay: number; // 单次延迟上限 jitter: number; // 抖动比例 (0-1)}
async function retryWithBackoff<T>( fn: () => Promise<T>, config: BackoffConfig = { maxRetries: 5, baseDelay: 1000, maxDelay: 30000, jitter: 0.5, },): Promise<T> { let lastError: Error | undefined;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { return await fn(); } catch (err) { lastError = err as Error; if (attempt === config.maxRetries) break;
// 指数延迟 + 随机抖动 const exponential = config.baseDelay * Math.pow(2, attempt); const jitter = exponential * config.jitter * Math.random(); const delay = Math.min(exponential + jitter, config.maxDelay);
await new Promise((r) => setTimeout(r, delay)); } }
throw lastError;}TypeScript 版本用 async/await 实现异步重试,天然适配 fetch 等 Promise 化的 API。抖动用的是乘法随机——在指数延迟的基础上加一个 0 到 exponential * jitter 的随机值。
5.3 幂等性:重试的安全前提
重试有一个隐含假设:重试是安全的。换句话说,同一个请求执行多次,效果和执行一次一样——这就是幂等性。HTTP 方法中,GET、PUT、DELETE 是幂等的,POST 通常不是。重试一个 POST /create-order 请求,可能创建两笔订单。
解决这个问题的常见方案是幂等键(Idempotency Key)。客户端在请求头中带上一个唯一标识(如 Idempotency-Key: uuid-xxx),服务端记录这个键和对应的处理结果。收到相同键的请求时,直接返回之前的结果,不再重复执行。Stripe 和 AWS 都采用了这种机制。如果操作本身无法做成幂等的,重试就不应该作为容错手段。
六、生产验证
| 项目 | 源码位置 | 用途 |
|---|---|---|
| Kubernetes | Backoff 结构体 | 定义退避参数,ExponentialBackoff(L475)实现重试。用于 Pod 重启、API 服务器重试,支撑整个 K8s 控制面的容错 |
| gRPC-Go | Exponential.Backoff() | 计算带抖动的指数延迟,基础延迟每次翻倍,上限为 MaxDelay。RunF(L86-L109)是带上下文取消的重试编排循环 |
| AWS SDK for Go v2 | RetryMaxAttempts 中间件 | 所有 AWS API 调用默认带指数退避重试,429 和 5xx 自动重试,抖动防止 SDK 用户同步冲击 AWS 端点 |
这三个项目恰好展示了三种不同的抖动策略选择:Kubernetes 使用固定抖动,gRPC 使用去相关抖动,AWS SDK 使用全抖动。
七、小结
何时使用:
- 网络请求——HTTP 调用、数据库连接、RPC,瞬时抖动是常态。一次 TCP 重置不代表服务挂了,可能只是负载均衡器在摘节点
- 分布式系统间调用——服务重启、GC 暂停等导致暂时不可用。Java 的 Full GC 可能暂停数秒,这段时间内的请求大概率超时
- 限流 API——命中 429 时退避等待,而不是反复冲击。很多 API 有严格的限流策略,重试只会让限流窗口重置更慢
- 队列消费者——消息处理失败时退避重试,避免堵塞队列。但要注意设置最大重试次数,超过后进入死信队列
何时不用:
- 非瞬时错误——400 Bad Request 重试也不会成功,应该立即暴露问题。对所有异常无差别重试只会掩盖 bug
- 非幂等操作——重试 POST /create-order 可能创建重复订单,需要幂等键保护。没有幂等性保障的重试,比不重试更危险
- 用户等待场景——指数退避意味着可能等 30 秒以上,用户等不了。这种场景更适合快速失败 + 后台异步重试 + 通知用户
- 本地操作——文件未找到、语法错误,这些不会自行修复。重试只对”可能自行恢复”的故障有意义
八、参考资料
- AWS Exponential Backoff - AWS 关于指数退避重试的最佳实践
- gRPC Retry Design - gRPC 重试机制的官方设计文档
- Google Cloud Retries - GCS 关于重试策略的建议,含抖动算法选择
- Kubernetes wait package - K8s 退避重试的 Go 包文档
- Release It! (2nd Edition) - Michael Nygard 著,含重试风暴和级联故障的深度分析
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






