重试为什么会放大故障:超时预算、指数退避、抖动与幂等
重试为什么会放大故障:超时预算、指数退避、抖动与幂等
重试不是可靠性的免费午餐。它把一次失败变成更多请求:在短暂网络抖动下可能自愈,在容量不足时却会让系统更快失血。
重试会逐层相乘
假设网关、服务 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"]
重试策略应由调用链中的一层统一拥有,其他层只报告可判断的错误。容量故障期间,还需要限流、熔断和降级配合;重试本身不能创造容量。
先问四个问题
- 操作可以重复吗? GET、HEAD 等语义上幂等;创建订单、扣款默认不是。
- 错误可能是瞬态吗? 连接重置、部分 5xx、429 可能值得重试;参数错误、鉴权失败通常不值得。
- 请求还有时间预算吗? 不能让三次各 2 秒的尝试塞进 3 秒的总截止时间。
- 请求体可以重放吗? 流已经读过就不能凭空再发;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 已取消/超时 | 立即停止 |
| 非幂等写、结果未知 | 没有幂等键时不要自动重试 |
最后给重试设一个硬上限:最大尝试次数、总时间预算、单次超时和最大并发缺一不可。真正健康的重试指标不是“最终成功率很高”,而是重试率足够低;持续依赖重试才能成功,说明底层故障已被遮住。