原创 Ludovic
date 2020-02-25
1、说在前
在项目开发过程中,系统间服务集成调用这种事是家常便饭,如通过 Rest 服务接口、RPC 服务接口发起调用等。然而,服务提供方的稳定性和服务质量千差万别,即使是稳定服务,也可能因为网络抖动等不可控因素而产生短暂的不可用情况发生。在第三方服务不可用时,应该由我们系统来消化这种短时异常,而不应将错误直接反馈给当前系统的用户。消化这种短时异常的常见的方式,就是重试。
其实我们可能都写过如下的代码,来做重试:
public static void main(String[] args) {
Integer retryTimes = 0;
Integer result;
while (retry_times <= 3) { //重试未超过3次
result = call_service(); //调用外部服务,可能失败
if(result == 成功了) {
break; //成功推出
}
i++; //重试
}
//处理最终结果
}
其中 while
循环内的 call_service
是需要重试的内容,共重试三次;循环内的 if
判断是否调用成功,如果成功则跳出循环,重试机制大体如此。
可以看到,我们程序真正要执行的是 call_service
,其余都是为了保证服务调用成功而提供的辅助逻辑,如果对每一个服务调用接口都添加此类重试代码,必然造成代码冗余,这种情况下,通常的做法是通过 AOP,将和业务无关分辅助逻辑,横向编织到项目中。Spring Retry 框架就替我们解决了这个问题。
2、初识 Spring Retry
从名字可以看出,Spring Retry 是Spring提供的用于重试的脚手架
在 Spring-boot 中使用 Spring Retry 比较简单,只需要:
- 在应用配置类上添加
@EnableRetry
以打开Spring Retry的自动配置; - 在需要重试的方法上添加
@Retryable
注解,注解中添加重试的错误类型; - 在重试方法后添加
@Recover
,用于重试失败后,执行一些补偿操作。
示例代码如下:
@Configuration
@EnableRetry
public class Application {
@Bean
public Service service() {
return new Service();
}
}
@Service
class Service {
@Retryable(RemoteAccessException.class)
public void service() {
// ... do something
}
@Recover
public void recover(RemoteAccessException e) {
// ... panic
}
}
以上是最简单的 Spring Retry 使用方法:
调用 service()
方法时,如果抛出 RemoteAccessException
错误则进行重试;
当三次重试(默认三次)均失败后,回调 recover()
方法
用起来是不是很简单?当然 Spring Retry 还有其他更多的功能,将在下面继续讲解。
3、浅谈 Spring Retry
想要了解 Spring Retry 的所有功能,就要了解它的三大注解。
@EnableRetry
EnableRetry 写在项目入口处,用于开启重试功能,让我们看一下 EnableRetry 里面到底有什么~
proxyTargetClass()
默认为false,使用标准JAVA接口代理;当设置为true时,则使用CGLIB代理。
@EnableRetry(proxyTargetClass = true)
@Retryable
接下来,看一看 Retryable。
interceptor()
Spring Retry 核心思想之一便是 AOP 拦截器,该属性可以指定一个拦截器,如果没有指定将使用默认拦截器。(不常用)
@Retryable(interceptor = "testInterceptor")
PAY ATTENTION:既然是切面,在使用重试的时候就要注意,如果在某个类中编写了重试方法并进行了内部调用,重试将会失效。举个错误的例子:
public class test {
@Retryable(RuntimeException.class)
public void A() {
throw new RuntimeException("This is a wrong example!");
}
public void B() {
A();
}
}
value()
Spring Retry 是通过异常来判定是否需要重试,value() 值则是指定需要重试的异常类型。默认为空,当 exclude() 同为空时,所有异常都将重试。
需要留意的是,如果调用方法返回的是错误码而不是异常,Spring Retry 是不会进行重试操作的。这时则需要调用方自行判断返回结果再抛出异常。
@Retryable(value = {RuntimeException.class, AccessException.class})
include()
与 value() 具有完全相同的功能,可能是为了与 exclude() 保持一致才添加了这个属性。
当 value() 和 include() 添加不同异常类型时,则是取两者的并集。
@Retryable(include = {Exception.class})
exclude()
不需要重试的异常类型,默认为空,当 include() 为空,exclude() 不为空时,其他所有异常均会重试。(不常用)
@Retryable(exclude = {RuntimeException.class})
label()
标注重试的名字,需要保证全局唯一。(不常用)
@Retryable(label = "test_retry")
stateful()
标记重试是否有状态,其使用场景主要有二:
其一,某些消息中间件支持push的消息投递方式,如果消费者接收消息后处理失败,消息中间件会再次发送该消息,stateful的重试会识别出这是一条相同的消息,并计入重试次数当中,当重试次数耗尽便会调用 recover 方法。
其二,如果重试方法被Spring的事务Transation包裹(方法会开启事务),stateful的重试会保障每次重试以及最后的 recover 都是一个新的事务。
@Retryable(stateful = true)
maxAttempts()
最大重试次数,其中第一次失败也算其中一次,默认为3次。重试次数建议不要设置太大,否则会堵塞资源,对程序运作造成影响。
@Retryable(maxAttempts = 5)
backoff()
回退策略,通俗易懂地讲即是在请求失败后多长时间进行下一次重试请求,其中有多种用法:
- 无任何参数,重试默认等待1000ms:
@Retryable(backoff = @Backoff())
- 添加 delay() 参数,重试等待设定的毫秒数:
@Retryable(backoff = @Backoff(delay = 50))
- 添加 delay() 和 maxDelay() 参数,重试等待两者之间的毫秒数,选取时间概率为均匀分布:
@Retryable(backoff = @Backoff(delay = 50, maxDelay = 2000))
- 添加 delay(), maxDelay(), multiplier() 参数,重试等待 delay() * multiplier()^i 和 maxDelay() * multiplier()^i 之间的毫秒数,选取时间概率依旧是均匀分布:eg,delay=1000 multiplier=2,第一次重试为1秒后,第二次为2秒,第三次为4秒。
@Retryable(backoff = @Backoff(delay = 50, maxDelay = 2000, multiplier = 5))
- 添加 delay(), maxDelay(), multiplier() 和 random() 参数,当 random() 为 true 时,乘积系数将从 [1, multiplier() -1] 中选取并记为rMultiplier,重试等待 delay() * rMultiplier^i 和 maxDelay() * rMultiplier^i 之间的毫秒数:(不常用)
@Retryable(backoff = @Backoff(delay = 50, maxDelay = 2000, multiplier = 5, random = true))
- 除此之外,@backoff 还贴心地定义了 delayExpression(), maxDelayExpression(), multiplierExpression() 属性,它们存在的意义在于不把数值写死,而可以选择放在配置文件中,便于项目更新。(不常用)
@Retryable(backoff = @Backoff(delayExpression = "${retry.backoff.delay}", maxDelayExpression = "${retry.backoff.maxDelay}", multiplierExpression = "${retry.backoff.multiplier}"))
maxAttemptsExpression() & exceptionExpression()
如同 @backoff 中的 delayExpression(),我们也可以将 maxAttempts 和 exception 写入配置文件,方便更新和管理。(不常用)
@Recover
如果重试多次之后依旧失败怎么办?使用 Recover 可以回调被注解的方法,其中有几点需要注意:
- 被注解方法和重试方法在同一个类中;
- 被注解方法入参中要包含重试方法抛出的异常,否则无法回调该方法;
- 被注解方法出参类型要和重试方法出参类型保持一致。
@Service
@Slf4j
public class RetryService {
public Integer i = 0;
@Retryable(value = Exception.class, maxAttempts = 4)
public String retryMethod(String name) throws Exception {
i++;
if (i <= 5) {
log.info("This is the {} time to retry", i);
throw new RuntimeException();
}
if (i <= 10) {
log.info("This is the {} time to retry", i);
throw new IOException();
}
log.info(name);
return name;
}
// 可以存在多个 recover,不同的异常类型将回调不同的方法
@Recover
public String recover(RuntimeException e, String name) {
log.info("RuntimeException final chance:{} ", name);
return "RuntimeException final chance: " + name;
}
@Recover
public String recover(IOException e, String name) {
log.info("IOException final chance:{}", name);
return "RuntimeException final chance: " + name;
}
返回结果如下:
2019-12-18 14:54:01.837 INFO 6259 --- [nio-8080-exec-1] c.ludovic.exercise.service.RetryService : This is the 1 time to retry
2019-12-18 14:54:02.843 INFO 6259 --- [nio-8080-exec-1] c.ludovic.exercise.service.RetryService : This is the 2 time to retry
2019-12-18 14:54:03.848 INFO 6259 --- [nio-8080-exec-1] c.ludovic.exercise.service.RetryService : This is the 3 time to retry
2019-12-18 14:54:04.851 INFO 6259 --- [nio-8080-exec-1] c.ludovic.exercise.service.RetryService : This is the 4 time to retry
2019-12-18 14:54:04.852 INFO 6259 --- [nio-8080-exec-1] c.ludovic.exercise.service.RetryService : RuntimeException final chance:Ludovic
2019-12-18 14:54:05.916 INFO 6259 --- [nio-8080-exec-2] c.ludovic.exercise.service.RetryService : This is the 5 time to retry
2019-12-18 14:54:06.921 INFO 6259 --- [nio-8080-exec-2] c.ludovic.exercise.service.RetryService : This is the 6 time to retry
2019-12-18 14:54:07.923 INFO 6259 --- [nio-8080-exec-2] c.ludovic.exercise.service.RetryService : This is the 7 time to retry
2019-12-18 14:54:08.929 INFO 6259 --- [nio-8080-exec-2] c.ludovic.exercise.service.RetryService : This is the 8 time to retry
2019-12-18 14:54:08.929 INFO 6259 --- [nio-8080-exec-2] c.ludovic.exercise.service.RetryService : IOException final chance:Ludovic
2019-12-18 14:54:11.943 INFO 6259 --- [nio-8080-exec-3] c.ludovic.exercise.service.RetryService : This is the 9 time to retry
2019-12-18 14:54:12.948 INFO 6259 --- [nio-8080-exec-3] c.ludovic.exercise.service.RetryService : This is the 10 time to retry
2019-12-18 14:54:13.953 INFO 6259 --- [nio-8080-exec-3] c.ludovic.exercise.service.RetryService : Ludovic
4、再探 Spring Retry
以上简单介绍了 Spring Retry 的用法,下面我们会稍微了解一下它是如何设计和运作的~
一切还需从项目启动时的注解 @EnableRetry 讲起…
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@EnableAspectJAutoProxy(proxyTargetClass = false)
@Import(RetryConfiguration.class)
@Documented
public @interface EnableRetry {
boolean proxyTargetClass() default false;
}
这里 @EnableAspectJAutoProxy 是开启AOP,紧接着 @Import(RetryConfiguration.class) 便开始创建拦截器,我们进入 RetryConfiguration 类走马观花式瞧一瞧(节选):
@SuppressWarnings("serial")
@Configuration
public class RetryConfiguration extends AbstractPointcutAdvisor implements IntroductionAdvisor, BeanFactoryAware {
private Advice advice;
private Pointcut pointcut;
@PostConstruct
public void init() {
Set> retryableAnnotationTypes = new LinkedHashSet>(1);
retryableAnnotationTypes.add(Retryable.class);
this.pointcut = buildPointcut(retryableAnnotationTypes);
this.advice = buildAdvice();
if (this.advice instanceof BeanFactoryAware) {
((BeanFactoryAware) this.advice).setBeanFactory(beanFactory);
}
}
protected Advice buildAdvice() {
AnnotationAwareRetryOperationsInterceptor interceptor = new AnnotationAwareRetryOperationsInterceptor();
if (retryContextCache != null) {
interceptor.setRetryContextCache(retryContextCache);
}
if (retryListeners != null) {
interceptor.setListeners(retryListeners);
}
if (methodArgumentsKeyGenerator != null) {
interceptor.setKeyGenerator(methodArgumentsKeyGenerator);
}
if (newMethodArgumentsIdentifier != null) {
interceptor.setNewItemIdentifier(newMethodArgumentsIdentifier);
}
if (sleeper != null) {
interceptor.setSleeper(sleeper);
}
return interceptor;
}
}
可见在这里初始化了切入点(Pointcut)和增强(Advice),其中 Advice 将配置的各种重试属性加到拦截器当中,例如回退策略和重试策略等,而主要的回退和重试策略如下:
- BackOffPolicy
在 BackOff 包中含有多种回退策略,@BackOff 属性不同会有不同的回退策略,例如:
NoBackOffPolicy(),最朴实无华的回退策略,要啥没啥;
FixedBackOffPolicy(),固定时间回退策略,对应上文的 delay();
ExponentialBackOffPolicy(),指数递增策略,对应上文的 multiplier();
ExponentialRandomBackOffPolicy(),指数随机递增回退策略,对应上文的 random() 等等。 - RetryPolicy
同样在 RetryPolicy 也包含多种重试策略,例如:
SimpleRetryPolicy(),默认重试三次的简单重试策略;
NeverRetryPolicy(),从不重试的重试策略;
ExpressionRetryPolicy(),表达式重试策略,对应 maxAttemptsExpression() 等等。
以上的以上,均属于初始化过程,而拦截器中具体的重试操作是通过 RetryOperations 定义的,它重载多个 execute() 方法,前两个是无状态重试,而后两个是有状态重试。传入的两个入参一个是重试回调,另外一个是兜底 recover 回调。
public interface RetryOperations {
T execute(RetryCallback retryCallback) throws E;
T execute(RetryCallback retryCallback, RecoveryCallback recoveryCallback) throws E;
T execute(RetryCallback retryCallback, RetryState retryState) throws E, ExhaustedRetryException;
T execute(RetryCallback retryCallback, RecoveryCallback recoveryCallback, RetryState retryState)
throws E;
}
紧接着 RetryTemplate 实现 RetryOperations 的方法,它也是 Spring Retry 最最核心的部分,代码如下(节选):
public class RetryTemplate implements RetryOperations {
/**
以下对应 RetryOperations 的实现方法
*/
@Override
public final T execute(RetryCallback retryCallback)
throws E {
return doExecute(retryCallback, null, null);
}
@Override
public final T execute(RetryCallback retryCallback,
RecoveryCallback recoveryCallback) throws E {
return doExecute(retryCallback, recoveryCallback, null);
}
@Override
public final T execute(RetryCallback retryCallback,
RetryState retryState) throws E, ExhaustedRetryException {
return doExecute(retryCallback, null, retryState);
}
@Override
public final T execute(RetryCallback retryCallback,
RecoveryCallback recoveryCallback, RetryState retryState)
throws E, ExhaustedRetryException {
return doExecute(retryCallback, recoveryCallback, retryState);
}
protected T doExecute(RetryCallback retryCallback,
RecoveryCallback recoveryCallback, RetryState state)
throws E, ExhaustedRetryException {
RetryPolicy retryPolicy = this.retryPolicy;
BackOffPolicy backOffPolicy = this.backOffPolicy;
RetryContext context = open(retryPolicy, state);
RetrySynchronizationManager.register(context);
Throwable lastException = null;
boolean exhausted = false;
try {
boolean running = doOpenInterceptors(retryCallback, context);
if (!running) {
throw new TerminatedRetryException(
"Retry terminated abnormally by interceptor before first attempt");
}
BackOffContext backOffContext = null;
Object resource = context.getAttribute("backOffContext");
if (resource instanceof BackOffContext) {
backOffContext = (BackOffContext) resource;
}
if (backOffContext == null) {
backOffContext = backOffPolicy.start(context);
if (backOffContext != null) {
context.setAttribute("backOffContext", backOffContext);
}
}
while (canRetry(retryPolicy, context) && !context.isExhaustedOnly()) {
try {
lastException = null;
return retryCallback.doWithRetry(context);
}
catch (Throwable e) {
lastException = e;
try {
registerThrowable(retryPolicy, state, context, e);
}
catch (Exception ex) {
throw new TerminatedRetryException("Could not register throwable", ex);
}
finally {
doOnErrorInterceptors(retryCallback, context, e);
}
if (canRetry(retryPolicy, context) && !context.isExhaustedOnly()) {
try {
backOffPolicy.backOff(backOffContext);
}
catch (BackOffInterruptedException ex) {
lastException = e;
throw ex;
}
}
}
if (shouldRethrow(retryPolicy, context, state)) {
throw RetryTemplate.wrapIfNecessary(e);
}
if (state != null && context.hasAttribute(GLOBAL_STATE)) {
break;
}
}
exhausted = true;
return handleRetryExhausted(recoveryCallback, context, state);
}
catch (Throwable e) {
throw RetryTemplate.wrapIfNecessary(e);
}
finally {
close(retryPolicy, context, state, lastException == null || exhausted);
doCloseInterceptors(retryCallback, context, lastException);
RetrySynchronizationManager.clear();
}
}
可以看出来,Spring Retry 重试过程也是通过 while 循环 来实现的,只不过添加了更多的判断条件 canRetry(retryPolicy, context) && !context.isExhaustedOnly()
和熔断方法 backOffPolicy.backOff(backOffContext)
。下图可以更直观地描述其流程:
在打开拦截器监听器之后,重试策略会通过上下文判断是否进行重试,如果需要重试则会运行需要重试部分的代码,一旦出现相匹配的异常就会被监听器捕获,执行回退策略,再次回到循环起点;如果重试策略判断不再重试,则会运行相对应的 Recover 方法。在以上全部完成之后,最后关闭监听器,流程结束。
如果想学习更多,可参见官方链接:https://github.com/spring-projects/spring-retry
5、写在后
使用重试方法需要强调的是保证幂等性!也就是说对于类似新增、更新数据库的非幂等请求,需要谨慎处理!
Java 重试机制不仅仅有 Spring Retry, Guava Retrying 也是一个不错的选择。相比于使用简单便利但略微单一的 Spring Retry, Guava Retrying 更加灵活,其中的 RetryIf 能够适用于更多的重试条件。重试方法各有千秋,在学习了解各个重试方法实现方式与优缺点之后,可以稍微尝试写一写自己独有的重试方法,或许下一个重试大神就是你!
参考文档
Spring-Retry重试实现原理
Spring-retry 重试机制
重试框架Spring retry实践