Go 超时和重试入门:context、退避和幂等性一起看

用外部 HTTP 调用的例子讲 Go 里的超时、context 取消、重试退避、幂等性和常见误区。

写后端服务时,外部调用失败是常态。网络会抖,依赖会慢,网关会返回 502,数据库也可能短暂不可用。初学者最容易写出两种极端代码:一种完全不重试,偶发错误直接暴露给用户;另一种无脑重试很多次,把一个小抖动放大成更大的压力。正确做法不是“永远重试”或“永不重试”,而是把超时、退避和幂等性一起考虑。

本文用一个调用用户资料服务的例子,讲 Go 中如何用 context 控制总耗时,如何写简单退避重试,以及哪些请求不应该随便重试。

先有总超时

没有总超时的重试很危险。比如每次请求最多 3 秒,重试 5 次,最坏就可能拖到 15 秒以上。用户早就离开了,服务还在忙。更稳的方式是给整个操作设置一个总 deadline:

func LoadProfile(ctx context.Context, userID string) (Profile, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		"https://profile.example.com/users/"+url.PathEscape(userID), nil)
	if err != nil {
		return Profile{}, err
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return Profile{}, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return Profile{}, fmt.Errorf("profile status: %d", resp.StatusCode)
	}

	var profile Profile
	if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
		return Profile{}, err
	}
	return profile, nil
}

NewRequestWithContext 很重要。它让 context 取消可以中断 HTTP 请求。只在函数外面创建 context,但请求没有使用它,取消就传不下去。

哪些错误值得重试

不是所有错误都值得重试。一般来说,可以考虑重试:

  • 网络临时错误
  • 502、503、504
  • 连接被重置
  • 依赖明确返回“稍后再试”

不应该重试:

  • 400、401、403 这类请求本身错误
  • 参数校验失败
  • 没有幂等保障的写操作
  • 已经超过总超时的请求

一个判断函数可以这样写:

func retryableStatus(code int) bool {
	return code == http.StatusBadGateway ||
		code == http.StatusServiceUnavailable ||
		code == http.StatusGatewayTimeout
}

实际项目里可以再处理 429 Too Many Requests,并尊重响应头里的 Retry-After。入门阶段先把最常见的 5xx 抖动处理好。

简单退避重试

重试不要立刻连续打过去。依赖已经慢了,你还快速补几拳,只会加重压力。可以用递增等待:

func doWithRetry(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
	delays := []time.Duration{
		100 * time.Millisecond,
		300 * time.Millisecond,
		700 * time.Millisecond,
	}

	var lastErr error
	for attempt := 0; attempt <= len(delays); attempt++ {
		resp, err := client.Do(req.Clone(ctx))
		if err == nil && !retryableStatus(resp.StatusCode) {
			return resp, nil
		}
		if resp != nil {
			resp.Body.Close()
			lastErr = fmt.Errorf("temporary status: %d", resp.StatusCode)
		} else {
			lastErr = err
		}

		if attempt == len(delays) {
			break
		}

		select {
		case <-time.After(delays[attempt]):
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}
	return nil, lastErr
}

这里每次用 req.Clone(ctx),确保请求使用当前 context。等待时也监听 ctx.Done(),否则总超时到了,函数还会傻等下一次重试。

这个例子只适合没有请求体或请求体可重复读取的请求。POST JSON body 如果已经被读掉,重试时不能直接复用同一个 body。要么提前把 body 放在 []byte 里,每次重新创建 reader;要么确认调用不需要重试。

幂等性比重试次数更重要

GET 通常可以重试,因为它应该只读取数据。创建订单、扣款、发送短信这类操作不能随便重试。第一次请求可能已经成功,只是响应在网络上丢了;第二次重试可能造成重复订单或重复扣款。

如果写操作必须重试,通常要设计幂等键:

req.Header.Set("Idempotency-Key", operationID)

服务端用这个 key 识别同一次业务操作。相同 key 的重复请求应该返回同一个结果,而不是重复执行。幂等性是业务协议,不是客户端重试库能单方面解决的东西。

入门阶段可以记住一句话:没有幂等保障的写操作,宁可少重试,也不要偷偷重试。

不要把超时藏太深

一个常见坏味道是每个底层函数都自己 context.WithTimeout(context.Background(), time.Second)。这样上层请求取消传不下来,链路里的 deadline 也被切断。更好的方式是从 handler 开始传 r.Context()

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	profile, err := h.profileClient.Load(r.Context(), userID)
	if err != nil {
		http.Error(w, "load profile failed", http.StatusBadGateway)
		return
	}
	writeJSON(w, profile)
}

底层可以在传入 context 的基础上设置更短超时:

func (c *ProfileClient) Load(ctx context.Context, userID string) (Profile, error) {
	ctx, cancel := context.WithTimeout(ctx, c.timeout)
	defer cancel()
	// 发请求
}

这样上层取消和底层超时都能生效。不要在业务函数里凭空创建 context.Background(),除非它真的是脱离请求生命周期的后台任务。

记录重试信息

重试失败时,日志里最好包含 attempt、耗时和最后错误:

log.Printf("call profile failed attempt=%d cost=%s err=%v", attempt, time.Since(start), err)

如果没有日志,线上只会看到接口慢或失败,却不知道背后重试了几次。重试会增加延迟,成功率提高的同时也可能掩盖依赖不稳定。指标上最好能区分“首次成功”和“重试后成功”,这样你才能知道系统是否正在变脆。

小结

Go 里写可靠外部调用,要先设置总超时,再决定哪些错误可重试,并用退避避免放大依赖压力。重试必须尊重 context 取消,写操作要先考虑幂等性。

重试不是让错误消失的魔法。它只是处理短暂故障的一种手段。真正可靠的系统,会把超时、幂等、日志、指标和错误边界一起设计,而不是在失败后简单多试几次。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页