固定重试间隔会导致同步重试风暴:1000个请求同时失败、同时计算出相同延迟(如400ms),在同一毫秒级窗口集体重发,压垮刚恢复的上游服务,引发503和DeadlineExceeded批量爆发。

为什么用固定重试间隔会触发重传风暴
多个 goroutine 同时失败、同时计算出相同重试时间(比如都算出 2^2 * 100ms = 400ms),会在同一毫秒级窗口集体重发请求——上游服务刚恢复,瞬间被压垮。这不是理论风险,是生产环境里 503 Service Unavailable 和 context.DeadlineExceeded 批量爆发的直接原因。
纯指数退避没加抖动,等于把所有客户端的重试节奏对齐了;而高并发队列(比如用 channel 或 worker pool 拉取任务)会让这种对齐效应放大数倍。
- 错误做法:在循环里写
time.Sleep(100 * time.Millisecond * (1 - 错误做法:用全局
rand.Rand实例,seed 固定导致每次抖动值一样 - 正确做法:每次重试前新建带纳秒级 seed 的局部
*rand.Rand
怎么给 backoff 库加 jitter 避免同步重试
如果你用的是 github.com/cenkalti/backoff/v4,别自己手写退避公式——它原生支持抖动,但默认关闭。开开关就行,不用改逻辑。
关键点就这一行:backoff.WithJitter(true),配合 backoff.NewExponentialBackOff() 使用:
bo := backoff.NewExponentialBackOff() bo.WithJitter(true) bo.MaxElapsedTime = 5 * time.Second // 必须设上限,否则可能无限重试 bo.InitialInterval = 100 * time.Millisecond
注意两个易漏配置:
-
MaxElapsedTime必须显式设置,v4 版本不设就用默认 0,变成无限重试 -
MaxInterval建议设为3 * time.Second,防止单次等待过长拖慢队列吞吐 - 每次重试前必须调用
bo.Reset()或新建实例,否则NextBackOff()返回值会复用上一次状态
重试前必须做错误分类,不是所有失败都能重试
高并发队列里混着各种失败:DNS 超时、TCP 连接拒绝、HTTP 400、gRPC codes.InvalidArgument……全扔进重试逻辑,只会让问题更糟。
只对临时性错误重试,其他一律跳过或转人工干预:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 可重试:
errors.Is(err, syscall.ECONNREFUSED)、err.(*net.OpError).Timeout() == true、codes.Unavailable(gRPC) - 不可重试:
codes.NotFound、codes.InvalidArgument、err.(*json.SyntaxError) != nil、io.ErrUnexpectedEOF - 特别注意:
context.DeadlineExceeded是超时结果,不是原因——再重试只是把雪崩延迟 1 秒而已
建议封装一个 isRetryableError(err error) bool 函数,在重试回调里第一行就判断。
队列层要限流,否则重试本身就成了 DDoS
没有并发控制的重试,等于给上游服务发 DDos。尤其文件上传、批量通知这类任务,重试请求会迅速打满连接池、耗尽 Redis 连接或触发远端限流(如 429 Too Many Requests)。
必须在重试外层套信号量(semaphore),限制最大并发重试数:
- 用
chan struct{}实现轻量信号量,容量设为 10~50(根据上游承载力调) - 每个重试任务启动前先
sem ,完成后再 - 别在 goroutine 启动后立刻
defer wg.Done(),要在重试逻辑真正结束时才释放令牌
信号量和 backoff 要分层:信号量控并发总量,backoff 控单个任务的重试节奏。两者缺一不可。
重传风暴最隐蔽的破口,往往不在重试逻辑本身,而在错误分类没做细、信号量没设、或者 jitter 参数忘开——这些点漏掉一个,整个重试就从容错机制变成压测脚本。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










