明明可再试一次,为什么失败一次就放弃呢(应用失败重试)

430 阅读10分钟

背景

正如标题所说,我发现公司项目部分场景没有支持失败重试操作,如果非重要场景,执行失败不进行重试节省资源没问题,但在核心场景,如Mq消息的发送,竟然只执行一次发送操作,后续结果也不进行处理,就这个操作经常导致消息丢失从而影响业务功能,只能说真的要加把劲啊,兄弟!!

实际项目为spring cloud微服务架构的后端应用,Mq组件使用的是Rocketmq4.X(为什么不用组件自带的内部重试功能,因为组件被二次封装了,配置内部重试发现重试功能存在Bug无法使用,因无代码权限也只能记录进行反馈,同时官方也建议应用增加相应的重试逻辑,故自定义实现重试逻辑。)

本文将基于Mq消息发送场景解释什么是失败重试,为什么要,并提供两个重试逻辑实现方式:

  1. 自定义实现;

  2. 使用Spring Retry组件实现。

在实际中失败重试并不限于本文场景,只要满足条件都应该引入失败重试机制。

什么是失败重试,为什么要

业务场景可能会涉及与第三方系统交互、或者外部组件交互、更直接的与数据库交互。当涉及到业务完整性时,我们必须保证本次操作要么成功要么失败,如果失败了则提示用户,如更新用户信息,此时用户是清楚本次操作是失败的,会自行再试一次或者反馈给应用,这种场景不会影响业务完整性。

但如果某个操作只是本次业务的附加操作,但又必须完成的,如完成某个操作后发送一条短信、调用第三方系统的通知接口,这些操作不影响当前业务,为了不阻塞业务,通常我们使用异步实现,这里采用的是Mq实现(消息队列作用就不赘述)。如果发送Mq消息失败,此时用户是无感知的,等发现异常时已经过了一段时间,此时操作时效性丢了,还要手动进行补偿,费时又费力。

失败是什么原因操作的?应用和Mq组件为两个不同的服务,之间交互通过网络传输,应用、Mq组件、网络三方都可能有异常,应用发送队列满了丢弃后续消息、Mq组件消息积压不再接收新消息、网络连接超时等情况都有可能导致消息发送失败,通常这些情况都是暂时的,下一刻或等一会可能就好了,此时如果对失败消息再发送一次就可能成功了。

所以重试就是认为本次操作失败是暂时的,再试一次可能就成功了。重试的必要性就是当前业务场景需要保证所有操作都是必须时,重试可以对失败进行兜底,保证业务一致,也降低后续手动补偿次数,提高应用的稳定性、健壮性。

重试模型建立和实现

模型建立

重试模型万变不离其宗,可以概括为以下两点

  1. 操作执行失败了能否再试一次

  2. 如果重试一定程度还是失败能否保留现场信息提供后续补偿机会

对于1,可以采用执行重试次数配合循环实现

对于2,可以采用构建现场信息模型保存到数据库中实现

细节思考:

1中,失败后应该在什么时间进行重试?立即重试还是等待一段时间?前面提过失败的原因可能是三方的状态不对导致发送失败,可以认为状态不对会持续一段时间,如果立即重试,很可能也会失败。

那等待一段固定时间后重试?可以设想一下网络拥塞场景,突然的消息量剧增导致当前10个消息同时发送失败,30个发送成功(即每个时刻最高发送成功的消息数为30,平均发送消息数为20),此时进行等待固定时间后重试,会发现这10个消息又同时进行发送了,如果此次新发送的消息数在21到30之间,就会出现又有消息发送失败了,所以固定间隔重试会导致重试积压,此时我们采用随机间隔,让重试操作均匀分布在不同时间,就可以缓解积压问题。那随机间隔如何确定呢??

通过解析后是否发现重试解决方案对应于一个计算机网络非常经典的重试算法:截断二进制指数退避算法。是的,当没有一个最佳实践解决方案时,我们应该借鉴经典的算法,这个是经过考验能够应用于实际项目的。

如果重试了多次还是失败后续如何操作?多次失败就并非偶发异常引起,应停止重试,同时要知道本次操作是失败的,即将现场信息保存起来,存入数据库或者打印日志等方式,提供后续分析、补偿的可能,如分析为什么出错、job定时重试、手动修复等。

