Java编程指南:高可用性的重试策略解析

发表时间: 2022-11-16 15:55

1、背景介绍

随着互联网的发展项目中的业务功能越来越复杂,有一些基础服务我们不可避免的会去调用一些第三方的接口或者公司内其他项目中提供的服务,但是远程服务的健壮性和网络稳定性都是不可控因素。在测试阶段可能没有什么异常情况,但上线后可能会出现调用的接口因为内部错误或者网络波动而出错或返回系统异常,因此我们必须考虑加上重试机制。

重试机制可以提高系统的健壮性,并且减少因网络波动依赖服务临时不可用带来的影响,让系统能更稳定的运行。

2、测试环境

2.1 模拟远程调用

本文会用如下方法来模拟远程调用的服务,其中每调用3次才会成功一次:

@Slf4j@Servicepublic class RemoteService {    /**     * 记录调用次数     */    private final static AtomicLong count = new AtomicLong(0);    /**     * 每调用3次会成功一次     */    public String hello() {        long current = count.incrementAndGet();        System.out.println("第" + current +"次被调用");        if (current % 3 != 0) {            log.warn("调用失败");            return "error";        }        return "success";    }}

2.2 单元测试

编写单元测试:

@SpringBootTestpublic class RemoteServiceTest {    @Autowired    private RemoteService remoteService;    @Test    public void hello() {        for (int i = 1; i < 9; i++) {            System.out.println("远程调用:" + remoteService.hello());        }    }}

执行后查看结果:验证是否调用3次才成功一次

同时在上边的单元测试中用for循环进行失败重试:在调用的时候如果失败则会进行了重复调用,直到成功

@Test
public void testRetry() {
for (int i = 1; i < 9; i++) {
String result = remoteService.hello();
if (!result.equals("success")) {
System.out.println("调用失败");
continue;
}
System.out.println("远程调用成功");
break;
}
}

上述代码看上去可以解决问题,但实际上存在一些弊端:

  • 由于没有重试间隔,很可能远程调用的服务还没有从网络异常中恢复,所以有可能接下来的几次调用都会失败
  • 代码侵入式太高,调用方代码不够优雅
  • 项目中远程调用的服务可能有很多,每个都去添加重试会出现大量的重复代码

3、自己动手使用AOP实现重试

考虑到以后可能会有很多的方法也需要重试功能,咱们可以将重试这个共性功能通过AOP来实现:

使用AOP来为目标调用设置切面,即可在目标方法调用前后添加一些重试的逻辑。

1)创建一个注解:用来标识需要重试的方法

@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface Retry {    /**     * 最多重试次数     */    int attempts() default 3;    /**     * 重试间隔     */    int interval() default 1;}

2)在需要重试的方法上加上注解:

//指定重试次数和间隔@Retry(attempts = 4, interval = 5)public String hello() {    long current = count.incrementAndGet();    System.out.println("第" + current +"次被调用");    if (current % 3 != 0) {        log.warn("调用失败");        return "error";    }    return "success";}

3)编写AOP切面类,引入依赖:

