5、重试与退避模式:重试策略、风暴防范与幂等性设计

大家好,我是老张。今天我们来聊聊重试与退避模式。这个话题,说白了就是「失败了怎么办」。在分布式系统里,网络抖动、服务暂时不可用,这些都是家常便饭。你想想看,一个请求发出去,对方没响应,你是立刻再发一次?还是等一等?等多久?这些问题看似简单,但处理不好,系统分分钟雪崩。

我个人习惯把重试策略分成三个层次:什么时候重试、怎么重试、重试失败了怎么办。咱们一个一个来看。

5.1 重试策略:固定间隔、指数退避、抖动

先说说最常见的三种重试间隔策略。

5.1.1 固定间隔重试

最简单粗暴的方式。每次重试之间等待固定的时间,比如 1 秒、2 秒。

// 伪代码示例:固定间隔重试
int retryCount = 0;
int maxRetries = 3;
long interval = 1000; // 1秒

while (retryCount < maxRetries) {
    try {
        callRemoteService();
        break; // 成功则退出
    } catch (Exception e) {
        retryCount++;
        if (retryCount >= maxRetries) {
            throw e; // 重试耗尽,抛出异常
        }
        Thread.sleep(interval);
    }
}

这种策略的优点是简单、可预测。但缺点也很明显——如果服务正在恢复中,固定间隔可能造成「踩踏效应」。我在项目中遇到过,一个数据库连接池挂了,所有客户端都在 1 秒后同时重试,结果连接池刚缓过来又被冲垮了。

注意:固定间隔适合对延迟不敏感、且下游服务恢复时间相对固定的场景。否则,慎用。

5.1.2 指数退避重试

这才是业界主流做法。每次重试的等待时间呈指数增长。比如第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,第四次等 8 秒……

// 伪代码示例:指数退避
int retryCount = 0;
int maxRetries = 5;
long baseInterval = 1000; // 基础间隔 1秒

while (retryCount < maxRetries) {
    try {
        callRemoteService();
        break;
    } catch (Exception e) {
        retryCount++;
        if (retryCount >= maxRetries) {
            throw e;
        }
        long waitTime = (long) Math.pow(2, retryCount) * baseInterval;
        Thread.sleep(waitTime);
    }
}

为什么指数退避有效?因为随着时间推移,重试频率越来越低,给下游服务留出了恢复时间。说白了,就是「别挤,一个一个来」。

经验之谈:我建议设置一个最大退避上限,比如 30 秒或 60 秒。否则指数增长下去,等个几分钟才重试一次,用户体验就太差了。

5.1.3 抖动(Jitter)

指数退避虽然好,但还有一个问题:如果多个客户端同时失败,它们的退避时间完全一样,还是会同时重试。这就是「惊群效应」。

抖动的思路很简单:在退避时间上加上一个随机偏移量。

// 伪代码示例:带抖动的指数退避
long waitTime = (long) Math.pow(2, retryCount) * baseInterval;
// 加上 ±50% 的随机抖动
long jitter = (long) (waitTime * 0.5 * Math.random());
waitTime = waitTime + jitter; // 或者 waitTime - jitter
Thread.sleep(waitTime);

嗯,这里要注意:抖动不是乱加,一般控制在基础时间的 ±25% 到 ±50% 之间。加太多反而失去了退避的意义。

5.2 重试风暴防范

重试风暴,说白了就是「重试引发更多重试,最终拖垮整个系统」。我见过最惨的一次,一个核心服务挂了,所有上游服务都在疯狂重试,结果把消息队列、数据库、缓存全部打满,整个机房差点瘫痪。

怎么防范?我总结了几个关键点:

  • 限制重试次数:不要无限重试。一般 3-5 次就够了。
  • 限制重试窗口:比如 30 秒内最多重试 3 次,超过就不再重试。
  • 熔断机制:当错误率达到阈值时,直接熔断,不再发起请求。
  • 请求缓存:对于读请求,可以考虑缓存结果,避免重复请求。
核心原则:重试是为了提高成功率,而不是制造更大的失败。如果重试本身成了问题,那就不要重试。

5.3 幂等性设计

重试带来的最大问题是什么?是重复执行。比如支付接口,重试一次就多扣一次钱,这谁受得了?

所以,所有支持重试的接口,必须设计成幂等的。幂等的意思是:同一个请求执行多次,和执行一次的效果完全一样。

常见的幂等实现方式:

方案 说明 适用场景
唯一请求 ID 客户端生成唯一 ID,服务端去重 写操作,如订单创建、支付
乐观锁 使用版本号或时间戳,防止覆盖 更新操作,如库存扣减
状态机 业务状态只能向前流转,不能回退 订单状态变更
去重表 用数据库唯一索引保证不重复 消息消费、事件处理

我曾经踩过一个坑:一个支付回调接口,没有做幂等。结果网络抖动导致回调重发了三次,用户被扣了三笔钱。虽然最后退款了,但用户体验极差,还被投诉了。从那以后,我所有写接口都强制要求幂等设计。

5.4 Spring Retry 实战

理论说完了,咱们看看实际怎么用。Spring Retry 是 Spring 生态里最常用的重试框架,用起来非常方便。

5.4.1 基础用法

import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    @Retryable(
        value = {RemoteException.class, TimeoutException.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000)
    )
    public void processPayment(String orderId) {
        // 调用远程支付服务
        remotePaymentService.pay(orderId);
    }
}

这里 @Backoffmultiplier = 2 就是指数退避,delay = 1000 是初始等待 1 秒,maxDelay = 10000 是最大等待 10 秒。

5.4.2 自定义重试策略

如果内置策略不够用,可以自定义 RetryPolicy

import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;

public RetryTemplate createRetryTemplate() {
    RetryTemplate template = new RetryTemplate();

    // 重试策略:最多重试 5 次
    SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
    retryPolicy.setMaxAttempts(5);
    template.setRetryPolicy(retryPolicy);

    // 退避策略:指数退避 + 抖动
    ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
    backOffPolicy.setInitialInterval(1000);
    backOffPolicy.setMultiplier(2.0);
    backOffPolicy.setMaxInterval(30000);
    template.setBackOffPolicy(backOffPolicy);

    return template;
}

5.4.3 重试耗尽后的处理

重试次数用完了怎么办?用 @Recover 注解定义兜底逻辑:

@Retryable(
    value = {RemoteException.class},
    maxAttempts = 3,
    backoff = @Backoff(delay = 1000)
)
public String callRemote(String param) {
    return remoteService.call(param);
}

@Recover
public String recover(RemoteException e, String param) {
    // 重试耗尽后的兜底处理
    log.error("远程调用失败,参数:{}", param, e);
    return "fallback_result";
}
小技巧:我习惯在 @Recover 方法里记录详细的错误日志,并发送告警。这样即使重试失败,也能第一时间发现问题。

5.5 知识体系总览

最后,我用一张图把本章的核心逻辑串起来。你一看就明白了:

重试与退避模式知识体系 重试策略 固定间隔 指数退避 抖动 重试风暴防范 幂等性设计 实战:Spring Retry

这张图把重试策略、风暴防范、幂等性设计和 Spring Retry 实战串在了一起。你从「重试策略」出发,选择合适的方式,同时做好风暴防范和幂等设计,最后用 Spring Retry 落地。环环相扣,缺一不可。

好了,这一章就到这里。记住:重试不是银弹,用得好是救星,用不好是灾难。下次遇到重试场景,先想想这三个问题——重试策略选对了吗?风暴防范做了吗?接口幂等了吗?


公众号:蓝海资料掘金营,微信deep3321