至此,Mq消息发送的失败重试逻辑采用以下思路:

  1. 消息发送增加失败重试功能,重试间隔采用截断二进制指数退避算法生成

  2. 当重试一定次数后还是失败则将本次发送现场持久化入数据库保存,用于后续补偿操作

模型实现

自定义实现

根据重试逻辑思路,转换为如下代码

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Random;

public class RetryImpl {

    private static final Logger log = LoggerFactory.getLogger(RetryImpl.class);

    public void operate() {
        //发送结果
        boolean isSuccess = false;
        //失败后重试次数
        int retryNum = 2;
        //重试单位区间上限
        int maxRetryRange = 4;
        //基础间隔时间,从0到min(2的k次方-1,maxRetryRange)随机取一个数*basicWaitMillisecond为本次的等待时间,单位ms
        int basicWaitMillisecond = 1000;
        Random random = new Random();
        //发送异常标记
        Exception sendException = null;
        String operateResult = null;
        try {
            for (int i = 0; i < retryNum + 1; i++) {
                // 重置异常记录
                sendException = null;
                operateResult = null;
                try {
                    // 执行业务操作
                    log.info("开始执行业务操作……");
                    int nextInt = random.nextInt(3);
                    boolean operateSuccess = nextInt < 1;
                    // 如果消息发送成功,设置成功标志并跳出循环
                    if (operateSuccess){
                        isSuccess = true;
                        if (log.isDebugEnabled()) {
                            log.debug("操作成功!");
                        }
                        break;
                    }else {
                        operateResult = "失败原因:" + nextInt;
                    }
                }catch (Exception e) {
                    sendException = e;
                }
                // 如果是最后一次重试,则跳出循环,否则记录错误并执行等待
                if (i >= retryNum){
                    break;
                }
                if (log.isDebugEnabled()) {
                    log.error("操作执行发生异常,准备重试,operateResult:{}", operateResult==null?"":operateResult, sendException);
                }
                try {
                    //从0到min(2的k次方(第几次重试)-1,maxRetryRange)随机取一个数
                    int retryRandom = random.nextInt(Math.min((int) Math.pow(2, i+1), maxRetryRange+1));
                    int waitTime = retryRandom * basicWaitMillisecond;
                    if (log.isDebugEnabled()) {
                        log.debug("操作开始重试等待,第{}次等待:等待时间为{}", i+1, waitTime);
                    }
                    if (waitTime == 0){
                        continue; //立即重试
                    }
                    Thread.sleep(waitTime);
                } catch (InterruptedException e) {
                    if (log.isDebugEnabled()) {
                        log.error("操作重试等待时线程中断异常", e);
                    }
                }
            }
        } catch (Exception e) {
            log.error("发送操作重试前异常!operateResult:{}", operateResult==null?"":operateResult, e);
            sendException = e;
        } finally {
            if (!isSuccess){
                // 发送失败保存日志
                log.error("操作执行失败,operateResult:{}", operateResult==null?"":operateResult, sendException);
                // 兜底操作,保存现场信息
                log.info("开始保存现场信息……");
            }
        }
    }

}

实现效果

2025-01-19 15:11:45.624  INFO 71244 --- [nio-8888-exec-7] c.x.m.service.RetryImpl                  : 开始执行业务操作……
2025-01-19 15:11:45.624 ERROR 71244 --- [nio-8888-exec-7] c.x.m.service.RetryImpl                  : 操作执行发生异常,准备重试,operateResult:失败原因:2
2025-01-19 15:11:45.625 DEBUG 71244 --- [nio-8888-exec-7] c.x.m.service.RetryImpl                  : 操作开始重试等待,第1次等待:等待时间为0
2025-01-19 15:11:45.625  INFO 71244 --- [nio-8888-exec-7] c.x.m.service.RetryImpl                  : 开始执行业务操作……
2025-01-19 15:11:45.625 ERROR 71244 --- [nio-8888-exec-7] c.x.m.service.RetryImpl                  : 操作执行发生异常,准备重试,operateResult:失败原因:2
2025-01-19 15:11:45.625 DEBUG 71244 --- [nio-8888-exec-7] c.x.m.service.RetryImpl                  : 操作开始重试等待,第2次等待:等待时间为2000
2025-01-19 15:11:47.637  INFO 71244 --- [nio-8888-exec-7] c.x.m.service.RetryImpl                  : 开始执行业务操作……
2025-01-19 15:11:47.637 DEBUG 71244 --- [nio-8888-exec-7] c.x.m.service.RetryImpl                  : 操作成功!