<dependency>    <groupId>org.aspectj</groupId>    <artifactId>aspectjweaver</artifactId></dependency>
/** * 重试切面类 */@Aspect@Component@Slf4jpublic class RetryAspect {    /**     * 定义切入点     */    @Pointcut("@annotation(cn.itcast.annotation.Retry)")    private void pt() {}    /**     * 定义重试的共性功能     */    @Around("pt()")    public Object retry(ProceedingJoinPoint joinPoint) throws InterruptedException {        //获取@Retry注解上指定的重试次数和重试间隔        MethodSignature sign = (MethodSignature) joinPoint.getSignature();        Retry retry = sign.getMethod().getAnnotation(Retry.class);        int maxRetry = retry.attempts(); //最多重试次数        int interval = retry.interval(); //重试间隔        Throwable ex = new RuntimeException();//记录重试失败异常        for (int i = 1; i <= maxRetry; i++) {            try {                Object result = joinPoint.proceed();                //第一种失败情况:远程调用成功返回,但结果是失败了                if (result.equals("error")) {                    throw new RuntimeException("远程调用返回失败");                }                return result;            } catch (Throwable throwable) {                //第二种失败情况,远程调用直接出现异常                ex = throwable;            }            //按照注解上指定的重试间隔执行下一次循环            Thread.sleep(interval * 1000);            log.warn("调用失败,开始第{}次重试", i);        }        throw new RuntimeException("重试次数耗尽", ex);    }}

4)编写单元测试

@Testpublic void testAOP() {    System.out.println(remoteService.hello());}

调用失败后:等待5毫秒后会进行重试,直到重试到达指定的上限或者调用成功

这样即不用编写重复代码,实现上也比较优雅了:一个注解就实现重试。

4、站在巨人肩上:Spring Retry

目前在Java开发领域,Spring框架基本已经是企业开发的事实标准。如果项目中已经引入了Spring,那咱们就可以直接使用Spring Retry,可以比较方便快速的实现重试功能,还不需要自己动手重新造轮子。

4.1 简单使用

下面咱们来一块来看看这个轮子究竟好不好使吧。

1)先引入重试所需的jar包

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

2)开启重试功能:在启动类或者配置类上添加@EnableRetry注解:

@SpringBootApplication@EnableRetrypublic class RemoteApplication {    public static void main(String[] args) {        SpringApplication.run(RemoteApplication.class);    }}

3)在需要重试的方法上添加@Retryable注解

/** * 每调用3次会成功一次 */@Retryable //默认重试三次,重试间隔为1秒public String hello() {    long current = count.incrementAndGet();    System.out.println("第" + current + "次被调用");    if (current % 3 != 0) {        log.warn("调用失败");        throw new RuntimeException("发生未知异常");    }    return "success";}

4)编写单元测试,验证效果

@Testpublic void testSpringRetry() {    System.out.println(remoteService.hello());}

通过日志可以看到:第一次调用失败后,经过两次重试,重试间隔为1s,最终调用成功

4.2 更灵活的重试设置

4.2.1 指定异常重试和次数

Spring的重试机制还支持很多很有用的特性:

  • 可以指定只对特定类型的异常进行重试,这样如果抛出的是其它类型的异常则不会进行重试,就可以对重试进行更细粒度的控制。
  • //@Retryable //默认为空,会对所有异常都重试 @Retryable(value = {MyRetryException.class}) //只有出现MyRetryException才重试 public String hello(){ //... }
  • 也可以使用include和exclude来指定包含或者排除哪些异常进行重试。
  • @Retryable(exclude = {NoRetryException.class}) //出现NoRetryException异常不重试
  • 可以用maxAttemps指定最大重试次数,默认为3次。
  • @Retryable(maxAttempts = 5)

4.2.2 指定重试回退策略

如果因为网络波动导致调用失败,立即重试可能还是会失败,最优选择是等待一小会儿再重试。决定等待多久之后再重试的方法叫做重试回退策略。通俗的说,就是每次重试是立即重试还是等待一段时间后重试。

默认情况下是立即重试,如果要指定策略则可以通过注解中backoff属性来快速实现:

  • 添加第二个重试方法,改为调用4次才成功一次。
  • 指定重试回退策略为:延迟5秒后进行第一次重试,后面重试间隔依次变为原来的2倍(10s, 15s)
  • 这种策略一般称为指数回退,Spring中也提供很多其他方式的策略(实现BackOffPolicy接口的都是)
