重试:本以为是救命稻草,没想到是压死骆驼的最后一根稻草
先说个真事。
两年前,我们团队接了一个支付系统重构。上线第一天,一切正常。第二天,合作方的支付接口开始频繁超时。我们的服务开始"聪明地"重试——每失败一次,等一等,再试一次。听起来很合理对吧?
然后合作方的接口彻底挂了。不是被我们打挂的,是被全国十几家调用他们接口的公司一起打挂的。但我们"贡献"了里面相当大的一部分流量。为什么?因为我们的重试策略是:无脑重试3次,间隔固定1秒。
1秒间隔、3次重试听起来不多。但你有100台服务器,每秒有1000个请求进来,遇到超时后每台服务器重试3次——你瞬间从每秒1000个请求变成4000个请求。合作方:我谢谢你们全家。
这不是个例。重试是后端开发中最容易被轻视的风险点之一。每个人都觉得"加个重试嘛,小意思",但生产事故里,重试相关的事故往往是放大器——把一个小问题放大成一场灾难。
今天聊三个最要命的重试反模式,这些坑我基本都踩过,有些差点让我当场去世。
第一个坑:无脑固定间隔重试,制造流量风暴
最常见的写法是这样的:
func callPaymentAPI(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error) {
for i := 0; i < 3; i++ {
resp, err := doRequest(ctx, req)
if err == nil {
return resp, nil
}
// 固定等1秒
time.Sleep(time.Second)
}
return nil, fmt.Errorf("after 3 retries: %w", err)
}
这段代码的问题在于:当服务正常时,它没问题;当服务开始抖动时,它会同步地、集体地制造流量风暴。
想象一个场景:服务在第T秒开始响应变慢(还没完全挂),恰好此时有大量请求到达。这些请求第一批失败,重试间隔1秒,于是在T+1秒,所有第一批失败的请求同时重试。如果T+1秒服务还没恢复,T+2秒就是第二轮集体重试。
更糟糕的是,如果你的服务有100个实例,每个实例每秒处理10个请求,遇到超时后每个实例会在1秒后重试这10个请求。这意味着服务抖动后的第1秒,你会在下游看到100 * 10 = 1000个额外请求。
这就是经典的惊群效应(Thundering Herd)。固定间隔是制造惊群的元凶。
正确做法:用指数退避 + 随机抖动。
func callWithBackoff(ctx context.Context, req *Request) (*Response, error) {
maxRetries := 3
baseDelay := 200 * time.Millisecond
maxDelay := 5 * time.Second
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// 指数退避:200ms → 400ms → 800ms
expDelay := baseDelay * time.Duration(1<<attempt)
// 加随机抖动,避免所有请求同步
jitter := time.Duration(rand.Int63n(int64(expDelay)))
delay := expDelay + jitter/2
if delay > maxDelay {
delay = maxDelay
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
resp, err := doRequest(ctx, req)
if err == nil {
return resp, nil
}
// 记录日志:第几次重试、错误类型、延迟多久
log.Warnf("attempt %d failed: %v", attempt+1, err)
}
return nil, fmt.Errorf("max retries exceeded")
}
指数退避(Exponential Backoff)让每次重试的等待时间变长,给下游恢复的机会;随机抖动(Jitter)让请求分散开来,不形成集体冲刺。两者缺一不可。
第二个坑:对写操作做重试,把一时失败变成永久数据不一致
这个坑极其隐蔽,而且极其致命。
我们来看一个典型的扣款场景:
func deduct(userID string, amount int64) error {
// 第一次尝试
err := db.Exec("UPDATE account SET balance = balance - ? WHERE id = ?", amount, userID)
if err != nil {
// 网络抖动?DB超时?不知道,反正失败了,重试!
return retry.Do(func() error {
return db.Exec("UPDATE account SET balance = balance - ? WHERE id = ?", amount, userID)
})
}
return nil
}
你发现问题了没有?
第一次执行可能已经成功了——钱已经扣了——只是返回超时了。如果DB的写入实际上成功了,但网络返回那一刻断了,你重试,就等于再扣一次钱。用户的账户余额凭空少了一笔钱。这不是"超扣"这么简单,在很多业务场景下,这是合规风险。
重试的本质是:"我不知道上一次操作到底成功没有,所以我要再试一次"。这个逻辑对只读操作没问题(读不到数据再读一次,不会改变状态),对写操作来说,每一次重试都可能是一次状态变更。
解决方案是什么?幂等性。不是"怎么让重试安全",而是"怎么让重试的副作用为零"。
func deductIdempotent(userID string, amount int64, idempotencyKey string) error {
// 先查:这个幂等Key是否处理过?
var existing string
err := db.QueryRow("SELECT result FROM idempotency WHERE `key` = ?", idempotencyKey).Scan(&existing)
if err == nil {
// 已经处理过了,直接返回之前的结果
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
// 开启事务
tx, err := db.Begin()
if err != nil {
return err
}
// 插入幂等记录(带唯一约束)
_, err = tx.Exec("INSERT INTO idempotency (`key`) VALUES (?)", idempotencyKey)
if err != nil {
tx.Rollback()
// 如果是唯一键冲突,说明另一个请求正在处理
return retryableerrors.New("concurrent idempotency key registration")
}
// 执行实际的扣款
_, err = tx.Exec("UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?", amount, userID, amount)
if err != nil {
tx.Rollback()
return err
}
// 标记完成
tx.Exec("UPDATE idempotency SET result = 'success' WHERE `key` = ?", idempotencyKey)
return tx.Commit()
}
关键点:用业务幂等Key(可以是订单ID+操作类型)做去重,写入前先查一下是否处理过。数据库唯一约束是你的安全网——即使两个请求同时到达,只有一个能插入成功。
总结一下:对读操作,重试是安全的。对写操作,重试必须配合幂等性保障,否则每一次重试都是在赌你上次到底成功没有。
第三个坑:超时和重试一起用,但它们的分工你搞反了
这个问题更微妙,但见过无数次。
很多人在代码里这么写:
client := &http.Client{
Timeout: 3 * time.Second, // 全局超时3秒
}
for i := 0; i < 3; i++ {
resp, err := client.Do(req)
if err == nil {
return resp, nil
}
time.Sleep(time.Second) // 失败了就等一秒重试
}
这里有个隐含的问题:超时是重试的敌人。
当网络开始变慢,一个请求在2.9秒的时候才收到服务端的响应——但你的超时是3秒,所以没超时,正常返回了。看起来没问题。但当网络进一步变慢,响应时间超过3秒时,超时触发,触发重试,同时服务端其实还在处理你上一个请求——然后你第二个请求也超时,触发第三次重试。
最终结果是:服务端处理了N个重复请求(因为它本来就慢,但没慢到完全挂),而你在客户端因为超时屡次重试,心态也崩了。
更混乱的是:当超时和重试混在一起时,你很难判断"这次到底是真的失败了,还是服务端慢了点但实际处理成功了"。
最佳实践是:让超时和重试各司其职,不要让它们打架。
// 方案一:只用一个——设置合理的超时,让它fail fast,不重试
client := &http.Client{
Timeout: 2 * time.Second, // 2秒内响应不了就认为是失败
}
// 调用时不要重试,让上游(或者专门的旁路重试服务)来处理
// 方案二:只用重试,不设超时(或设一个很长的超时)
// 通过重试机制本身来处理失败,给服务端充足的处理时间
// 方案三(推荐):分层处理
// 1. 短超时(fail fast):用于探测服务是否存活
// 2. 重试机制(带退避):用于处理偶发性故障
// 3. 熔断器(Circuit Breaker):用于在服务彻底挂了时停止请求,避免雪崩
我个人的选择是:用带退避的重试,超时设一个较长的时间(比如10-30秒),不设短超时。重试本身就是对"慢"的一种处理方式——你选择重试,就是选择给服务端多一点时间。
一条公式 + 三个检查点
写重试代码之前,先问自己三个问题:
第一,这个操作重试是安全的吗?(读操作安全,写操作需要幂等性)
第二,重试的流量会不会压垮下游?(固定间隔会制造惊群,指数退避+抖动才能保护下游)
第三,有没有熔断器兜底?(当失败率超过阈值时,熔断器会"跳闸",停止所有请求,避免把一个局部故障放大成全局灾难)
一个合格的带重试的调用代码,大致长这样:
// goroutine-safe retry with circuit breaker (conceptual)
breaker := circuitbreaker.New(func() error {
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
delay := calculateBackoff(attempt)
time.Sleep(delay)
}
err := doRequest(ctx, req)
if err == nil {
return nil
}
// 非幂等写操作且错误不确定时,尽早退出
if isNonIdempotentWrite(err) && isTimeoutError(err) {
return err // 不要重试,不确定是否成功
}
}
return fmt.Errorf("max retries exceeded")
})
重试不是万能药。它是你在分布式系统的不确定性海洋里,给自己的船加的一个救生圈。救生圈有用,但如果你往船上塞太多,最后船会沉。
记住:好的重试策略,是让下游感知不到你在重试。