go泛型retry函数必须接收context.context并支持错误分类:首参为ctx,用ctx.err()提前退出;区分可重试(如网络超时)与不可重试错误(如os.isnotexist);建议用constraints.error约束泛型参数。

Go 泛型 retry 函数必须支持 context.Context 和错误分类
不带 context.Context 的重试封装在生产环境基本不可用——超时控制、主动取消、链路追踪都无从谈起。泛型本身不解决这个问题,但容易让人误以为“类型安全了就稳了”,结果一上线就卡死 goroutine。
关键点在于:重试逻辑要能区分「可重试错误」和「不可重试错误」,比如 os.IsNotExist(err) 或 errors.Is(err, sql.ErrNoRows) 这类错误不该重试,而网络超时或临时 503 才该退避重试。
- 泛型参数约束建议用
constraints.Error(Go 1.22+)或自定义接口,避免泛化成any - 必须接收
ctx context.Context作为首参,所有重试分支都要用ctx.Err()提前退出 - 重试前调用
select { case 是底线,别省
retry.Do 的退避策略不能硬编码,得支持 BackoffFunc 类型注入
固定间隔(如每次 sleep 100ms)在真实服务中大概率引发雪崩;指数退避(min(2^i * base, max))是事实标准,但不同场景对 base、max、是否 jitter 有强需求。
直接把退避逻辑写死在函数里等于放弃灵活性。正确做法是把退避计算抽象为函数类型:
type BackoffFunc func(attempt int) time.Duration
常见实现示例:
- 指数退避:
func(i int) time.Duration { return time.Duration(math.Pow(2, float64(i))) * time.Second } - 带 jitter 的指数退避:在返回值上加
time.Duration(rand.Int63n(int64(100 * time.Millisecond))) - 线性退避:更适用于已知故障恢复时间较稳定的内部服务
泛型 retry 函数的 error 返回必须保留原始错误链,不能只返回最后一次失败
很多封装会返回 err(即最后一次尝试的 error),但调试时你真正需要的是「第一次失败的根因 + 后续重试次数 + 最终失败原因」。丢掉原始错误等于放弃可观测性。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
推荐做法是用 fmt.Errorf("retry failed after %d attempts: %w", n, firstErr) 包裹,其中 firstErr 是首次失败的 error,且确保它没被 errors.Unwrap 掉。
- 不要用
errors.Join拼接多次错误——它不保留顺序,也不方便日志提取 - 如果需要结构化记录,可在重试循环中显式记录
log.Printf("retry attempt %d failed: %v", i, err) - 注意:
errors.Is和errors.As在嵌套 error 上仍有效,只要原始 error 被正确传递
并发重试(如 retry.ParallelDo)极易触发资源泄漏,慎用
看到「并行重试」就兴奋?先想想你的下游服务能不能扛住 N 倍并发请求。多数 retry 场景本质是「等待临时故障恢复」,不是「压测」。
真要用并发重试,必须满足两个条件:
- 上游调用本身是幂等的(否则并发发出去多个相同请求,下游状态可能错乱)
- 必须限制 goroutine 数量,比如用
semaphore.NewWeighted(3)控制最大并发度 - 并发版本的
ctx取消必须穿透到每个子 goroutine,不能只靠主 ctx Done
绝大多数业务场景,老老实实用串行退避重试,配合合理的 BackoffFunc,比盲目并发更可靠。
泛型封装的价值不在炫技,而在让「可重试」这件事变得明确、可控、可观测。最容易被忽略的,其实是第一次失败时的 error 是否被完整保留、是否被正确分类、是否被及时上报——这些细节,比泛型语法本身重要得多。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










