本文内容是目前对重试机制实现方案的梳理总结。

从为什么需要重试的背景开始,到重试的场景,大致的一些设计思路,最后通过两个成熟的retry组件进行案例讲解,理论+实战。

java 重试 阶梯时间 java重试代码实现_等待时间

背景

重试是系统提高容错能力的一种手段。在一次请求中,往往需要经过多个服务之间的调用,由于网络波动或者其他原因,请求可能无法正常到达服务端或者服务端的请求无法正常的返回,从而导致请求失败,这种失败往往可以通过重试的方式来解决。因此服务之间的重试机制提高了整个系统的故障恢复能力。

重试场景

典型的重试场景如下:

  1. 网络抖动问题造成的请求失败,通过重试提高成功率。
  2. 由于系统负载高原因导致请求变慢,导致请求超时,通过重试提高成功率。
  3. 由于系统故障或服务不可用导致请求没能成功,通过重试保证数据落地。

那么是不是所有请求都可以重试?
显然不是,重试依赖于接口的幂等性,假设一个接口多次使用相同参数调用会导致其违反数据约束,那么得到的结果可能会在我们预期之外。

设计思路

如何设计一个优雅的重试机制?优雅的重试机制应该具备如下几点特点:

  • 无侵入:或者侵入低,这个好理解,不改动当前的业务逻辑,对于需要重试的地方,可以很简单的实现
  • 可配置:包括重试次数,重试的间隔时间,是否使用异步方式等
  • 通用性:最好是无改动(或者很小改动)的支持绝大部分的场景,拿过来直接可用

模板方式

将重试机制的实现抽取成一个模板,预留接口。示例如下:

public abstract class MyRetryTemplate<T> {
    //重试次数
    private int retryTime;
    //重试时间
    private int sleepTime;
    //重试时间是否倍数增长
    private  boolean multiple = false;
 
    /**
     * 执行业务方法逻辑,由实现类实现
     *
     * @return
     */
    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 {
    /**
     * 重试次数
     * @return
     */
    int retryTime() default 0;
 
 
    /**
     * 重试的间隔时间
     * @return
     */
    int sleepTime() default 0;
 
 
    /**
     * 是否支持异步重试方式
     * @return
     */
    boolean asyn() default false;
    
    /**
     * 重试时间是否倍数增长
     * @return
     */
    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");
    }
}
  • 优点
  • 代码侵入性低
  • 缺点
  • 某些方法无法被切面拦截的场景无法覆盖(如spring-aop无法切私有方法,final方法)
  • 直接使用aspecj有些复杂;如果用spring-aop,则只能切被spring容器管理的bean

定时任务

通过定时任务定时执行某种需要重试的业务。以订单支付为例,定时扫描数据库中一定时间范围内的所有锁定状态订单,然后查询支付中心这笔订单的状态,此例中重试间隔时间就是定时任务的间隔时间,重试停止条件就是查不到对应的订单。

伪代码:

/**
  * 3分钟执行一次
  */
@Xxljob(cron = "50 */3  * * * ?")
public void getOrderStatus(){
    // 计算订单查询开始时间
    String startTime = DateUtils.addMinut(new Date, -30, "yyyy-MM-dd hh:mm:ss");
    // 查询startTime之后的锁定状态订单
    List<Order> orderList = orderMapper.getOrderList(startTime, "L");
    for (Order order : orderList) {
        // 调用支付中心接口查询订单支付状态
        Order order = orderCenter.getOrderStatus(order);
        if ("S".equals(order.getStatus())) {
            // 修改支付状态
            orderMapper.update();
        }
    }
}
  • 优点
  • 使用简单
  • 解耦
  • 侵入性低
  • 缺点
  • 重试时间间隔固定

消息队列重试

可以通过消息队列中的延时队列和死信交换机实现重试队列,这里通过rabbitmq实现。在介绍重试队列实现之前,先了解一下延迟队列与死信队列

死信交换机

队列中的消息可能会变成死信消息(dead-lettered),进而当以下几个事件任意一个发生时,消息将会被重新发送到一个交换机:

  • 消息被消费者使用basic.reject或basic.nack方法并且requeue参数值设置为false的方式进行消息确认(negatively acknowledged)
  • 消息由于消息有效期(per-message TTL)过期
  • 消息由于队列超过其长度限制而被丢弃

死信消息将被队列的死信交换机路由到其他队列

