Go 实现指数退避重试:context 取消、抖动 jitter 与什么时候别重试

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;而「该不该重试」永远先问「这个操作幂等吗」。
相关推荐
青山木1 小时前
RocketMQ 入门到原理(六):可靠性全景
java·后端·中间件·架构·rocketmq
青山木1 小时前
RocketMQ 入门到原理(五):特殊消息类型
java·后端·中间件·架构·rocketmq
第五页的你1 小时前
SpringBoot 源码理解
后端
RISCV_Explorer1 小时前
RISC-V处理器性能优化:从指令集到微架构的协同设计
后端·risc-v
她的男孩1 小时前
数据库改了配置,说好的 30 秒自动刷新根本没跑:那条 @Scheduled 是注释状态
java·后端·架构
右耳朵猫AI2 小时前
Rust周刊2026W38 | mold重写Rust、认证级Rust裸机、Slint 1.18发布、lint提速3133倍
后端·rust·系统编程
是枚小菜鸡儿吖2 小时前
同版式截图批量打码:用华为云码道做一个本地小工具!
人工智能·后端·ai·华为云码道