重试前必须先分类错误类型:临时性错误(如net.OpError、502/503/504、codes.Unavailable)才可重试,确定性错误(如sql.ErrNoRows、codes.NotFound)应直接放弃,须用errors.Is或类型断言判断而非err!=nil。

重试前必须先分类错误类型,否则重试次数再精准也没用
很多团队设了 maxRetries = 3 就以为万事大吉,结果发现 sql.ErrNoRows 被重试了三次,json.SyntaxError 也进了重试循环——这不是容错,是掩盖问题。临时性错误和确定性错误必须分开处理:
- 临时性错误才值得重试:
net.OpError(连接拒绝、超时)、context.DeadlineExceeded、HTTP 响应码502/503/504、gRPC 的codes.Unavailable或codes.ResourceExhausted - 确定性错误直接放弃:
sql.ErrNoRows、strconv.ParseInt失败、errors.Is(err, os.ErrNotExist)、codes.NotFound、codes.InvalidArgument - 别用
err != nil当重试开关;用errors.Is或类型断言判断,或显式调用backoff.Permanent(err)标记不可重试
HTTP 客户端重试该用 RoundTripper,不是 for 循环
在业务函数里写 for i := 0; i 看似简单,实则把超时控制、错误判断、退避逻辑全耦合进业务代码,后续加监控、换策略、隔离健康检查都得动主逻辑。更可控的做法是封装 <code>http.RoundTripper:
- 用
&http.Transport{RoundTrip: originalRT}包装原生 Transport,只拦截RoundTrip返回的错误,不影响其他 HTTP 调用 - 对
*url.Error判断:优先用err.Timeout(),避免靠字符串匹配"connection refused"这种脆弱方式 - 对响应体判断:仅当
resp.StatusCode >= 500 && resp.StatusCode 才考虑重试;<code>400、401、404一律跳过 - 切忌复用
http.DefaultClient并全局加重试——健康检查请求也会被重试三次,反而放大下游压力
gRPC 重试配置三要素缺一不可
gRPC v1.29+ 支持客户端重试,但默认关闭。光写 grpc_retry.WithMax(3) 没用,必须同时满足三个条件:
- 显式传入拦截器:
grpc.WithChainUnaryInterceptor(grpc_retry.UnaryClientInterceptor()) - 在
grpc.DialOption中正确拼接,比如grpc.WithTransportCredentials(...)后追加重试选项 - 用
grpc_retry.WithCodes(codes.Unavailable, codes.ResourceExhausted)明确指定可重试码;codes.NotFound不会触发重试 - 注意:重试会重新序列化请求体,proto.Message 里不能含
sync.Mutex等不可序列化字段,否则 panic
指数退避必须加 jitter,否则并发下就是重试风暴
纯指数退避(如 1s → 2s → 4s)在多实例、高并发场景下极易导致大量请求在同一时刻撞上服务端,压垮本就脆弱的下游。抖动(jitter)不是优化项,是安全底线:
立即学习“go语言免费学习笔记(深入)”;
- 每次延迟应在
[base * 0.75, base * 1.25]区间内随机,上限不超过基础值的 0.5 倍 - 用独立的
*rand.Rand实例,例如r := rand.New(rand.NewSource(time.Now().UnixNano())),避免多 goroutine 共享默认 seed 导致抖动失效 - 别直接用
math/rand.Float64()套公式;第 2 次重试(n=1)基础间隔为 2s,则实际 sleep 在 1.5s~2.5s 随机,而非固定 2s - 每次重试必须新建
context,否则第二次重试时ctx.Err() != nil已成立,却没检查就继续执行,造成无效调用或资源泄漏
真正难的不是写个重试循环,而是让每一次重试都“有理有据”:错误类型判得准、退避抖动控得住、上下文生命周期管得清、幂等边界守得牢。这些点漏掉任何一个,重试就从容错变成扰动。


















