Java中常见的7种重试机制

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

1. 手动重试

手动重试:使用while语句进行重试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
public class OrderServiceImpl implements OrderService {

@Override
public void addOrder() {
//重试次数
int times = 1;
while (times <= 5){
try {
//故意抛异常, 模拟调用失败
int i = 3/ 0;
//-------省略业务代码
}catch (Exception exception){
System.out.println("重试:"+times+"次");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
times ++;
if (times > 5){
throw new RuntimeException("不再重试");
}
}
}
}
}

运行上述代码,查看日志:

1
2
3
4
5
6
7
8
9
重试:1次
重试:2次
重试:3次
重试:4次
重试:5次
2023-08-13 15:38:58.047 ERROR 12486 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 不再重试] with root cause

java.lang.RuntimeException: 不再重试
.....

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

  • 由于没有重试间隔,很可能远程调用的服务还没有从网络异常中恢复,所以有可能接下来的所有调用都会失败

  • 代码侵入式太高,调用方法代码不够优雅

  • 项目中远程调用的服务有可能会很多,每个方法都去添加重试会出现大量的重复代码

2. 静态代理

上面的处理方式由于需要对业务代码进行大量修改,虽然实现了功能,但是对原来的代码侵入性太强,可维护性差,所以需要使用一种更优雅的方式,不直接修改业务代码。

使用代理模式,在业务代码的外面再包一层就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
public class OrderServiceProxyImpl implements OrderService {

@Autowired
private OrderServiceImpl orderService;

@Override
public void addOrder() {
//重试次数
int times = 1;
while (times <= 5){
try {
//故意抛异常, 模拟调用失败
int i = 3/ 0;
//还是执行原来的业务代码,只是不需要在原来的地方去重试
orderService.addOrder();
}catch (Exception exception){
System.out.println("重试:"+times+"次");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
times ++;
if (times > 5){
throw new RuntimeException("不再重试");
}
}
}
}
}

这样,重试的逻辑就有代理类来完成,原来业务类的逻辑就不需要修改了。

代理模式虽然更加优雅,但是如果依赖的服务很多的适合,需要为每个服务都创建都创建一个代理类,显然过于麻烦,而且重试的逻辑都大同小异,无非就是重试次数和延时不一样而已,如果每个类都写这么一长串的类似代码,显然,也不优雅。

3. JDK 动态代理

这时候,动态代理就登场了,只需要写一个代理类就OK 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* @author xiaoyuge
*/
public class RetryInvocationHandler implements InvocationHandler {

private final Object subject;

public RetryInvocationHandler(Object subject) {
this.subject = subject;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//重试次数
int times = 1;
while (true) {
try {
if (times < 5) {
//故意抛异常, 模拟调用失败
int i = 3 / 0;
}
//通过代理去调用方法
return method.invoke(subject, args);
} catch (Exception exception) {
System.out.println("重试:" + times + "次");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
times++;
if (times > 5) {
throw new RuntimeException("不再重试");
}
}
}
}

public static Object getProxy(Object subject) {
InvocationHandler handler = new RetryInvocationHandler(subject);
return Proxy.newProxyInstance(handler.getClass().getClassLoader(), subject.getClass().getInterfaces(), handler);
}
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController("/retry")
public class RetryController {

@Qualifier("orderServiceImpl")
@Autowired
OrderService service;

@GetMapping("/test2")
public void test2() {
OrderService orderServiceProxy = (OrderService) RetryInvocationHandler.getProxy(service);
orderServiceProxy.addOrder();
}
}

动态代理可以将重试逻辑都放到一块,显然比直接使用代理类要方便很多,而且更加优雅。

这里使用的是JDK动态代理,因此就存在一个天然的缺陷,如果想要被代理的类,没有实现任何接口,那么就无法为其创建代理对象,这种方式就行不通了。

4. CGLib 动态代理

使用JDK动态代理对被代理类有要求,不是所有的类都能被代理,而CGLib动态代理刚好解决了这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class CGLibProxyHandler implements MethodInterceptor {

private Object target;

@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
int time = 1;
while (true) {
try {
if (time < 5) {
//前几次故意抛异常,模拟调用失败
int i = 3 / 0;
}
return method.invoke(target, objects);
} catch (Exception ex) {
System.out.println("重试:" + time + "次");
Thread.sleep(2000);
time++;
if (time > 5) {
throw new RuntimeException("不再重试");
}
}
}
}

public Object getCglibProxy(Object objectTarget) {
this.target = objectTarget;
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(objectTarget.getClass());
enhancer.setCallback(this);
return enhancer.create();
}
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController("/retry")
public class RetryController {

@Qualifier("orderServiceImpl")
@Autowired
OrderService service;

@GetMapping("/test3")
public void test3() {
CGLibProxyHandler cgLibProxyHandler = new CGLibProxyHandler();
OrderService orderServiceProxy = (OrderService) cgLibProxyHandler.getCglibProxy(service);
orderServiceProxy.addOrder();
}
}

运行后,查看日志:

1
2
3
4
5
重试:1次
重试:2次
重试:3次
重试:4次
--------添加订单-------

这样就完美解决了JDK动态代理带来的缺陷,但是这个方案仍存在一个问题,那就是需要对原来的逻辑进行侵入式修改,在每个被代理实例被调用的方法的地方都需要调整,这样仍然会对原来的代码带来较多的修改。

5. 手动 AOP

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

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

