登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

请求重试应该放在 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
Go http.Client、Transport 与业务层之间的请求重试责任边界说明图
图1:重试责任边界说明图,Transport 只覆盖可重放的网络层,业务层保留状态码和写入语义。

这里的关键不是“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 层不应替业务做决定。

Go 创建请求超时后通过 Idempotency-Key 防止重复写入的结构说明图
图2:重复写入防护结构图,同一业务键把超时重试映射回原结果,而不是再次创建资源。

四、创建类接口把重试放回业务流程

创建订单、发货或扣库存时,我更愿意让业务方法拥有一次固定的操作键:第一次请求超时,先调用查询接口确认结果;查不到且服务端明确支持幂等键时,才用同一个键重新提交。查询仍然超时,就把状态记为“待确认”,而不是直接再发一次。

// 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 复用持久连接,也会让重试时的资源状态更难判断。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>