请求重试应该放在 Transport 还是业务层,如何避免重复写入
来源:17golang原创
时间:2026-10-07 03:04:54 255浏览 收藏
我在排查一次“客户端超时、服务端却多出一条记录”的问题时,最先想改的是 Transport 的重试次数。后来把链路拆开才发现:Transport 适合处理可安全重放的网络失败,业务层才知道一次创建操作能不能再做一遍。两层都重试,重复写入几乎是必然的;两层都不重试,又会把短暂网络抖动直接暴露给用户。
- GET、HEAD,以及有明确幂等语义的请求,才适合进入通用 Transport 重试。
- POST 创建不能只看“请求失败”,要结合 Idempotency-Key、结果查询和服务端去重记录。
- 每次重试都要重新取得请求体,响应体要关闭,次数和总耗时必须有上限。
一、先把重试责任分成两层
http.Client 负责更高层的重定向、Cookie 和调用入口,Transport 负责一次 HTTP 事务。Go 文档还明确说明:非 2xx 响应不会自动变成 Client.Do 的 error,而 RoundTripper 不应把重定向、认证等高层语义混进去。因此,重试位置不能按“哪里方便”决定,而要看失败是否已经带有业务含义。
| 场景 | 建议归属 | 判断依据 |
|---|---|---|
| 连接刚建立失败、短暂网络错误 | Transport 包装层 | 请求可重放,且尚未拿到业务结果 |
| 502/503/504 等暂时性服务状态 | 业务客户端 | 需要读取响应头、Retry-After 和接口语义 |
| 创建订单、扣库存、发起支付 | 业务层 | 必须绑定幂等键并能查询最终结果 |
| 限流、鉴权、参数错误 | 业务层或直接终止 | 重试不能修复 401、403、400 |

这里的关键不是“Transport 永远不能重试”,而是通用层必须保守。Go 的 Transport 本身只会对满足幂等条件的部分网络错误做有限处理;自定义包装器如果把所有 POST 和所有 5xx 都重放,就扩大了风险面。
二、先证明请求可以被安全重放
HTTP 规范把多次相同请求产生同一预期效果称为幂等。GET、HEAD、PUT、DELETE 通常更容易满足这一点,但“方法名看起来安全”并不等于你的接口实现没有副作用。POST 创建接口若由服务端支持 Idempotency-Key,也可以把一次逻辑操作变成可重试操作;关键是同一业务操作的键必须保持不变,不能每次重试都生成新键。
请求体也是一道边界。RoundTripper 消费并关闭 Request.Body,第二次发送前必须通过 Request.GetBody 重新构造 body。使用 bytes.NewReader、strings.NewReader 等标准输入创建请求时,http.NewRequest 通常会自动提供 GetBody;自定义流则应显式提供工厂,无法重建就不要放进通用自动重试。
// replayableRequest 只允许明确具备重放条件的请求进入 Transport 重试。
func replayableRequest(req *http.Request) bool {
// 这些方法通常是幂等的,但仍要以服务端实际语义为准。
switch req.Method {
case http.MethodGet, http.MethodHead, http.MethodPut, http.MethodDelete:
return req.Body == nil || req.GetBody != nil
case http.MethodPost:
// POST 只有在服务端按幂等键去重时才允许自动重放。
return req.Header.Get("Idempotency-Key") != "" && req.GetBody != nil
default:
return false
}
}
三、Transport 重试要有退避、上限和体重建
下面这个包装器只示范“网络错误或明确的临时状态”两类入口,实际项目还应接入随机抖动和指标。它不会把 400、401、403、404 当作暂时故障,也不会无限等待。收到响应后如果准备重试,先关闭旧 body,避免连接池被悬挂响应拖住。
// RetryTransport 给可重放请求增加有限次数的底层重试。
type RetryTransport struct {
Base http.RoundTripper
Attempts int
Backoff time.Duration
}
func (t RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.Base
if base == nil {
// 复用默认 Transport,保留连接池和 HTTP/2 能力。
base = http.DefaultTransport
}
max := t.Attempts
if max 0 && req.GetBody != nil {
// 每次重试都重建 body,不能复用已被读取的流。
body, err := req.GetBody()
if err != nil {
return nil, err
}
current = req.Clone(req.Context())
current.Body = body
}
resp, err := base.RoundTrip(current)
if err == nil && (resp.StatusCode
这段代码把“可重放”作为第一道闸门,但它仍然不知道 POST 是否已经在服务端落库。也就是说,Idempotency-Key 只是双方约定的入口,服务端还需要保存键与结果的对应关系;没有这部分协议,Transport 层不应替业务做决定。

四、创建类接口把重试放回业务流程
创建订单、发货或扣库存时,我更愿意让业务方法拥有一次固定的操作键:第一次请求超时,先调用查询接口确认结果;查不到且服务端明确支持幂等键时,才用同一个键重新提交。查询仍然超时,就把状态记为“待确认”,而不是直接再发一次。
// CreateOrder 让一次逻辑创建共享同一个幂等键。
func CreateOrder(ctx context.Context, client *http.Client, payload []byte, key string) error {
// bytes.Reader 让 NewRequest 能为重试准备 GetBody。
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.example.test/orders", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Idempotency-Key", key)
resp, err := client.Do(req)
if err != nil {
// 超时只说明结果未知,先查询 key 对应的订单,再决定是否重试。
return fmt.Errorf("创建结果待确认: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode
示例中的域名只是代码占位,不代表真实接口。生产实现要补齐三件事:服务端按键保存最终响应、查询接口能用同一键或业务号确认状态、客户端为每次逻辑操作保存可追踪的状态转移。这样即使网络在服务端提交之后断开,也不会把“未知”误判成“未执行”。
五、上线前的重试检查清单
- Transport 是否只覆盖网络错误和明确的临时状态?
- 每种方法的幂等语义是否由接口契约确认,而不是只看方法名?
- 带 body 的请求是否有可靠的
GetBody,重试前是否关闭旧响应? - POST 是否固定使用同一个
Idempotency-Key,服务端是否保存键、状态和响应? - 是否记录 attempt、请求键、最终状态与耗时,并对重试率、待确认数设置告警?
相关问题
Transport 能不能统一重试所有 5xx?
不建议。502、503、504 也可能代表请求已经被上游处理,应该结合接口幂等性、Retry-After 和业务结果判断。
请求超时是不是一定没有写入?
不是。客户端只知道在限定时间内没有拿到结果,服务端可能已经提交,必须通过幂等键或查询接口确认。
没有 Idempotency-Key 的 POST 怎么办?
不要把它放入通用自动重试;优先补服务端幂等协议,或把操作改造成可查询、可补偿的业务流程。
为什么重试前要关闭 Response.Body?
Go 文档要求调用方关闭响应体;不关闭可能阻碍底层 Transport 复用持久连接,也会让重试时的资源状态更难判断。
-
273 收藏
-
Golang · Go问答 | 31分钟前 | go · 文件上传 · 流式处理 · 内存优化 · net/http · 流式读取 ParseMultipartForm Go HTTP服务 MaxBytesReader Go大文件上传 MultipartReader141 收藏
-
232 收藏
-
128 收藏
-
Golang · Go问答 | 1小时前 | net/http · Go问答 · 性能边界 · 高并发 连接池 http.Transport Go HTTP客户端 http.DefaultClient151 收藏
-
378 收藏
-
109 收藏
-
159 收藏
-
209 收藏
-
221 收藏
-
122 收藏
-
309 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习