  1. 添加依赖

    1
    2
    3
    4
    <dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    </dependency>
  2. 自定义重试注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /**
    * @author xiaoyuge
    */
    @Documented
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface Retryable {
    //重试最大次数
    int retryTimes() default 3;

    //重试间隔
    int retryInterval() default 1;
    }
  3. 定义切面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    /**
    * @author xiaoyuge
    */
    @Slf4j
    @Aspect
    @Component
    public class RetryAspect {

    @Pointcut("@annotation(org.example.Retryable)")
    private void retryMethodCall() {
    }

    @Around("retryMethodCall()")
    public Object retry(ProceedingJoinPoint joinPoint) throws InterruptedException {
    final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Retryable retryable = signature.getMethod().getAnnotation(Retryable.class);
    int maxRetryTimes = retryable.retryTimes();
    int retryInterval = retryable.retryInterval();

    Throwable error = new RuntimeException();
    for (int retryTimes = 1; retryTimes <= maxRetryTimes; retryTimes++) {
    try {
    return joinPoint.proceed();
    } catch (Throwable throwable) {
    error = throwable;
    log.warn("调用发生异常,开始重试,retryTimes:{}", retryTimes);
    }
    Thread.sleep(2000);
    }
    throw new RuntimeException(" 重试次数耗尽", error);
    }
    }
  4. 使用注解进行重试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Service
    public class OrderServiceImpl implements OrderService {

    @Override
    @Retryable(retryInterval = 2, retryTimes = 5)
    public void addOrder() {
    //模拟调用失败,进行重试
    int i = 3 / 0;
    //添加订单的方法
    }
    }
  5. 测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @RestController("/retry")
    public class RetryController {

    @Qualifier("orderServiceImpl")
    @Autowired
    OrderService service;

    @GetMapping("/test")
    public void test() {
    service.addOrder();
    }
    }

    查看日志:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    2023-08-13 17:20:14.859  INFO 18892 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 4 ms
    2023-08-13 17:20:14.902 WARN 18892 --- [nio-8080-exec-1] org.example.RetryAspect : 调用发生异常,开始重试,retryTimes:1
    2023-08-13 17:20:16.908 WARN 18892 --- [nio-8080-exec-1] org.example.RetryAspect : 调用发生异常,开始重试,retryTimes:2
    2023-08-13 17:20:18.911 WARN 18892 --- [nio-8080-exec-1] org.example.RetryAspect : 调用发生异常,开始重试,retryTimes:3
    2023-08-13 17:20:20.914 WARN 18892 --- [nio-8080-exec-1] org.example.RetryAspect : 调用发生异常,开始重试,retryTimes:4
    2023-08-13 17:20:22.919 WARN 18892 --- [nio-8080-exec-1] org.example.RetryAspect : 调用发生异常,开始重试,retryTimes:5
    2023-08-13 17:20:24.933 ERROR 18892 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 重试次数耗尽] with root cause

    java.lang.ArithmeticException: / by zero
    .........

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

