必须用 go-retry 库配 retry.Retry + 显式错误过滤 + 带 timeout 的 context.Context;单层 I/O 重试会丢错、泄漏句柄、误判 EIO;HTTP 重试需结合状态码、body 读取结果与语义错误分类,且须加抖动和最大延迟限制。

直接结论:别从零手写 retry 循环,用 github.com/sethvargo/go-retry,但必须配对使用 retry.Retry + 显式错误过滤 + 带 timeout 的 context.Context,否则极易陷入无限重试或吞掉关键错误。
为什么不能只用 retry.Do 包裹单个 I/O 操作
常见错误是把重试粒度卡在 os.Open 或 http.Get 这一层。比如:retry.Do(func() error { f, _ := os.Open(path); return nil }) —— 看似“重试了打开”,实则埋下三个坑:
- 文件成功打开后,后续
f.Read或io.ReadAll(f)报read: connection reset by peer,但重试已退出,错误被静默丢弃 -
defer f.Close()在每次循环里无法执行,多次失败会累积未关闭句柄,最终触发too many open files - 挂载点(如 NFS)临时离线时,
os.Open可能返回syscall.EIO,但 Go 标准库不自动转成语义化错误,retry.Do默认不会识别它为可重试信号
retry.Retry 必须传入完整动作函数,且带 context 和错误过滤
正确做法是把整个“打开 → 读取 → 关闭”封装成一个原子操作,并交给 retry.Retry:
- 用
retry.WithMaxRetries(5):超过 5 次大概率不是瞬时故障,而是路径不存在或权限问题 - 退避策略选
retry.WithCappedBackoff(10*time.Millisecond, 500*time.Millisecond):本地 I/O 故障恢复快,10ms 起步比 1s 更合理 - 上下文必须带超时:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second),否则重试卡住无感知 - 错误过滤函数里只放真正可重试的底层错误:
errors.Is(err, syscall.EIO) || errors.Is(err, syscall.EAGAIN) || os.IsTimeout(err);而os.IsNotExist(err)必须立刻返回,不可重试
HTTP 请求重试时,状态码和 body 读取必须一起判断
仅检查 resp.StatusCode 不够。很多服务在过载时返回 503,但响应体可能为空或不完整;更危险的是,某些 CDN 或代理会缓存 200 响应但实际后端已失败。
立即学习“go语言免费学习笔记(深入)”;
- 重试条件应包含:
resp.StatusCode >= 500 || resp.StatusCode == 429 - 必须调用
io.ReadAll(resp.Body)并检查是否成功,否则resp.Body未读完会导致连接复用异常 - 如果
io.ReadAll返回io.ErrUnexpectedEOF或字节数远小于Content-Length,说明传输中断,应重试 - 避免对
401、403、404重试——这些是语义明确的业务错误,重试只会放大问题
指数退避不是万能解药,抖动和上限必须同时设
纯 2^i 指数增长在高并发场景下容易引发“重试风暴”,多个客户端在同一时刻重试,反而压垮下游。
- 必须加随机抖动:
retry.WithJitter(0.1)(10% 范围内随机偏移) - 必须设最大间隔上限:
retry.WithMaxDelay(500 * time.Millisecond),防止第 10 次重试等 5 秒才发请求 - 如果底层服务 SLA 是 200ms P99,那重试总耗时不应超过 1.5 秒,否则用户已感知超时,再重试失去意义
真正的难点不在“怎么重试”,而在“什么时候不该重试”——错误分类、动作边界、上下文生命周期,三者缺一不可。漏掉任意一项,容错就变成负优化。


















