如何在业务逻辑当中优雅引入重试机制
马刺2021-02-03

为什么要引入重试机制



我们首先看看正常的业务系统交互流程,就像下面图中所示一样,我们自己开发的系统通过HTTP接口或者通过RPC去访问其他业务系统,其他系统在没出现任何问题的情况下会返回给我们需要的数据,状态为success。

image

但大家在日常的开发工作当中应该碰到过不少这样的问题:自己应用因为业务需求需要调其他关联应用的接口或二方包,而其他应用的接口稳定性不敢过分恭维,老是出一些莫名奇妙的幺蛾子,比如由于接口暂时升级维护导致的短暂不可用,又或者网络抖动因素导致的单次接口请求失败。


image


诸如此类的麻烦问题会因为业务强依赖致使我们自己维护的系统也跟着陷入一种不稳定的状态(当然这个强依赖是没有办法的事情,毕竟业务之间需要解耦独立开发维护)。


所以也就是说重试的使用场景大多是因为我们的系统依赖了其他的业务,或者是由于我们自己的业务需要通过网络请求去获取数据这样的场景。既然一次请求结果的状态非常不可控、不稳定,那么一个非常自然的想法就是多试几次,就能很好的避开网络抖动或其他关联应用暂时down机维护带来的系统不可用问题。


image


当然,这里也有几个引入重试机制以后需要考虑的问题。


  • 我们应该重试几次?
  • 每次重试的间隔设置为多少合适?
  • 如果所有重试机会都用完了还是不成功怎么办?下面我们就这几个问题展开分析一下。


重试几次合适



通常来说我们单次重试所面临的情况就如上面我们分析的一样,有很大的不可确定性,那到底多少次是比较合理的次数呢?这个就要“具体业务具体分析”了,但一般来说3次重试就差不多可以满足大多数业务需求了,当然,这是需要结合后面要说的重试间隔一起讨论的。为什么说3次就基本够了呢,因为如果被请求系统实在处于长时间不可用状态。我们重试多次是没有什么意义的。


重试间隔设置为多少合适



如果重试间隔设置得太小,可能被调用系统还没来得及恢复过来我们就又发起调用,得到的结果肯定还是Fail;如果设置的太大,我们自己的系统就会牺牲掉不少数据时效性。所以,重试间隔也要根据被调用的系统平均恢复时间去正确估量,通常而言这个平均恢复时间很难统计到,所以一般的经验值是3至5分钟。


重试机会用完以后依旧Fail怎么办



这种情况也是需要认真考虑的,因为不排除被调用系统真的起不来的情况,这时候就需要采取一定的补偿措施了。首先要做的就是在我们自己的系统增加错误报警机制,这样我们才能即时感知到应用发生了不可自恢复的调用异常。其次就是在我们的代码逻辑中加入触发手动重试的开关,这样在发生异常情况以后我们就可以方便的修改触发开关然后手动重试。


在这里还有一个非常重要的问题需要考虑,那就是接口调用的幂等性问题,如果接口不是幂等的,那我们手动重试的时候就很容易发生数据错乱相关的问题。


Spring重试工具包



Spring为我们提供了原生的重试类库,我们可以方便地引入到工程当中,利用它提供的重试注解没有太多的业务逻辑侵入性。如下,我们先引入依赖包。


<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

然后在启动类或者配置类上添加@EnableRetry注解,并在需要重试的方法上添加@Retryable注解。


@Retryable
public String hello(){
    long times = helloTimes.incrementAndGet();
    log.info("hello times:{}", times);
    if (times % 4 != 0){
        log.warn("发生异常,time:{}", LocalTime.now() );
        throw new HelloRetryException("发生Hello异常");
    }
    return "hello " + nameService.getName();
}


更详细的用法和属性大家参阅Spring Retry的文档就好了,解释的非常清楚。这里要说的一点是,Spring的重试机制也还是存在一定的不足,只支持对异常进行捕获,而无法对返回值进行校验。



Guava Retry



相比Spring Retry,Guava Retry具有更强的灵活性,可以根据返回值校验来判断是否需要进行重试。我们依然需要先引入它的依赖包。

<dependency>
    <groupId>com.github.rholder</groupId>
    <artifactId>guava-retrying</artifactId>
    <version>2.0.0</version>
</dependency>


在用的时候也很简单,先创建一个Retryer实例,然后使用这个实例对需要重试的方法进行调用,可以通过很多方法来设置重试机制,比如使用retryIfException来对所有异常进行重试,使用retryIfExceptionOfType方法来设置对指定异常进行重试,使用retryIfResult来对不符合预期的返回结果进行重试,使用retryIfRuntimeException方法来对所有RuntimeException进行重试。


@Test
public void guavaRetry() {
    Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
        .retryIfExceptionOfType(HelloRetryException.class)
        .retryIfResult(StringUtils::isEmpty)
        .withWaitStrategy(WaitStrategies.fixedWait(3, TimeUnit.SECONDS))
        .withStopStrategy(StopStrategies.stopAfterAttempt(3))
        .build();

    try {
        retryer.call(() -> helloService.hello());
    } catch (Exception e){
        e.printStackTrace();
    }
}


相比Spring,Guava Retry提供了几个核心特性。


  • 可以设置任务单次执行的时间限制,如果超时则抛出异常。
  • 可以设置重试监听器,用来执行额外的处理工作。
  • 可以设置任务阻塞策略,即可以设置当前重试完成,下次重试开始前的这段时间做什么事情。
  • 可以通过停止重试策略和等待策略结合使用来设置更加灵活的策略,比如指数等待时长并最多10次调用,随机等待时长并永不停止等等。


小结



上面针对我们为什么要引入重试机制,引入重试机制需要思考的几个核心问题,以及为重试机制提供良好支持的工具类库都分别作了简单介绍,相信大家在今后的开发工作中遇到类似场景也能驾轻就熟地使用思考了。


我们日常工作中有很多“大”的业务场景需要我们集中精力去突破、去思考,但也有很多类似的“小”点需要我们去打穿、吃透,大家共勉。