推荐使用 github.com/cenkalti/backoff/v4 实现带指数退避的重试,避免手动计算间隔、缺失 jitter 和忽略上下文取消;需调用 backoff.Reset()、操作函数接收 context.Context,并用 backoff.WithContext 包装。

Go 里用 backoff.Retry 实现带指数退避的重试
直接上手最稳妥的方式是用 github.com/cenkalti/backoff/v4,它封装了标准退避策略,避免自己算错倍数或漏掉 jitter。别自己写 time.Sleep(2^i * time.Second) —— 容易越界、没随机扰动、不处理上下文取消。
常见错误现象:context deadline exceeded 后还在重试;重试间隔越来越长直到卡死;并发调用时多个 goroutine 共享同一个 backoff 实例导致间隔错乱。
- 每次重试前必须调用
backoff.Reset()(尤其在循环中复用 backoff 实例时) - 操作函数必须接收
context.Context,并在内部及时响应ctx.Done() - 推荐用
backoff.WithContext(ctx, b)包一层,而不是手动传 ctx 到重试逻辑里 - 初始间隔设
100 * time.Millisecond比1 * time.Second更友好,避免首错就等太久
为什么不能把 time.AfterFunc 或 time.Ticker 塞进重试循环
因为它们不感知上下文取消,也不适配退避曲线。比如用 time.AfterFunc 启动下一次重试,一旦中间 ctx 被 cancel,定时器还在跑,可能触发无效执行甚至 panic。
使用场景:你想“失败后等 X 秒再试”,但又希望整个流程能被 context.WithTimeout 控制住 —— 这时候必须让等待逻辑本身可中断。
立即学习“go语言免费学习笔记(深入)”;
-
backoff库底层用的是timer := time.NewTimer(...)+select { case - 自己手写时若用
time.Sleep,会阻塞 goroutine 且无法被 ctx 中断 -
time.Ticker适合固定频率轮询,不适合指数增长的间隔,且容易忘记ticker.Stop()
自定义重试条件:不是所有错误都该重试
backoff.Retry 默认遇到任何 error 都重试,但 HTTP 400、gRPC InvalidArgument 这类错误重试毫无意义,反而加重服务压力。
参数差异:原生 Retry 不支持条件过滤,得换用 RetryNotify 或包装操作函数。
- 用
backoff.RetryNotify(op, b, func(err error, d time.Duration) { ... })可在每次失败时加日志或判断是否跳过 - 更干净的做法是在
op函数里提前检查 error 类型:if errors.Is(err, io.EOF) || isBadRequest(err) { return err } - 注意 gRPC 错误要用
status.Code(err)判断,别直接err.Error()匹配字符串 - HTTP 场景建议用
resp.StatusCode >= 500作为重试依据,4xx 一律返回不重试
并发重试时共享 backoff 实例会出什么问题
一个 backoff.ExponentialBackOff 实例维护着当前重试次数和下次间隔,如果多个 goroutine 并发调用它,NextBackOff() 返回的间隔会被互相覆盖,导致退避失效或间隔混乱。
性能影响:看似省了内存分配,实则引入竞态,还可能让本该快速失败的请求拖到最大重试次数。
- 每个独立的重试任务(比如一次 HTTP 请求)必须持有自己的
backoff.BackOff实例 - 如果批量发起 N 个请求,就 new N 次
backoff.NewExponentialBackOff(),别共用 - 可以用
sync.Pool缓存,但前提是确保每次取出后调用Reset(),且不跨 goroutine 复用 - 简单场景下,直接在函数内声明
b := backoff.NewExponentialBackOff()最安全
真正容易被忽略的是:退避不是万能解药,它只缓解临时性故障;如果下游服务已雪崩,重试只会让问题更糟 —— 此时该熔断,而不是调大 MaxInterval。










