跳至内容
重试为什么会放大故障:超时预算、指数退避、抖动与幂等

重试为什么会放大故障:超时预算、指数退避、抖动与幂等

2026年6月17日·
yanlong

重试不是可靠性的免费午餐。它把一次失败变成更多请求:在短暂网络抖动下可能自愈,在容量不足时却会让系统更快失血。

重试会逐层相乘

假设网关、服务 A、服务 B 都“最多重试 3 次”,最坏情况下,一个用户请求会在最底层制造 27 次尝试。

    flowchart TD
    U["1 个用户请求"] --> G1["网关尝试 1"]
    U --> G2["网关尝试 2"]
    U --> G3["网关尝试 3"]
    G1 --> A1["A × 3"]
    G2 --> A2["A × 3"]
    G3 --> A3["A × 3"]
    A1 --> B1["B × 3"]
    A2 --> B2["B × 3"]
    A3 --> B3["B × 3"]
  

重试策略应由调用链中的一层统一拥有,其他层只报告可判断的错误。容量故障期间,还需要限流、熔断和降级配合;重试本身不能创造容量。

先问四个问题

  1. 操作可以重复吗? GET、HEAD 等语义上幂等;创建订单、扣款默认不是。
  2. 错误可能是瞬态吗? 连接重置、部分 5xx、429 可能值得重试;参数错误、鉴权失败通常不值得。
  3. 请求还有时间预算吗? 不能让三次各 2 秒的尝试塞进 3 秒的总截止时间。
  4. 请求体可以重放吗? 流已经读过就不能凭空再发;HTTP 请求可通过 GetBody 或重新构造 Body 支持重放。

“没有收到响应”不表示服务端没有执行。连接可能在服务端提交之后、响应到达之前断开。这正是非幂等写请求最危险的灰区。

指数退避必须带抖动

没有抖动的客户端会在同一时刻失败,又在同一时刻醒来,形成周期性尖峰。Full Jitter 的思路是让第 n 次等待均匀分布在 [0, cap]

func retry(ctx context.Context, maxAttempts int, op func(context.Context) error) error {
	var last error
	for attempt := 0; attempt < maxAttempts; attempt++ {
		if err := op(ctx); err == nil {
			return nil
		} else if !isTransient(err) {
			return err
		} else {
			last = err
		}

		if attempt == maxAttempts-1 { break }
		capDelay := min(100*time.Millisecond*(1<<attempt), 2*time.Second)
		delay := time.Duration(rand.Int64N(int64(capDelay) + 1))

		timer := time.NewTimer(delay)
		select {
		case <-ctx.Done():
			if !timer.Stop() { <-timer.C }
			return context.Cause(ctx)
		case <-timer.C:
		}
	}
	return fmt.Errorf("retry exhausted: %w", last)
}

生产实现还应:遵守服务端的 Retry-After;把单次尝试的超时从剩余总预算中切出来;限制最大退避;记录“逻辑请求数”和“实际尝试数”两个指标。不要把错误字符串当分类依据,应使用类型、状态码或 errors.Is/As

幂等键把重复请求识别为同一件事

对“创建支付”一类写操作,客户端生成稳定的幂等键,服务端把它和请求指纹、最终结果一起持久化:

    sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    participant D as 数据库
    C->>S: POST /payments, Idempotency-Key: K
    S->>D: 原子写入 K + 请求指纹
    D-->>S: 首次请求
    S->>D: 执行业务并保存结果
    S--xC: 响应途中断开
    C->>S: 使用同一个 K 重试
    S->>D: 查询 K
    D-->>S: 返回已保存结果
    S-->>C: 同一业务结果
  

幂等表至少要解决:

  • 同一个键并发到达时,只有一个执行者;可用唯一约束和事务实现。
  • 同一个键配上不同请求体时拒绝,避免键误用。
  • “处理中”和“已完成”状态如何恢复,不能永远卡住。
  • 键的作用域、过期时间和结果保留时间与业务重试窗口一致。

幂等键不是去重缓存。只有“记录键”和“业务提交”处于同一原子边界,才能避免记了键却没执行、或执行了却没记键。跨系统副作用通常还需要 Outbox、状态机或对方提供的幂等能力。

一个可操作的重试策略

情况默认决策
DNS 临时失败、连接重置在预算内有限重试
408、429、部分 5xx按接口约定与 Retry-After 决定
400、401、403、404通常不重试
Context 已取消/超时立即停止
非幂等写、结果未知没有幂等键时不要自动重试

最后给重试设一个硬上限:最大尝试次数、总时间预算、单次超时和最大并发缺一不可。真正健康的重试指标不是“最终成功率很高”,而是重试率足够低;持续依赖重试才能成功,说明底层故障已被遮住。

进一步阅读:RFC 9110:幂等方法RFC 9110:Retry-After