背景:重试这事儿,谁没写过呢
写HTTP重试?简单。for循环 + if判断 + sleep,结束。真的吗?
我之前也这么想。直到有一天,线上出了个bug,用户收到了三条重复的短信验证码。我才开始认真审视"重试"这两个字。
这篇文章不讲"重试怎么写",而是讲"重试为什么容易写烂"。每个写过重试逻辑的工程师,都值得花五分钟看看。
坑一:超时和重试搅在一起
最常见的写法是这样的:
for i := 0; i < 3; i++ {
resp, err := http.DefaultClient.Do(req)
if err == nil {
return resp, nil
}
time.Sleep(time.Second * time.Duration(i+1))
}
return nil, errors.New("failed after 3 attempts")
这段代码的问题在于:它把"网络错误需要重试"和"请求超时需要终止"混为一谈。实际上,HTTP请求可能因为三个原因失败:
- 网络不可达 —— 可以重试,而且幂等的话应该尽快重试
- 服务器返回了5xx错误 —— 可以重试,因为服务器可能只是暂时过载
- 业务超时(比如支付回调)—— 不能重试,重试可能导致双扣
很多工程师写到一半就忘了第三种情况,然后半夜两点被电话叫起来处理数据问题。
坑二:没有区分"可重试"和"不可重试"
HTTP方法决定了能不能重试:
- GET:幂等,随便重试
- DELETE:幂等,可以重试,但要注意"不存在"也算成功
- POST / PUT:非幂等,要非常小心
但即便对于POST,如果你的接口设计的是"创建资源",重复调用本身就是个业务错误。你重试一万次也不会得到正确结果。
一个合格的HTTP客户端至少需要这样判断:
func isRetryable(statusCode int, method string) bool {
// 5xx 服务器错误,可以重试
if statusCode >= 500 {
return true
}
// 429 Too Many Requests,说明我们请求过快,等待后可以重试
if statusCode == 429 {
return true
}
// 4xx 客户端错误,原则上不重试
// 但408 Request Timeout是个例外,服务器可能真的处理不过来
if statusCode == 408 {
return true
}
return false
}
坑三:重试间隔是等差数列
三个请求,分别间隔1秒、2秒、3秒——这是等差数列,重试效果很差。
原因是:当系统发生故障时,大量的请求会在同一个时间点涌进来。如果所有客户端都用相同的等差间隔重试,就会形成"惊群效应"——所有重试在同一秒内到达,把服务器再次打垮。
正确做法是指数退避(Exponential Backoff),最好加上随机抖动:
func backoff(attempt int) time.Duration {
base := 100 * time.Millisecond
maxDelay := 30 * time.Second
// 指数退避:100ms, 200ms, 400ms, 800ms...
exp := base * time.Duration(1<<attempt)
if exp > maxDelay {
exp = maxDelay
}
// 加上随机抖动,避免所有客户端同时重试
jitter := time.Duration(rand.Int63n(int64(exp / 2)))
return exp + jitter
}
这样写的好处是:第一次失败后100ms重试,第二次400ms后重试,第三次800ms后重试——而且因为有随机抖动,不会所有客户端同时打过来。
坑四:没有记录重试日志和上下文
当重试发生问题时,最让你崩溃的不是bug本身,而是"这个请求到底重试了几次?每次状态是什么?"
好的重试框架应该记录:
- 当前是第几次重试
- 上次失败的原因
- 距离第一次请求过去了多久
- 请求的唯一标识(trace ID)
没有这些信息,排查问题的时候你就是瞎子。
坑五:以为重试是"免费的"
很多人写重试的时候觉得"反正就是多发几次请求嘛"。实际上,重试是有代价的:
- 服务端负载翻倍甚至更多
- 用户等待时间变长(如果同步等待的话)
- 幂等性设计缺失时,可能产生副作用(如重复扣款)
所以重试策略必须谨慎设计:
type RetryConfig struct {
MaxAttempts int // 最多重试几次
MaxTime time.Duration // 总共允许花多长时间重试
PerRetryTimeout time.Duration // 每次重试的超时
}
// 如果总的重试时间已经超过MaxTime,直接放弃
// 而不是等到MaxAttempts用完
实际推荐:用什么库?
如果你用的是Go,强烈推荐go-retry或者自己封装一个简单的包装器,真的不需要造这个轮子。
Python的话,urllib3和requests库都内置了重试支持,稍微配置一下就行:
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
session = requests.Session()
retries = Retry(total=3, backoff_factor=0.1, status_forcelist=[500, 502, 503, 504])
session.mount('http://', HTTPAdapter(max_retries=retries))
一句话:不要自己写for循环做重试。你会漏掉很多东西。
总结:重试是个技术活
写重试逻辑的时候,最容易犯的错是"想得太简单"。重试不仅仅是"失败了就再试一次",它涉及到:
- 超时和重试的分离
- 幂等性判断
- 退避策略设计
- 日志和可观测性
- 资源限制和熔断
这些每一个都够写一篇文章。今天这篇先帮你把这些坑标出来,以后写重试之前,先过一遍这个清单。
至于那条三条重复短信的bug,最后是怎么修的?加了个分布式锁。下一篇文章再细说。