本文内容是目前团队内小磊同学对重试机制实现方案的梳理总结。
从为什么需要重试的背景开始,到重试的场景,大致的一些设计思路,最后通过两个成熟的retry组件进行案例讲解,理论+实战。
背景
重试是系统提高容错能力的一种手段。在一次请求中,往往需要经过多个服务之间的调用,由于网络波动或者其他原因,请求可能无法正常到达服务端或者服务端的请求无法正常的返回,从而导致请求失败,这种失败往往可以通过重试的方式来解决。因此服务之间的重试机制提高了整个系统的故障恢复能力。
重试场景
典型的重试场景如下:
网络抖动问题造成的请求失败,通过重试提高成功率。
由于系统负载高原因导致请求变慢,导致请求超时,通过重试提高成功率。
由于系统故障或服务不可用导致请求没能成功,通过重试保证数据落地。
那么是不是所有请求都可以重试?
显然不是,重试依赖于接口的幂等性,假设一个接口多次使用相同参数调用会导致其违反数据约束,那么得到的结果可能会在我们预期之外。对于幂等不了解的伙伴,可以参看:高并发下接口幂等性解决方案
设计思路
如何设计一个优雅的重试机制?优雅的重试机制应该具备如下几点特点:
无侵入:或者侵入低,这个好理解,不改动当前的业务逻辑,对于需要重试的地方,可以很简单的实现
可配置:包括重试次数,重试的间隔时间,是否使用异步方式等
通用性:最好是无改动(或者很小改动)的支持绝大部分的场景,拿过来直接可用
模板方式
将重试机制的实现抽取成一个模板,预留接口。示例如下:
public abstract class MyRetryTemplate<T> {
private int retryTime;
private int sleepTime;
private boolean multiple = false;
public abstract T doBiz() throws Exception;
public T execute() throws InterruptedException {
for (int i = 1; i < retryTime + 1; i++) {
try {
return doBiz();
} catch (Exception e) {
System.out.println(e.getMessage());
if (multiple){
Thread.sleep(sleepTime);
}
else{
Thread.sleep(sleepTime * (i));
}
}
}
return null;
}
public T submit(ExecutorService executorService) {
Future submit = executorService.submit((Callable) () -> execute());
try {
return (T) submit.get();
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
return null;
}
public MyRetryTemplate setRetryTime(int retryTime) {
this.retryTime = retryTime;
return this;
}
public MyRetryTemplate setSleepTime(int sleepTime) {
this.sleepTime = sleepTime;
return this;
}
public MyRetryTemplate setMultiple(boolean multiple) {
this.multiple = multiple;
return this;
}
}
业务代码中的使用demo:
public void retryDemo() throws InterruptedException {
Object ans = new MyRetryTemplate() {
@Override
protected Object doBiz() throws Exception {
int n = (int) (Math.random() * 10);
System.out.println(n);
if (n > 3) {
throw new Exception("generate value bigger then 3! need retry");
}
return n;
}
}.setRetryTime(10).setSleepTime(10).execute();
System.out.println(ans);
}
切面方式
自定义注解,在需要重试的方法上标注。然后在切面中实现重试的逻辑,重试配置参数可以写在注解参数中。在模板方式的基础上,通过spring的aop实现该方式如下:
自定义注解:
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
int retryTime() default 0;
int sleepTime() default 0;
boolean asyn() default false;
boolean multiple() default false;
}
切面逻辑:
@Aspect
@Component
public class RetryAspect {
ExecutorService executorService = new ThreadPoolExecutor(3, 5,
1, TimeUnit.MINUTES,
new LinkedBlockingQueue<Runnable>());
@Around(value = "@annotation(retry)")
public Object execute(ProceedingJoinPoint joinPoint, Retry retry) throws Exception {
RetryTemplate retryTemplate = new RetryTemplate() {
@Override
protected Object doBiz() throws Throwable {
return joinPoint.proceed();
}
}.setRetryCount(retry.count()).setSleepTime(retry.sleepTime());
if (retry.asyn()) {
return retryTemplate.submit(executorService);
} else {
return retryTemplate.execute();
}
}
}
注解使用示例:
@Retry(retryTime=4, sleepTime=10000, multiple=true)
public void retryDemo() throws InterruptedException {
int n = (int) (Math.random() * 10);
System.out.println(n);
if (n > 3) {
throw new Exception("generate value bigger then 3! need retry");
}
}
定时任务
通过定时任务定时执行某种需要重试的业务。以订单支付为例,定时扫描数据库中一定时间范围内的所有锁定状态订单,然后查询支付中心这笔订单的状态,此例中重试间隔时间就是定时任务的间隔时间,重试停止条件就是查不到对应的订单。
伪代码:
@Xxljob(cron = "50 */3 * * * ?")
public void getOrderStatus(){
String startTime = DateUtils.addMinut(new Date, -30, "yyyy-MM-dd hh:mm:ss");
List<Order> orderList = orderMapper.getOrderList(startTime, "L");
for (Order order : orderList) {
Order order = orderCenter.getOrderStatus(order);
if ("S".equals(order.getStatus())) {
orderMapper.update();
}
}
}
消息队列重试
参考:https://blog.csdn.net/qq_37513473/article/details/102591717
可以通过消息队列中的延时队列和死信交换机实现重试队列,这里通过rabbitmq实现。在介绍重试队列实现之前,先了解一下延迟队列与死信队列
死信交换机
队列中的消息可能会变成死信消息(dead-lettered),进而当以下几个事件任意一个发生时,消息将会被重新发送到一个交换机:
死信消息将被队列的死信交换机路由到其他队列
延迟队列
延迟队列可以解决很多特定场景下,带时间属性的任务需求。延迟队列一般应用于需要延迟工作的场景,比如:
订单下单后未支付需要延迟一定时间释放。
预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议
账单在一周内未支付,则自动结算
用户注册成功后,如果三天内没有登陆则进行短信提醒
这些场景都有一个特点,就是需要在某个事件发生之后或者之前的指定时间点完成某一项任务。队列内的消息有序,且会在指定的时间后被取出消费。
在rabbitmq中,延迟队列的实现依赖它的特性——TTL(time to live),TTL是RabbitMQ中一个消息或者队列的属性,表明一条消息或者该队列中的所有消息的最大存活时间,单位是毫秒。换句话说,如果一条消息设置了TTL属性或者进入了设置TTL属性的队列,那么这条消息如果在TTL设置的时间内没有被消费,则会成为“死信”。如果同时配置了队列的TTL和消息的TTL,那么较小的那个值将会被使用。
延迟队列的实现
rabbitmq本身没有延迟队列,我们可以通过设置队列和消息的TTL属性与死信交换机实现一个延迟队列。将消息设置TTL后,不让消费者进行消费,消息过期后会自动通过死信交换机路由到其他队列,让消费者消费死信交换机路由后的队列中的消息,这样就实现了一个延迟队列。
重试实现
在了解了延迟队列的实现方式后,重试实现起来也相差不大。将需要重试的业务发送一条消息到队列A中,消费者进行消费,如果处理失败,则再发送一条消息到一个延迟队列B,队列B中的消息过期后通过交换机路由重新回到队列A中,这就实现了重试。这种方法中,重试方法的等待时间就是消息的过期时间,而重试最大等待时间为延迟队列的TTL,借助redis等类似的组件缓存重试次数,达到限制方法重试次数的方法。
成熟的重试组件
spring-retry
该项目为Spring应用程序提供声明式重试支持。它用于Spring批处理、Spring集成等。命令式重试也支持显式使用。它主要是针对可能抛出异常的一些调用操作,进行有策略的重试
依赖引入
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>1.2.5-RELEASE</version>
</dependency>
重试示例
启动类上开启重试
@SpringBootApplication
@EnableAspectJAutoProxy
@EnableRetry
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
在需要重试的方法上标注@Retryable注解,注解中参数含义:
value:当方法抛出此类异常时进行重试。示例中配置的是RuntimeException,也就是在发生运行时异常时进行重试
maxAttempts:最大执行次数。示例中配置的是3,当方法发生RuntimeException异常后最多重试2次
backoff:重试等待策略。本示例配置含义为重试等待时间为5s,每次等待时间翻两倍
@Service
public class RemoteService {
@Retryable(value = RuntimeException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 5000L, multiplier = 2))
public void call() {
System.out.println("Call something...");
throw new RuntimeException("RPC调用异常");
}
@Recover
public void recover(RuntimeException e) {
System.out.println("Call recover...");
}
}
如果达到最大重试次数还没请求成功,这种情况可使用@Recover进行熔断补偿,该方法会在全部重试失败调用
缺陷
spring-retry 存在两个不友好设计:
重试实体限定为 Throwable 子类,说明重试针对的是可捕捉的功能异常为设计前提的,但是我们希望依赖某个数据对象实体作为重试实体,但 sping-retry框架必须强制转换为Throwable子类。
如果你要以返回值的某个状态来判定是否需要重试,可能只能通过自己判断返回值然后显式抛出异常了
guava-retrying
guava retrying模块提供了一种通用方法,用于重试具有特定停止、重试和异常处理功能的任意Java代码,这些功能通过guava的谓词匹配得到了增强。
依赖引入
<!-- https:
<dependency>
<groupId>com.github.rholder</groupId>
<artifactId>guava-retrying</artifactId>
<version>2.0.0</version>
</dependency>
入门案例
public class RetryDemoTask {
public static boolean retryTask(String param) {
int i = RandomUtils.nextInt(0,11);
if (i < 2) {
throw new IllegalArgumentException("参数异常");
}else if (i < 5){
return true;
}else if (i < 7){
return false;
}else{
throw new RemoteAccessException("大于2,抛出自定义异常");
}
}
}
public void fun01(){
Retryer<Boolean> retryer = RetryerBuilder.<Boolean> newBuilder()
.retryIfExceptionOfType(RemoteAccessException.class)
.retryIfResult(res-> res==false)
.withWaitStrategy(WaitStrategies.fixedWait(3, TimeUnit.SECONDS))
.withStopStrategy(StopStrategies.stopAfterAttempt(3))
.build();
try {
retryer.call(() -> RetryDemoTask.retryTask("abc"));
} catch (Exception e) {
e.printStackTrace();
}
}
关键设置说明:retryIfExceptionOfType:发生指定异常时重试 retryIfResult:设置一个断言表达式,例子中结果为false重试 withWaitStrategy:重试等待策略,可设置的值如下:
WaitStrategy.fixedWait():每次等待间隔固定
WaitStrategy.randomWait():每次等待间隔时间为设置的最小时间~最大时间之间的随机时间
WaitStrategy.incrementingWait():每次等待时间递增
WaitStrategy.exponentialWait():每次等待时间倍数增长,不超过设置的最大时间
WaitStrategy.fibonacciWait():每次等待时间呈菲波那契数列增长
WaitStrategy.exceptionWait():发生指定异常时等待自定义时间
withStopStrategy:重试停止策略,可设置的值如下:
withAttemptTimeLimiter:重试限制器,默认两种提供两种实现:
withBlockStrategy:阻塞策略。阻塞策略配置每次重试之前如何阻塞流程,默认是线程休眠,guava-retrying只提供了一种阻塞策略:
缺陷
github中该项目已经很久没更新维护了,虽然很久没维护,但不影响使用。
总结
上面介绍了几种常见重试机制的实现方法以及两种成熟的重试组件,除了定时任务重试,其余的方案他们都存在一个共同的问题,就是当服务器宕机或者其他情况导致方法没有进行重试。
在一些一定要进行重试补偿的场景中,上述方案无法保证方法一定进行重试,可以将重试数据持久化,通过定时任务+重试组合解决这个问题。
以上就是本次文章的全部内容,感谢你阅读,如果文章对你有所帮助,点赞支持一下,感谢你的慷慨~