Go 实现指数退避重试:context 取消、抖动 jitter 与什么时候别重试
调外部接口偶尔超时、数据库瞬断,第一反应是「重试一下」。但 for i := 0; i < 3; i++ 里塞个 time.Sleep(time.Second) 的写法,会在故障恢复的瞬间把成千上万个客户端同时唤醒,再次把刚缓过来的服务打垮------这就是「惊群」(thundering herd)。这篇从朴素重试一路改到生产可用:指数退避 + 抖动 + context 取消 + 幂等性判断。
朴素写法:固定间隔重试(有惊群风险)
go
func fetch(url string) ([]byte, error) {
var lastErr error
for i := 0; i < 3; i++ {
resp, err := http.Get(url)
if err == nil {
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
lastErr = err
time.Sleep(time.Second) // 每次都等 1 秒
}
return nil, lastErr
}
两个问题:一是固定 1 秒间隔,对方还没缓过来你就又打过去;二是所有失败的客户端节奏一致,恢复瞬间一起重试,把服务二次打死。
进阶一:指数退避
指数退避的思路是「每次失败,等待时间翻倍」:1s、2s、4s、8s......给对方越来越多的喘息时间,同时加一个上限防止等太久:
go
func backoffDelay(attempt int, base, max time.Duration) time.Duration {
// attempt 从 0 开始:base * 2^attempt
d := base * time.Duration(1<<attempt) // 位移实现 2 的幂
if d > max {
return max
}
return d
}
1<<attempt 就是 2^attempt,比 math.Pow 快且不涉及浮点。但只做指数退避还不够------如果 1000 个客户端同时失败,它们的退避序列完全一样(都是 1s、2s、4s),依然会成批同时醒来。
进阶二:加抖动 jitter,打散重试时刻
抖动就是在退避时间上加一点随机,把「整齐划一的重试」摊平成「均匀分布的重试」。最简单实用的是「Full Jitter」:在 [0, 退避值] 区间取随机:
go
import "math/rand/v2"
func backoffWithJitter(attempt int, base, max time.Duration) time.Duration {
d := base * time.Duration(1<<attempt)
if d > max {
d = max
}
// Full Jitter:在 [0, d) 之间随机,彻底打散各客户端的重试时刻
return time.Duration(rand.Int64N(int64(d)))
}
rand/v2(Go 1.22+)的全局函数已经是并发安全的,不用自己加锁或建 *rand.Rand。加了 jitter 后,即便一万个客户端同时失败,它们的下次重试也会均匀铺开,而不是叠在同一个时间点。
进阶三:用 context 控制取消与总超时
重试期间用户可能已经取消请求、或整体超时到了,这时应该立刻停下 ,而不是傻等完退避。关键是把 time.Sleep 换成 select 同时监听定时器和 ctx.Done():
go
func retryDo(ctx context.Context, maxAttempts int, fn func() error) error {
const (
base = 200 * time.Millisecond
max = 5 * time.Second
)
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
// 先检查 context 是否已取消,避免白跑一次
if err := ctx.Err(); err != nil {
return err
}
lastErr = fn()
if lastErr == nil {
return nil // 成功
}
// 不可重试的错误,立刻返回别浪费退避
if !isRetryable(lastErr) {
return lastErr
}
// 最后一次失败就不用再等了
if attempt == maxAttempts-1 {
break
}
delay := backoffWithJitter(attempt, base, max)
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop() // 及时释放定时器,防泄漏
return ctx.Err()
case <-timer.C:
// 退避结束,进入下一轮
}
}
return fmt.Errorf("重试 %d 次仍失败: %w", maxAttempts, lastErr)
}
两个细节:select 命中 ctx.Done() 时要 timer.Stop() 主动释放定时器;%w 包装 lastErr,上层能用 errors.Is/As 判断根因。
关键:什么时候「别」重试
盲目重试比不重试更危险,两条红线必须守住。
第一,只重试「瞬时/可恢复」的错误。 4xx(参数错、鉴权失败)重试一万次也白搭,只该重试超时、连接失败、5xx、429 这类:
go
func isRetryable(err error) bool {
if err == nil {
return false
}
// 超时、临时网络错误可重试
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
// 业务层可以定义一个哨兵错误标记「可重试」
return errors.Is(err, ErrRetryable)
}
第二,只对「幂等」操作重试。 GET、PUT、DELETE 天然幂等,重试安全。而 POST 创建订单这种非幂等 操作,重试可能导致重复下单------你以为超时失败了,其实对方已经处理成功,只是响应丢了。非幂等操作要重试,必须配合幂等键(idempotency key):客户端生成唯一 key 随请求带上,服务端用它去重,这样重试同一个 key 不会造成第二次副作用。没有幂等保证时,POST 宁可失败也别自动重试。
用起来
go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := retryDo(ctx, 4, func() error {
resp, err := http.Get("https://api.example.com/data")
if err != nil {
return err // 网络错误,isRetryable 会判定可重试
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("服务端错误 %d: %w", resp.StatusCode, ErrRetryable)
}
return nil
})
整体 10 秒超时兜底,单次失败按指数退避 + jitter 重试,context 一取消立刻停。
小结
- 固定间隔重试会惊群:故障恢复瞬间所有客户端一起打过去,把服务二次打垮。
- 指数退避 让等待时间翻倍(
base * 1<<attempt+ 上限),给对方喘息;但退避序列一致仍会成批醒来。 - 加 jitter (Full Jitter:
rand.Int64N(d))把重试时刻均匀打散,这是防惊群的关键一步。 - 用
select+ctx.Done()替代time.Sleep,取消/超时时立刻停并timer.Stop()防泄漏。 - 两条红线:只重试瞬时错误 (超时/5xx/429,不重试 4xx)、只对幂等操作重试(非幂等的 POST 要配幂等键)。
- 记忆点:重试三件套 = 指数退避 + 抖动 + context;而「该不该重试」永远先问「这个操作幂等吗」。