6. spring-retry

  1. 添加spring-retry依赖
    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
    </dependency>
  1. 在启动类或配置类上添加@EnableRetry注解

    1
    2
    3
    4
    5
    6
    7
    8
    @SpringBootApplication
    @EnableRetry
    public class RetryApplication {

    public static void main(String[] args) {
    SpringApplication.run(RetryApplication.class, args);
    }
    }
  2. 在需要重试的方法上添加@Retryable注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Service
    public class OrderServiceImpl implements OrderService {

    @Override
    //最大重试次数为3, 第一次重试间隔2s, 之后以2倍大小进行递增,第二次重试间隔 4s ,第三次 8s
    @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 2000, multiplier = 2))
    public void addOrder() {
    System.out.println("重试......");
    //模拟调用失败,进行重试
    int i = 3 / 0;
    //添加订单的方法
    }
    }
  3. 测试

    1
    2
    3
    4
    5
    6
    重试......
    重试......
    重试......
    2023-08-13 17:57:17.878 ERROR 20516 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.ArithmeticException: / by zero] with root cause

    java.lang.ArithmeticException: / by zero

Spring的重试机制还支持很多有用的特性,由三个注解完成:

  • @Retryable
  • @Backoff
  • @Recover

查看@Retryable注解源码:指定异常重试、次数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Retryable {
//设置重试拦截器的bean名称
String interceptor() default "";
//只对特定类型的异常进行重试。默认所有异常
Class<? extends Throwable>[] value() default {};
//包含或者排除哪些异常进行重试
Class<? extends Throwable>[] include() default {};
Class<? extends Throwable>[] exclude() default {};

//设置该重试的唯一标志,用户统计输出
String label() default "";

boolean stateful() default false;

//最大重试次数,设置为3次
int maxAttempts() default 3;

String maxAttemptsExpression() default "";

//设置重试补偿机制,可以设置重试间隔,并且支持设置重试延迟倍数
Backoff backoff() default @Backoff;

//异常表达式,在抛出异常后执行,以判断后续是否进行重试
String exceptionExpression() default "";

String[] listeners() default {};
}

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

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

注意:

  • @Recover 注解标记的方法必须和被@Retryable标记的方法在同一个类中
  • 重试方法抛出的异常要与recover方法的参数保持一致
  • recover()方法返回值需要与重试方法返回值保持一致
  • recover()方法中不能再抛出Exception,否则会报无法识别异常的错误

这里还需要再提醒的一点是,由于Spring Retry用到了 Aspect 增强,所以就会有使用 Aspect 不可避免的坑——方法内部调用,如果被 @Retryable 注解的方法的调用方和被调用方处于同一个类中,那么重试将会失效

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

弊端:
Spring的重试机制只支持对异常进行捕获,无法对返回值进行校验

1
2
3
4
5
6
7
8
9
10
@Retryable
public String hello() {
long current = count.incrementAndGet();
System.out.println("第" + current +"次被调用");
if (current % 3 != 0) {
log.warn("调用失败");
return "error";
}
return "success";
}

因此就算在方法上添加@Retryable, 也无法实现失败重试。

