重试机制,可以提高系统的健壮性,并且减少因网络波动依赖服务临时不可用带来的影响,让系统更加稳定的运行。
1. 手动重试
手动重试:使用while语句进行重试
1 |
|
运行上述代码,查看日志:
1 | 重试:1次 |
上述代码看上去可以解决重试问题,但实际上存在一些弊端:
由于没有重试间隔,很可能远程调用的服务还没有从网络异常中恢复,所以有可能接下来的所有调用都会失败
代码侵入式太高,调用方法代码不够优雅
项目中远程调用的服务有可能会很多,每个方法都去添加重试会出现大量的重复代码
2. 静态代理
上面的处理方式由于需要对业务代码进行大量修改,虽然实现了功能,但是对原来的代码侵入性太强,可维护性差,所以需要使用一种更优雅的方式,不直接修改业务代码。
使用代理模式,在业务代码的外面再包一层就行了。
1 |
|
这样,重试的逻辑就有代理类来完成,原来业务类的逻辑就不需要修改了。
代理模式虽然更加优雅,但是如果依赖的服务很多的适合,需要为每个服务都创建都创建一个代理类,显然过于麻烦,而且重试的逻辑都大同小异,无非就是重试次数和延时不一样而已,如果每个类都写这么一长串的类似代码,显然,也不优雅。
3. JDK 动态代理
这时候,动态代理就登场了,只需要写一个代理类就OK 了。
1 | /** |
测试:
1 |
|
动态代理可以将重试逻辑都放到一块,显然比直接使用代理类要方便很多,而且更加优雅。
这里使用的是JDK动态代理,因此就存在一个天然的缺陷,如果想要被代理的类,没有实现任何接口,那么就无法为其创建代理对象,这种方式就行不通了。
4. CGLib 动态代理
使用JDK动态代理对被代理类有要求,不是所有的类都能被代理,而CGLib动态代理刚好解决了这个问题。
1 | public class CGLibProxyHandler implements MethodInterceptor { |
测试:
1 |
|
运行后,查看日志:
1 | 重试:1次 |
这样就完美解决了JDK动态代理带来的缺陷,但是这个方案仍存在一个问题,那就是需要对原来的逻辑进行侵入式修改,在每个被代理实例被调用的方法的地方都需要调整,这样仍然会对原来的代码带来较多的修改。
5. 手动 AOP
考虑到以后可能会有很多的方法也需要重试功能,可以讲重试这个共性功能通过AOP来实现;
使用AOP来为每次调用设置切面,即可在目标方法调用前添加一些重试的逻辑。
添加依赖
1
2
3
4<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>自定义重试注解
1
2
3
4
5
6
7
8
9
10
11
12
13/**
* @author xiaoyuge
*/
public Retryable {
//重试最大次数
int retryTimes() default 3;
//重试间隔
int retryInterval() default 1;
}定义切面
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
*/
public class RetryAspect {
private void 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);
}
}使用注解进行重试
1
2
3
4
5
6
7
8
9
10
11
public class OrderServiceImpl implements OrderService {
public void addOrder() {
//模拟调用失败,进行重试
int i = 3 / 0;
//添加订单的方法
}
}测试
1
2
3
4
5
6
7
8
9
10
11
12
public class RetryController {
OrderService service;
public void test() {
service.addOrder();
}
}查看日志:
1
2
3
4
5
6
7
8
9
102023-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
- 添加
spring-retry
依赖1
2
3
4<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
在启动类或配置类上添加
@EnableRetry
注解1
2
3
4
5
6
7
8
public class RetryApplication {
public static void main(String[] args) {
SpringApplication.run(RetryApplication.class, args);
}
}在需要重试的方法上添加
@Retryable
注解1
2
3
4
5
6
7
8
9
10
11
12
13
public class OrderServiceImpl implements OrderService {
//最大重试次数为3, 第一次重试间隔2s, 之后以2倍大小进行递增,第二次重试间隔 4s ,第三次 8s
public void addOrder() {
System.out.println("重试......");
//模拟调用失败,进行重试
int i = 3 / 0;
//添加订单的方法
}
}测试
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 |
|
@Backoff
注解:指定重试会退策略(如果因为网络波动导致调用失败,立即重试可能还是会失败,最优选择是等待一小会再重试,决定等待多久之后在重试的方法。通俗的说,就是每次重试是立即重试还是等待一段时间后重试)
@Recover
注解:进行善后工作,当重试达到指定次数之后,会调用指定的方法来进行日志记录等操作。
注意:
@Recover
注解标记的方法必须和被@Retryable
标记的方法在同一个类中
- 重试方法抛出的异常要与
recover
方法的参数保持一致
recover()
方法返回值需要与重试方法返回值保持一致
recover()
方法中不能再抛出Exception,否则会报无法识别异常的错误
这里还需要再提醒的一点是,由于Spring Retry
用到了 Aspect
增强,所以就会有使用 Aspect 不可避免的坑——方法内部调用,如果被 @Retryable 注解的方法的调用方和被调用方处于同一个类中,那么重试将会失效
通过以上几个简单的配置,可以看到 Spring Retry
重试机制考虑的比较完善,比自己写AOP实现要强大很多
弊端:
Spring的重试机制只支持对异常进行捕获,无法对返回值进行校验
1 |
|
因此就算在方法上添加@Retryable
, 也无法实现失败重试。
除了使用注解外,Spring Retry也支持直接在调用是使用代码进行重试:
1 |
|
此时唯一的好处是可以设置多种重试策略:
NeverRetryPolicy
:只允许调用RetryCallback一次,不允许重试
AlwaysRetryPolicy
:允许无限重试,直到成功,此方式逻辑不当会导致死循环
SimpleRetryPolicy
:固定次数重试策略,默认重试最大次数为3次,RetryTemplate默认使用的策略
TimeoutRetryPolicy
:超时时间重试策略,默认超时时间为1秒,在指定的超时时间内允许重试
ExceptionClassifierRetryPolicy
:设置不同异常的重试策略,类似组合重试策略,区别在于这里只区分不同异常的重试
CircuitBreakerRetryPolicy
:有熔断功能的重试策略,需设置3个参数openTimeout、resetTimeout和delegate
CompositeRetryPolicy
:组合重试策略,有两种组合方式,乐观组合重试策略是指只要有一个策略允许即可以重试,悲观组合重试策略是指只要有一个策略不允许即可以重试,但不管哪种组合方式,组合中的每一个策略都会执行
7. guava-retry
与Spring Retry相比, Guava Retry具有更强的灵活性,并且能够根据返回值来判断是否需要重试
添加依赖
1
2
3
4
5<dependency>
<groupId>com.github.rholder</groupId>
<artifactId>guava-retrying</artifactId>
<version>2.0.0</version>
</dependency>重试代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public 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() {
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:自成体系,使用起来更加灵活强大