调用第三方接口,网络抖一下很正常。但”失败就重试”如果写得随意,反而会把对方打挂、把自己的线程占满。一个可靠的重试客户端要同时满足:会退让、有边界、可取消。
最容易踩的坑
for i := 0; i < 3; i++ {
resp, err := client.Do(req)
if err == nil {
return resp
}
time.Sleep(time.Second) // 错误:固定间隔 + 不区分错误类型
}
三个问题:固定间隔会造成同步重试风暴;不判断错误类型,4xx 也重试纯属浪费;没有超时控制,一个慢接口能拖垮整个服务。
判断哪些错误值得重试
| 状态码 | 是否重试 | 原因 | |—|—|—| | 408 超时 | 是 | 请求未被处理 | | 429 限流 | 是 | 需读 Retry-After | | 500 / 502 / 503 / 504 | 是 | 服务端临时故障 | | 4xx(除 408/429) | 否 | 请求本身有问题,重试还是失败 |
网络层错误(连接被拒、超时)可以重试,但DNS 解析失败通常是配置问题,重试无意义。
指数退避加抖动
func backoff(attempt int, base, max time.Duration) time.Duration {
d := base << attempt // 100ms, 200ms, 400ms
if d > max || d <= 0 {
d = max
}
// 加 ±25% 抖动,避免多实例同时重试
jitter := time.Duration(rand.Int63n(int64(d) / 2))
return d/2 + jitter
}
抖动是必须的。没有抖动时,一批客户端会在同一时刻一起重试,形成二次洪峰。
完整结构
type RetryClient struct {
http *http.Client
retries int
}
func (c *RetryClient) Do(req *http.Request) (*http.Response, error) {
ctx := req.Context()
var lastErr error
for i := 0; i <= c.retries; i++ {
if i > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err() // 上游已取消,立刻退出
case <-time.After(backoff(i-1, 100*time.Millisecond, 2*time.Second)):
}
}
resp, err := c.http.Do(req)
if err == nil && !shouldRetry(resp.StatusCode) {
return resp, nil
}
if resp != nil {
resp.Body.Close() // 必须关,否则连接泄漏
lastErr = fmt.Errorf("status %d", resp.StatusCode)
continue
}
lastErr = err
}
return nil, fmt.Errorf("重试 %d 次后仍失败: %w", c.retries, lastErr)
}
注意 resp.Body.Close()——忘记关 body 是连接泄漏的头号原因,表现为跑几小时后端口耗尽。
与超时配合
http.Client.Timeout 是单次请求的总时限,不含重试等待。三次重试加两轮退避,最坏情况要按 6 倍估算。如果调用方有整体 deadline,用 context.WithTimeout 包住整个 Do,上面的 ctx.Done() 分支会保证及时退出。
小结
- 只重试幂等请求和临时性错误
- 指数退避 + 抖动,别用固定间隔
- 必须支持 context 取消
- 每次失败都要
Body.Close()