mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3198 字
9 分钟
指数退避重试(Retry with Backoff)
2026-06-13

一、为什么需要指数退避重试#

网络请求失败了,第一反应是什么?重试。但如果立即重试呢?下游服务正好在过载,你的重试只会给它增加负担。更糟的是,如果同时有成千上万个客户端都在失败后立即重试,就会形成”重试风暴”——所有客户端在同一时刻冲击正在恢复的服务,把它再次打挂。这就是惊群效应(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 ✓
每条横杠 = 下次重试前的等待时间(每次翻倍)
+ 抖动:在每个等待窗口内随机偏移,避免惊群效应
mermaid
flowchart TD
Request[发起请求] --> Result{成功?}
Result -->|是| Return[返回结果]
Result -->|否| Check{达到最大重试次数?}
Check -->|是| Fail[抛出异常]
Check -->|否| Wait[等待 baseDelay × 2^attempt + jitter]
Wait --> Request

3.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 = 1smaxRetries = 5maxDelay = 30s,不考虑抖动,总等待时间是 1 + 2 + 4 + 8 + 16 = 31 秒。加上抖动后,实际总等待时间在 15-46 秒之间浮动。这个计算在设计 SLA 时很重要——你需要知道最坏情况下用户等多久。

关键参数:

参数典型值说明
baseDelay1 秒首次重试的等待时间
maxDelay30-60 秒单次等待上限,防止无限增长
maxRetries3-10 次最大重试次数
jitter0-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 都采用了这种机制。如果操作本身无法做成幂等的,重试就不应该作为容错手段。

六、生产验证#

项目源码位置用途
KubernetesBackoff 结构体定义退避参数,ExponentialBackoff(L475)实现重试。用于 Pod 重启、API 服务器重试,支撑整个 K8s 控制面的容错
gRPC-GoExponential.Backoff()计算带抖动的指数延迟,基础延迟每次翻倍,上限为 MaxDelayRunF(L86-L109)是带上下文取消的重试编排循环
AWS SDK for Go v2RetryMaxAttempts 中间件所有 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 秒以上,用户等不了。这种场景更适合快速失败 + 后台异步重试 + 通知用户
  • 本地操作——文件未找到、语法错误,这些不会自行修复。重试只对”可能自行恢复”的故障有意义

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

指数退避重试(Retry with Backoff)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-retry-backoff/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时