重试:本以为是救命稻草,没想到是压死骆驼的最后一根稻草

2026-10-02 3 0

重试:本以为是救命稻草,没想到是压死骆驼的最后一根稻草

先说个真事。

两年前,我们团队接了一个支付系统重构。上线第一天,一切正常。第二天,合作方的支付接口开始频繁超时。我们的服务开始"聪明地"重试——每失败一次,等一等,再试一次。听起来很合理对吧?

然后合作方的接口彻底挂了。不是被我们打挂的,是被全国十几家调用他们接口的公司一起打挂的。但我们"贡献"了里面相当大的一部分流量。为什么?因为我们的重试策略是:无脑重试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")
})

重试不是万能药。它是你在分布式系统的不确定性海洋里,给自己的船加的一个救生圈。救生圈有用,但如果你往船上塞太多,最后船会沉。

记住:好的重试策略,是让下游感知不到你在重试。

相关文章

你的Pod正在被”悄悄枪毙”:K8s资源压力下的驱逐机制全解
还在为部署AI工具秃头?小龙虾帮你一键搞定!
连上了就别断开:一次把HTTP长连接聊透
Prompt写得好是艺术,写得烂是工伤:我调教AI三年的血泪经验
你的限流方案,可能是后端最大的性能陷阱
连接池:那些年我们踩过的坑,比你想象的要多得多

发布评论