/** * 每调用4次会成功一次 */@Retryable(        maxAttempts = 3, //指定重试次数        //调用失败后,等待5s重试,后面重试间隔依次变为原来的2倍        backoff = @Backoff(delay = 5000, multiplier = 2))public String hello2() {    long current = count.incrementAndGet();    System.out.println("第" + current + "次被调用");    if (current % 4 != 0) {        log.warn("调用失败");        throw new RuntimeException("发生未知异常");    }    return "success";}

编写单元测试验证:

@Testpublic void testSpringRetry2() {    System.out.println(remoteService.hello2());}

4.2.3 指定熔断机制

重试机制还支持使用@Recover 注解来进行善后工作:当重试达到指定次数之后,会调用指定的方法来进行日志记录等操作。

在重试方法的同一个类中编写熔断实现:

/** * 每调用4次会成功一次 */@Retryable(        maxAttempts = 3, //指定重试次数        //调用失败后,等待5s重试,后面重试间隔依次变为原来的2倍        backoff = @Backoff(delay = 5000, multiplier = 2))public String hello2() {    long current = count.incrementAndGet();    System.out.println("第" + current + "次被调用");    if (current % 4 != 0) {        log.warn("调用失败");        throw new RuntimeException("发生未知异常");    }    return "success";}/** * 熔断机制:达到最多重试次数后,会调用 recover() 方法 * @param ex 异常 */@Recoverpublic String recover(RuntimeException ex) {    log.info("execute recover...");    log.warn("重试到达上限", ex);    return "final error";}
注意:1、@Recover注解标记的方法必须和被@Retryable标记的方法在同一个类中2、重试方法抛出的异常类型需要与recover方法参数类型保持一致3、recover方法返回值需要与重试方法返回值保证一致4、recover方法中不能再抛出Exception,否则会报无法识别该异常的错误

总结

通过以上几个简单的配置,可以看到Spring Retry重试机制考虑的比较完善,比自己写AOP实现要强大很多。

4.3 弊端

Spring Retry虽然功能强大使用简单,但是也存在一些不足,Spring的重试机制只支持对异常进行捕获,而无法对返回值进行校验,具体看如下的方法:

1、方法执行失败,但没有抛出异常,只是在返回值中标识失败了(return error;)
/** * 每调用3次会成功一次 */@Retryablepublic String hello3() {    long current = count.incrementAndGet();    System.out.println("第" + current +"次被调用");    if (current % 3 != 0) {        log.warn("调用失败");        return "error";    }    return "success";}
2、因此就算在方法上添加@Retryable,也无法实现失败重试

编写单元测试:

@Testpublic void testSpringRetry3() {    System.out.println(remoteService.hello3());}

输出结果:只会调用一次,无论成功还是失败

5、另一个巨人谷歌 guava-retrying

5.1 Guava 介绍

Guava是一个基于Java的开源类库,其中包含谷歌在由他们很多项目使用的核心库。这个库目的是为了方便编码,并减少编码错误。这个库提供用于集合,缓存,并发性,常见注解,字符串处理,I/O和验证的实用方法。

源码地址:
https://github.com/google/guava

优势:

  • 标准化 - Guava库是由谷歌托管。
  • 高效 - 可靠,快速和有效的扩展JAVA标准库
  • 优化 -Guava库经过高度的优化。

当然,此处咱们主要来看下 guava-retrying 功能。

5.2 使用guava-retrying

guava-retrying是Google Guava库的一个扩展包,可以对任意方法的调用创建可配置的重试。该扩展包比较简单,也已经好多年没有维护,但这完全不影响它的使用,因为功能已经足够完善。

源码地址:
https://github.com/rholder/guava-retrying

和Spring Retry相比,Guava Retry具有更强的灵活性,并且能够根据返回值来判断是否需要重试。

1)添加依赖坐标

<!--guava retry是基于guava实现的,因此需要先添加guava坐标--><dependency>    <groupId>com.google.guava</groupId>    <artifactId>guava</artifactId>    <!--继承了SpringBoot后,父工程已经指定了版本-->	<!--<version>29.0-jre</version>--></dependency><dependency>    <groupId>com.github.rholder</groupId>    <artifactId>guava-retrying</artifactId>    <version>2.0.0</version></dependency>

2)编写远程调用方法,不指定任何Spring Retry中的注解

