别再乱写HTTP重试了:一次设计失控的复盘

2026-09-19 21 0

背景:重试这事儿,谁没写过呢

写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,最后是怎么修的?加了个分布式锁。下一篇文章再细说。

相关文章

为什么你的API总被骂?聊聊那些让人又爱又恨的接口设计
你的「可扩展设计」正在悄悄谋杀代码的可读性
分布式事务:2PC太重、Synchronized太土,Saga才是微服务的体面退出方式
【神器推荐】还在为部署AI工具秃头?一键部署服务来了,拯救你的头发!🦞
写API这事儿,10个人里有9个没想明白
写API这事儿,10个人里有9个没想明白

发布评论