纯指数退避会触发重试风暴,因为多个客户端起始时间与逻辑一致,导致同步重试(如第3次均为800ms);抖动通过每次重试独立用time.Now().UnixNano()初始化rand,在[0.5,1.5)区间随机偏移等待时间,有效打散重试点。

为什么纯指数退避会触发重试风暴
多个客户端在服务恢复瞬间集体重试,本质是时间对齐问题:2^3 * 100ms 算出来都是 800ms,结果所有 goroutine 同时苏醒发请求。这不是理论风险,而是线上真实压垮刚恢复服务的常见原因。
抖动(jitter)不是“加点随机数”就完事——它必须让重试时间在基础间隔上做有界偏移,且每次重试都重新采样,否则失去打散效果。
- 错误做法:用全局
rand.Rand实例,seed 固定为0→ 每次运行抖动序列完全一致 - 错误做法:只在循环外初始化一次
rand.Rand→ 所有重试共享同一个随机序列,实际偏移趋同 - 正确做法:每次调用
jitteredBackoff()都用time.Now().UnixNano()初始化新实例
用 backoff/v4 开箱启用 jitter
别手写 for + time.Sleep,它无法响应 ctx.Done(),还容易漏掉 Reset() 导致 attempt 计数错乱。直接用 github.com/cenkalti/backoff/v4 是更稳的选择。
关键点只有三行:
bo := backoff.NewExponentialBackOff()
bo = backoff.WithJitter(bo) // 必须显式开启
err := backoff.Retry(func() error { return doRequest(ctx) }, backoff.WithContext(bo, ctx))注意:WithJitter() 默认使用 RandomizationFactor=0.5,即区间 [0.5, 1.5),和手动实现一致;但如果你复用 bo 实例(比如放在 struct 字段里),每次新请求前必须调 bo.Reset(),否则 attempt 累加错位。
retry-go 更适合业务层控制重试语义
当你要明确区分“总共执行 N 次”还是“最多重试 N 次”,或者需要按错误类型白名单过滤时,github.com/avast/retry-go 的 API 更贴近直觉。
示例:
err := retry.Do(func() error {
return doHTTPRequest(ctx, url)
}, retry.Attempts(4), // 总共执行 4 次(含首次)
retry.DelayType(retry.FixedDelay), // 可换为 retry.BackOffDelay
retry.RetryIf(func(err error) bool {
return errors.Is(err, net.ErrClosed) ||
strings.Contains(err.Error(), "timeout")
}))它原生支持 context.Context,无需额外包装;retry.Attempts(4) 表示“最多尝试 4 次”,不是“重试 4 次”,这点和 backoff.Retry 的语义不同,容易踩坑。
抖动因子选 [0.5, 1.5) 而不是 [0, 1)
用 rand.Float64() 直接映射到 [0, 1) 是常见误区——这会让部分请求立刻重试(0 * base),可能加重下游压力,甚至触发限流。
标准做法是线性映射到 [0.5, 1.5):
jitter := 0.5 + rand.Float64()*1.0 // 不是 rand.Float64()
这个范围保证最小等待时间不低于基础值一半,最大不超过 1.5 倍,既打散了时间点,又不会让某次重试短得离谱或长得失焦。生产环境别改这个范围,除非你做过压测验证其他区间更优。
真正容易被忽略的不是抖动公式本身,而是每个重试周期都必须独立初始化随机源——哪怕只是多开一个 goroutine,也得重新 rand.New(rand.NewSource(time.Now().UnixNano())),否则抖动就失效了。

