/** * 每调用3次会成功一次 */public String hello4() {    long current = count.incrementAndGet();    System.out.println("第" + current + "次被调用");    if (current % 3 != 0) {        log.warn("调用失败");        //throw new RuntimeException("发生未知异常");        return "error";    }    return "success";}

3)编写单元测试:创建Retryer实例,指定如下几个配置

  • 出现什么类型异常后进行重试:retryIfException()
  • 返回值是什么时进行重试:retryIfResult()
  • 重试间隔:withWaitStrategy()
  • 停止重试策略:withStopStrategy()
@Testpublic void testGuavaRetry() {    Retryer<String> retryer = RetryerBuilder.<String>newBuilder()            .retryIfException() //无论出现什么异常,都进行重试            //返回结果为 error时,进行重试            .retryIfResult(result -> Objects.equals(result, "error"))            //重试等待策略:等待5s后再进行重试            .withWaitStrategy(WaitStrategies.fixedWait(5, TimeUnit.SECONDS))            //重试停止策略:重试达到5次            .withStopStrategy(StopStrategies.stopAfterAttempt(5))            .build();}

4)调用方法,验证重试效果

try {    retryer.call(() -> {        String result = remoteService.hello4();        System.out.println(result);        return result;    });} catch (Exception e) {    System.out.println("exception:" + e);}
另外,也可以修改原始方法的失败返回实现:发现不管是抛出异常失败还是返回error失败,都能进行重试

另外,guava-retrying还有很多更灵活的配置和使用方式:

  1. 通过retryIfException 和 retryIfResult 来判断什么时候进行重试,同时支持多个且能兼容
  2. 设置重试监听器RetryListener,可以指定发生重试后,做一些日志记录或其他操作
  3. .withRetryListener(new RetryListener() { @Override public <V> void onRetry(Attempt<V> attempt) { System.out.println("RetryListener: 第" + attempt.getAttemptNumber() + "次调用"); } }) //也可以注册多个RetryListener,会按照注册顺序依次调用

5.3 弊端

虽然guava-retrying提供更灵活的使用,但是官方没有提供注解方式,频繁使用会有点麻烦。大家可以自己动手通过Spring AOP将实现封装为注解方式。

6、微服务架构中的重试(Feign+Ribbon)

在日常开发中,尤其是在微服务盛行的年代,我们在调用外部接口时,经常会因为第三方接口超时、限流等问题从而造成接口调用失败,那么此时我们通常会对接口进行重试,可以使用Spring Cloud中的Feign+Ribbon进行配置后快速的实现重试功能,经过简单配置即可:

spring:  cloud:   loadbalancer:      retry:        enabled: true #开启重试功能ribbon:  ConnectTimeout: 2000 #连接超时时间,ms  ReadTimeout: 5000 #等待请求响应的超时时间,ms  MaxAutoRetries: 1 #同一台服务器上的最大重试次数  MaxAutoRetriesNextServer: 2 #要重试的下一个服务器的最大数量  retryableStatusCodes: 500 #根据返回的状态码判断是否重试  #是否对所有请求进行失败重试  OkToRetryOnAllOperations: false #只对Get请求进行重试  #OkToRetryOnAllOperations: true #对所有请求进行重试
注意:对接口进行重试时,必须考虑具体请求方式和是否保证了幂等;如果接口没有保证幂等性(GET请求天然幂等),那么重试Post请求(新增操作),就有可能出现重复添加

7、总结

从手动重试,到使用Spring AOP自己动手实现,再到站在巨人肩上使用特别优秀的开源实现Spring Retry和Google guava-retrying,经过对各种重试实现方式的介绍,可以看到以上几种方式基本上已经满足大部分场景的需要:

  • 如果是基于Spring的项目,使用Spring Retry的注解方式已经可以解决大部分问题
  • 如果项目没有使用Spring相关框架,则适合使用Google guava-retrying:自成体系,使用起来更加灵活强大
  • 如果采用微服务架构开发,那直接使用Feign+Ribbon组件提供的重试即可