必须用 backoff.retry 而不是手写 for+sleep,因其内置指数退避、jitter、context 取消响应、最大耗时控制及状态隔离,避免重试风暴、间隔错乱和忽略取消信号等线上高危问题。

别手写 for + time.Sleep 做重试——它会在 context.DeadlineExceeded 后继续 sleep、共享退避计数器导致间隔错乱、无 jitter 引发重试风暴,且无法响应取消信号。
用 backoff.Retry 而不是裸循环
社区事实标准是 github.com/cenkalti/backoff/v4,它把指数退避、jitter、上下文取消、最大耗时全收口了,你只管传策略和操作函数。手写循环看似可控,实则漏掉 ctx.Done() 检查、溢出防护、状态隔离等细节,线上极易出问题。
- 必须用
backoff.WithContext(b, ctx)包一层,否则重试过程完全无视 cancel - 每次复用
backoff.BackOff实例前,必须调b.Reset(),否则NextBackOff()返回的间隔会串号 - 别直接用
backoff.NewExponentialBackOff()默认值:它的MaxInterval = 1 * time.Second,第 4 次重试就卡死在 1s 不再增长 - 操作函数必须接收
context.Context并透传到底层调用(如http.Client.Do),不能在内部自己 new context
必须加 backoff.WithJitter()
纯指数退避(100ms → 200ms → 400ms)在并发场景下必然形成“重试风暴”:所有 goroutine 在同一时刻发起请求,刚恢复的服务瞬间被压垮。WithJitter() 让等待时间随机偏移 ±25%,打散重试节奏。
- 不加 jitter 的退避,本地测不出问题,上线后高并发下失败率反而飙升
- v4 版本启用 jitter 只需一行:
bo = backoff.WithJitter(bo) - 抖动不是可选项,是生产环境硬性要求;别信“先上线再加”的说法,雪崩往往发生在第一次流量高峰
错误分类必须前置,不能靠 err != nil 判断
HTTP 503 和 400 都返回非空 err,但前者该重试、后者该立刻失败。重试不是兜底,而是针对临时性失败(如 net.OpError、context.DeadlineExceeded)的补救。
- 对
4xx状态码、sql.ErrNoRows、json.SyntaxError这类确定性错误,应调backoff.Permanent(err)直接退出 - gRPC 错误必须用
status.Code(err)判断,禁用strings.Contains(err.Error(), "DeadlineExceeded")—— 字符串匹配不可靠且易被本地化干扰 - 封装统一判断函数
shouldRetry(err error) bool,收口逻辑,避免散落在各处 - POST 请求默认不重试(非幂等),若接口确定幂等,需在
CheckRetry或包装函数中显式放行
并发请求必须隔离退避状态
每个请求的退避过程是独立的:第 1 次失败等 100ms,第 2 次等 200ms……这个计数器如果被多个 goroutine 共用,A 请求第 3 次失败却拿到 B 请求的第 1 次间隔,结果重试节奏全乱。
-
backoff.BackOff实例不能声明为全局变量或结构体字段长期持有 - 每个 HTTP 请求、DB 查询、消息投递都应新建或显式
Reset()后使用 - 若用
retryablehttp.Client,它内部已做隔离,但要注意Client.RetryMax是 per-request 的,不是全局计数器 - 别在包级用
rand.New(rand.NewSource(0))—— seed 固定会导致所有重试抖动值一样
最隐蔽的坑是退避状态没隔离 + 错误没分类 + jitter 没开,这三者叠加,会让服务在低峰期稳如磐石、高峰期雪崩无声。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