除了使用注解外,Spring Retry也支持直接在调用是使用代码进行重试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Test
public void normalSpringRetry() {
// 表示哪些异常需要重试,key表示异常的字节码,value为true表示需要重试
Map<Class<? extends Throwable>, Boolean> exceptionMap = new HashMap<>();
exceptionMap.put(HelloRetryException.class, true);

// 构建重试模板实例
RetryTemplate retryTemplate = new RetryTemplate();

// 设置重试回退操作策略,主要设置重试间隔时间
FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy();
long fixedPeriodTime = 1000L;
backOffPolicy.setBackOffPeriod(fixedPeriodTime);

// 设置重试策略,主要设置重试次数
int maxRetryTimes = 3;
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(maxRetryTimes, exceptionMap);

retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.setBackOffPolicy(backOffPolicy);

Boolean execute = retryTemplate.execute(
//RetryCallback
retryContext -> {
String hello = helloService.hello();
log.info("调用的结果:{}", hello);
return true;
},
// RecoverCallBack
retryContext -> {
//RecoveryCallback
log.info("已达到最大重试次数");
return false;
}
);
}

此时唯一的好处是可以设置多种重试策略:

  • NeverRetryPolicy:只允许调用RetryCallback一次,不允许重试
  • AlwaysRetryPolicy:允许无限重试,直到成功,此方式逻辑不当会导致死循环
  • SimpleRetryPolicy:固定次数重试策略,默认重试最大次数为3次,RetryTemplate默认使用的策略
  • TimeoutRetryPolicy:超时时间重试策略,默认超时时间为1秒,在指定的超时时间内允许重试
  • ExceptionClassifierRetryPolicy:设置不同异常的重试策略,类似组合重试策略,区别在于这里只区分不同异常的重试
  • CircuitBreakerRetryPolicy:有熔断功能的重试策略,需设置3个参数openTimeout、resetTimeout和delegate
  • CompositeRetryPolicy:组合重试策略,有两种组合方式,乐观组合重试策略是指只要有一个策略允许即可以重试,悲观组合重试策略是指只要有一个策略不允许即可以重试,但不管哪种组合方式,组合中的每一个策略都会执行

7. guava-retry

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

  1. 添加依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.github.rholder</groupId>
    <artifactId>guava-retrying</artifactId>
    <version>2.0.0</version>
    </dependency>
  2. 重试代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public String guavaRetry(Integer num) {
    Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
    //无论出现什么异常,都进行重试
    .retryIfException()
    //返回结果为 error时,进行重试
    .retryIfResult(result -> Objects.equals(result, "error"))
    //重试等待策略:等待 2s 后再进行重试
    .withWaitStrategy(WaitStrategies.fixedWait(2, TimeUnit.SECONDS))
    //重试停止策略:重试达到 3 次
    .withStopStrategy(StopStrategies.stopAfterAttempt(3))
    .withRetryListener(new RetryListener() {
    @Override
    public <V> void onRetry(Attempt<V> attempt) {
    System.out.println("RetryListener: 第" + attempt.getAttemptNumber() + "次调用");
    }
    })
    .build();
    try {
    //使用重试器执行你的业务
    retryer.call(() -> testGuavaRetry(num));
    } catch (Exception e) {
    e.printStackTrace();
    }
    return "test";
    }

先创建一个Retryer实例,然后使用这个实例对需要重试的方法进行调用,可以通过很多方法来设置重试机制:

  • retryIfException():对所有异常进行重试
  • retryIfRuntimeException():设置对指定异常进行重试
  • retryIfExceptionOfType():对所有 RuntimeException 进行重试
  • retryIfResult():对不符合预期的返回结果进行重试

还有五个以 withXxx 开头的方法,用来对重试策略/等待策略/阻塞策略/单次任务执行时间限制/自定义监听器进行设置,以实现更加强大的异常处理:

  • withRetryListener():设置重试监听器,用来执行额外的处理工作
  • withWaitStrategy():重试等待策略
  • withStopStrategy():停止重试策略
  • withAttemptTimeLimiter:设置任务单次执行的时间限制,如果超时则抛出异常
  • withBlockStrategy():设置任务阻塞策略,即可以设置当前重试完成,下次重试开始前的这段时间做什么事情

8. 总结

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

  • 如果是基于 Spring 的项目,使用 Spring Retry 的注解方式已经可以解决大部分问题
  • 如果项目没有使用 Spring 相关框架,则适合使用 Google guava-retrying:自成体系,使用起来更加灵活强大