Spring Retry实现

对于重试实现,市场肯定有成熟的解决方案。Spring Retry就是其中一款,主要针对于简单重试场景,具体介绍Spring Retry引入和初始化自行查找资料了解。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

import java.util.Random;

@Service
public class SpringRetryImpl {

    private static final Logger log = LoggerFactory.getLogger(SpringRetryImpl.class);

    /**
     * 使用@Retryable标记为重试方法,返回值必须为void,需要Spring环境支持
     * 注解Retryable:
     *  value:指定触发重试的异常类型,其子类也生效,指定后才会回调@Recover方法
     *  maxAttempts:方法的执行次数
     *  backoff:退避算法相关属性
     *    delay:基础间隔时间
     *    multiplier:指数底数,multiplier的(第几次重试)次方
     *    random:是否开启随机
     *    是则执行截断multiplier进制指数退避算法,从0到multiplier的(第几次重试)次方*delay随机取一个值
     *    否则固定退避,即每次等待时间为multiplier的(第几次重试)次方*delay
     *    maxDelay:最大等待时间
     */
    @Retryable(maxAttempts = 2, backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 4000, random = true))
    public void operate(String param) {
        Random random = new Random();
        // 执行业务操作
        log.info("开始执行业务操作……,param:{}", param);
        int nextInt = random.nextInt(3);
        boolean operateSuccess = nextInt < 1;
        if (operateSuccess){
            if (log.isDebugEnabled()) {
                log.debug("操作成功!");
            }
        }else {
            // 如果消息发送失败,则抛出异常触发重试
            RuntimeException runtimeException = new RuntimeException("失败原因:" + nextInt);
            throw runtimeException;
        }
    }

    /**
     * spring retry重试最大次数还是失败则回调同类中@Recover注解的方法
     * @param e 最后一次重试失败的异常,必须与重试方法抛出的异常类型一致或者父类
     * @param param 重试方法对应的参数列表,类型和顺序一致,可选
     */
    @Recover
    public void recover(Exception e, String param) {
        // 发送失败保存日志
        log.error("操作执行失败,入参:{}", param, e);
        // 兜底操作,保存现场信息
        log.info("开始保存现场信息……");
    }


}

效果

2025-02-23 17:36:37.671  INFO 1552 --- [nio-8888-exec-5] c.x.m.service.SpringRetryImpl            : 开始执行业务操作……,param:springRetryImpl
2025-02-23 17:36:38.871  INFO 1552 --- [nio-8888-exec-5] c.x.m.service.SpringRetryImpl            : 开始执行业务操作……,param:springRetryImpl
2025-02-23 17:36:38.874 ERROR 1552 --- [nio-8888-exec-5] c.x.m.service.SpringRetryImpl            : 操作执行失败,入参:springRetryImpl

java.lang.RuntimeException: 失败原因:1 
.....

2025-02-23 17:36:38.874  INFO 1552 --- [nio-8888-exec-5] c.x.m.service.SpringRetryImpl            : 开始保存现场信息……

可以看到Spring Retry完全支持重试模型描述的功能。

如果一个方法中只有部分逻辑需要重试,除了可以抽取为一个重试方法外,还可以使用RetryTemplate来自定义重试逻辑

总结

本文基于实际场景说明应用操作应支持失败重试的原因和必要性,解释了失败重试对于一些业务场景是非常必要的,能有效降低操作失败率,提高系统的稳定性和健壮性。同时介绍了通用的重试模型并提供相应实现,Spring Retry完全支持重试模型描述的功能,使用简单,官方组件具有更高的可用性,建议无特殊情况优先使用Spring Retry等经过市场检验的组件。