延迟队列

延迟队列可以解决很多特定场景下,带时间属性的任务需求。延迟队列一般应用于需要延迟工作的场景,比如:

  1. 订单下单后未支付需要延迟一定时间释放。
  2. 预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议
  3. 账单在一周内未支付,则自动结算
  4. 用户注册成功后,如果三天内没有登陆则进行短信提醒

这些场景都有一个特点,就是需要在某个事件发生之后或者之前的指定时间点完成某一项任务。队列内的消息有序,且会在指定的时间后被取出消费。

在rabbitmq中,延迟队列的实现依赖它的特性——TTL(time to live),TTL是RabbitMQ中一个消息或者队列的属性,表明一条消息或者该队列中的所有消息的最大存活时间,单位是毫秒。换句话说,如果一条消息设置了TTL属性或者进入了设置TTL属性的队列,那么这条消息如果在TTL设置的时间内没有被消费,则会成为“死信”。如果同时配置了队列的TTL和消息的TTL,那么较小的那个值将会被使用。

延迟队列的实现

rabbitmq本身没有延迟队列,我们可以通过设置队列和消息的TTL属性与死信交换机实现一个延迟队列。将消息设置TTL后,不让消费者进行消费,消息过期后会自动通过死信交换机路由到其他队列,让消费者消费死信交换机路由后的队列中的消息,这样就实现了一个延迟队列。

java 重试 阶梯时间 java重试代码实现_System_02

重试实现

在了解了延迟队列的实现方式后,重试实现起来也相差不大。将需要重试的业务发送一条消息到队列A中,消费者进行消费,如果处理失败,则再发送一条消息到一个延迟队列B,队列B中的消息过期后通过交换机路由重新回到队列A中,这就实现了重试。这种方法中,重试方法的等待时间就是消息的过期时间,而重试最大等待时间为延迟队列的TTL,借助redis等类似的组件缓存重试次数,达到限制方法重试次数的方法。

java 重试 阶梯时间 java重试代码实现_等待时间_03

成熟的重试组件

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, mr = 2))
    public void call() {
        System.out.println("Call something...");
        throw new RuntimeException("RPC调用异常");
    }
 
    /**
     * recover 机制
     * @param e 异常
     */
    @Recover
    public void recover(RuntimeException e) {
        System.out.println("Call recover...");
    }
}

如果达到最大重试次数还没请求成功,这种情况可使用@Recover进行熔断补偿,该方法会在全部重试失败调用

缺陷

spring-retry 存在两个不友好设计:

  1. 重试实体限定为 Throwable 子类,说明重试针对的是可捕捉的功能异常为设计前提的,但是我们希望依赖某个数据对象实体作为重试实体,但 sping-retry框架必须强制转换为Throwable子类。
  2. 如果你要以返回值的某个状态来判定是否需要重试,可能只能通过自己判断返回值然后显式抛出异常了

guava-retrying

guava retrying模块提供了一种通用方法,用于重试具有特定停止、重试和异常处理功能的任意Java代码,这些功能通过guava的谓词匹配得到了增强。

依赖引入
<!-- https://mvnrepository.com/artifact/com.github.rholder/guava-retrying -->
<dependency>
    <groupId>com.github.rholder</groupId>
    <artifactId>guava-retrying</artifactId>
    <version>2.0.0</version>
</dependency>
入门案例
public class RetryDemoTask {
  /**
   * 重试方法
   * @return
   */
  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(){
    // RetryerBuilder 构建重试实例 retryer,可以设置重试源且可以支持多个重试源,可以配置重试次数或重试超时时间,以及可以配置等待时间间隔
    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:重试停止策略,可设置的值如下:

• StopStrategies.stopAfterAttempt():达到重试次数上线后停止重试
• StopStrategies.stopAfterDelay():超过最大重试时间后停止重试

withAttemptTimeLimiter:重试限制器,默认两种提供两种实现:

• NoAttemptTimeLimit:无限制,直接调用回调方法
• FixedAttemptTimeLimit:固定时间限制处理器,超时取消

withBlockStrategy:阻塞策略。阻塞策略配置每次重试之前如何阻塞流程,默认是线程休眠,guava-retrying只提供了一种阻塞策略:

  • ThreadSleepStrategy:线程休眠(默认)
缺陷

github中该项目已经很久没更新维护了,虽然很久没维护,但不影响使用。