写后端服务时,外部调用失败是常态。网络会抖,依赖会慢,网关会返回 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 取消,写操作要先考虑幂等性。
重试不是让错误消失的魔法。它只是处理短暂故障的一种手段。真正可靠的系统,会把超时、幂等、日志、指标和错误边界一起设计,而不是在失败后简单多试几次。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。