likes
comments
collection
share

一行注解搞定异常重试,这么牛?

作者站长头像
站长
· 阅读数 18

前言

我正在参加「掘金·启航计划」

大家好,我是 Skow

在我们业务开发的过程中,难免会碰到对外进行调用的情况,诸如在金融场景中,我们需要去推送还款计划的信息、查询还款结果等

三方的接口,对于我们来说其实类似一个“黑盒”,我们不知道这个盒子里面到底做了什么事,但是为了不影响盒子出现的异常影响我们业务正常的进行,所以我们需要针对不同的异常进行一个重试或者其他策略的进行

类比到我们框架中,诸如 RocketMQ 针对于发送失败的消息,也会将消息放到指定的队列,然后进行重试发送,其实简而言之重试,就是为了去保障我们业务的可用性、容错性、一致性

那么,针对于异常重试,可能新来的小杨同学会觉得,异常重试不是很简单,我判断一下三方回来的异常,进行 for 循环固定次数的重新调用就完事了

比如这样 👇

一行注解搞定异常重试,这么牛?

坏代码的味道

稍微解释一下这一部分代码(摘自真实业务系统,去除了一些敏感的业务逻辑)

开发这一部分理赔查证的同学,抽取了一个 payQuery 方法,其中一个参数为 重试次数

然后代码里如果重试次数小于等于 0,则认为重试结束了,此次查证失败

然后 利用 try catch 去进行外部调用,针对外部 回来的不同异常进行 catch 然后进行业务逻辑处理、睡眠、重试次数减一,继续进行外部调用

看到这个代码的时候,其实你说能跑吧也能跑,用吧,也可以凑合用,就是有点 “臭”,接下来我们借助 Spring-Retry 框架去优雅实现我们的重试功能

把玩 Spring-Retry

介绍

根据 Baeldung 网站介绍

Spring Retry provides an ability to automatically re-invoke a failed operation. This is helpful where the errors may be transient (like a momentary network glitch).

这些都是四级词汇,想必大家应该都认识,我在给大家翻译一下

Spring-Retry 框架提供了自动重试失败操作的能力,这个能力对瞬时的错误(比如网络故障)是非常有帮助的

通过上述介绍,其实我们知道 Spring-Retry 是基本可以满足我们的需求,那么接下来跟我,我们一起把玩一下这个框架

入门

引入依赖

        <dependency>
            <groupId>org.springframework.retry</groupId>
            <artifactId>spring-retry</artifactId>
            <version>1.2.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aspects</artifactId>
        </dependency>

启动类增加注解

@SpringBootApplication
// 增加这个注解
@EnableRetry
public class RetryApplication {
    public static void main(String[] args) {
        SpringApplication.run(RetryApplication.class, args);
    }
}

指定异常重试

将 @Retryable 注解加在对应的方法上即可

针对 NPE、IAE 异常会进行重试

@Retryable(value = {NullPointerException.class, IllegalArgumentException.class})

通过 SPEL 写法指定异常

@Retryable(exceptionExpression = "#{#root instanceof T(java.lang.IllegalArgumentException)}")

通过 SPEL 写法指定异常

// 格式#{@bean.methodName(#root)}。methodName的返回值为boolean类型。#root是异常类,即用户可以在代码中判断是否进行重试
    @Retryable(exceptionExpression = "#{@retryDemoImpl.isRetry(#root)}")
    public void testNullException() throws MyException {
        log.info("重试一下吧!");
        throw new MyException("异常");
    }

    public Boolean isRetry(Exception e) {
        return e instanceof MyException && (((MyException) e).getMyMessage()).contains("异常");
    }

    @Getter
    @Setter
    public class MyException extends Exception {
        private String myMessage;

        public MyException(String myMessage) {
            this.myMessage = myMessage;
        }

        @Override
        public String toString() {
            return "MyException{" + "myMessage='" + myMessage + '\'' + '}';
        }
    }

排除某些异常不进行重试

@Retryable(exclude = {IllegalArgumentException.class} )

指定重试次数

    /**
     * maxAttempts 直接指定重试次数
     * maxAttemptsExpression = "${max.attempts:}" 从配置文件中获取异常重试次数
     * 如果贪心的你都进行配置了,那么以 maxAttemptsExpression 为主
     */
    @Retryable(value = {IllegalArgumentException.class}, maxAttempts = 5, maxAttemptsExpression = "${max.attempts:}" )
    public void testNullException() {
        log.info("重试一下吧!");
        throw new IllegalArgumentException();
    }

指定间隔时间重试

    /**
     * delay为 2000 ms 进行重试,multiplier设置为2,则表示第一次重试间隔为2s,第二次为4秒,第三次为8s,
     * maxDelay 设置最大的重试间隔,当超过这个最大的重试间隔的时候,重试的间隔就等于maxDelay的值
     */
    @Retryable(value = IllegalArgumentException.class, backoff = @Backoff(delay = 2000, multiplier = 2, maxDelay = 5000))
    public void testNullException() {
        log.info("重试一下吧!");
        throw new IllegalArgumentException();
    }

重试失败兜底策略


    @Retryable(value = IllegalArgumentException.class, backoff = @Backoff(delay = 2000, multiplier = 2, maxDelay = 5000))
    public int testNullException(String message) {
        log.info("重试一下吧!");
        throw new IllegalArgumentException();
    }

    /**
     * 作为 @Retryable 方法重试失败之后的兜底方案
     * @Recover 的异常 @Retryable 注解的方法保持一致,第一入参为要重试的异常,其他参数与 @Retryable 保持一致,返回值也要一样,否则无法执行
     */
    @Recover()
    public int recover1(IllegalArgumentException e, String message) {
        log.info("进入异常1");
        return 1;
    }

    @Recover
    public int recover2(NullPointerException e, String message) {
        log.info("进入异常2");
        return 2;
    }

利用 RetryTemplate 进行异常重试

我们知道事务通常可以分为声明式事务管理和编程式事务管理,关于这二者的差异,我前期文章中有进行分析,感兴趣的小伙伴可以去翻一下

我们的异常重试也有“声明式重试”和“编程式重试”

同样的我认为,再有必要的时候可以使用“编程式重试”,去提醒未来接手这段代码的小伙伴,这段代码具有重试逻辑~~,你丫的看认真点~~

我们借助 RetryTemplate 进行 “编程式重试”

首先我们需要自定义我们自己的 RetryTemplate,诸如以下所示

@Configuration
public class RetryTemplateConfig {
    @Bean
    public RetryTemplate retryTemplate() {
        RetryTemplate retryTemplate = new RetryTemplate();

        // 设置重试策略
        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
        retryPolicy.setMaxAttempts(5);
        retryTemplate.setRetryPolicy(retryPolicy);

        // 设置退避策略
        FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy();
        fixedBackOffPolicy.setSleeper(new Sleeper() {
            @Override
            public void sleep(long backOffPeriod) throws InterruptedException {
                // 等待的时候做点什么事呢,看个电影吧?
                System.out.println("当前等待时间" + backOffPeriod);
            }
        });
        fixedBackOffPolicy.setBackOffPeriod(5000L);
        retryTemplate.setBackOffPolicy(fixedBackOffPolicy);
        retryTemplate.setListeners();
        return retryTemplate;
    }
}

其实这一段代码,无非就是把注解的东西搬到了我们的代码上

重点关注 RetryPolicy、FixedBackOffPolicy 、RetryListener这三个类是啥玩意

RetryPolicy 其实就是去设置我们的重试策略,默认的话已经给我们提供了多种重试策略

一行注解搞定异常重试,这么牛?

从图中我们可以看 RetryPolicy 有诸多的实现类,我们说两个比较常用的,其他的小伙伴可以自己去探索一下如何使用,

  • SimpleRetryPolicy 默认最多重试3次
  • CompositeRetryPolicy 可以组合多个重试策略
  • NeverRetryPolicy 从不重试
  • AlwaysRetryPolicy 总是重试

FixedBackOffPolicy 是我们的退避策略,退避策略,其实就是去指定我们一旦发生异常多久的时间可以重试、怎么去做下一次重试

同样的我们看一下 BackOffPolicy 的实现类有哪些,我们也简单举一些 🌰

一行注解搞定异常重试,这么牛?

  • FixedBackOffPolicy 默认固定延迟 1 秒后执行下一次重试
  • ExponentialBackOffPolicy 指数递增延迟执行重试,默认初始 0.1 秒,系数是 2,那么下次延迟 0.2 秒,再下次就是延迟 0.4 秒,如此类推,最大延迟 30 秒
  • ExponentialRandomBackOffPolicy 在上面那个策略上增加随机性,我们设置该策略参数为initialInterval = 50 multiplier = 2.0 maxInterval = 3000 numRetries = 5,按照正常重试时间为 [50, 100, 200, 400, 800],但是在该策略下可能为 [76, 151, 304, 580, 901]
  • UniformRandomBackOffPolicy 这个跟上面的区别就是,上面的延迟会不停递增,这个只会在你设置的最小的重试时间和最大的重试之间随机

RetryListener 其实可以理解为我们的监听者,它可以去监听我们重试过程然后执行我们对应的回调方法

接下来,我们自定义 Listener 玩一下

@Slf4j
public class DefaultListenerSupport extends RetryListenerSupport {

    @Override
    public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback,
        Throwable throwable) {
        log.info("在最后一次重试后调用");
        super.close(context, callback, throwable);
    }

    @Override
    public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback,
        Throwable throwable) {
        log.info("在每次重试后都会被调用");
        super.onError(context, callback, throwable);
    }

    @Override
    public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
        log.info("在第一次重试前调用");
        return super.open(context, callback);
    }
}

然后 set 到我们的 RetryTemplate 中即可

完整的使用例子如下

    @Autowired
    private RetryTemplate retryTemplate;

    public int testNullException(String message) throws IllegalAccessException {

        retryTemplate.execute(new RetryCallback<Object, IllegalAccessException>() {

            @Override
            public Object doWithRetry(RetryContext context) throws IllegalAccessException {
                log.info("重试一下吧");
                throw new IllegalAccessException();
            }
        });
        return 1;
    }

源码欣赏

上面只是带大家入门了一下 Spring-Retry 的基本使用方法,小伙伴们可以以此为抓手,然后自己深入研究,闭环 Spring-Retry 整体玩法,从而达到年底 375 的好评 🐶

我们深入讨论下 Spring-Retry 是如何实现重试的,在翻看源码前,其实我们可以猜想,如果是我们自己去设计这样一个重试注解我们会怎么设计?

思考 5 分钟

5 分钟结束

接下来跟着 Skow 扒开 Spring-Retry 的外衣

我们开始 debug,根据堆栈其实我们可以发现这个框架的入口处其实在

一行注解搞定异常重试,这么牛?

org.springframework.retry.support.RetryTemplate#execute(org.springframework.retry.RetryCallback<T,E>)

	public final <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback)
			throws E {
		return doExecute(retryCallback, null, null);
	}

这个 execute 仅接收一个函数,这个函数其实就是涵盖了我们需要重试的方法以及类的一些基本信息

我们继续 debug 往下走,下一步是 org.springframework.retry.support.RetryTemplate#doExecute

一般在 Spring 源码中,带有 do 的方法,就是真实的执行逻辑,这个小 tip 送给大家,拿小本本记起来

这个 doExecute 也是我们 Spring-Retry 真正的执行逻辑, 在这个方法中它主要做了几件事

  • 判断是否需要进行回调
  • 按照配置执行重试策略(有状态、无状态)
  • 按照配置执行退避策略

那我们就根据这几点进行重点代码的分析

判断是否需要进行回调

判断方法集中在 RetryTemplate.java:278

canRetry(retryPolicy, context) && !context.isExhaustedOnly()

这个方法名起的就非常的简单易懂,是否可以重试 && 暂时不知道什么的 context 是否已经结束

我们先看一下 canRetry 里面做了什么事

	@Override
	public boolean canRetry(RetryContext context) {
		Throwable t = context.getLastThrowable();
		return (t == null || retryForException(t)) && context.getRetryCount() < maxAttempts;
	}

这个 t 我们可以理解为当前抛出来的异常类型,即引起我们重试的到底是那个可恶的异常

retryForException 做的事情就是判断你当前这个异常是否符合我们配置的异常类型

context 可以理解为重试配置的上下文对象,从中获取的 retryCount 就是我们目前已经重试多少次了

maxAttempts 读了上文的小伙伴肯定一眼就知道了,这个就是我们自主配置的最大的重试次数

所以这个 canRetry 这个方法做的事情就是判断异常类型是否符合我们的需要重试的异常并且当前的重试次数要小于我们的重试次数

接着我们来看 !context.isExhaustedOnly() 做了什么事

在前文我们已经剧透了 context 事 重试配置的上下文对象,那我 isExhaustedOnly 这个字段是什么时候被设置的,什么时候为 true 什么时候为 false,则需要引起我们的关注

细心的小伙伴可能会观察到我们在自定义 DefaultListenerSupport 的时候,其实在方法中是可以拿到 contetx 这个作为入参的,bingo,就是在这个时候我们可以设置重试结束,直接 context.setExhaustedOnly(); 即可阻止我们的重试

无状态重试策略

经过了前一轮方法的判断,我们可以顺利执行我们的重试了

一行注解搞定异常重试,这么牛?

一旦重试发生了任何异常,回被我们再次 catch 住

然后进行 registerThrowable,这个方法其实就是为了计算我们的重试次数、将异常塞入我们的上下文对象、设置我们的 retryState(这是一个重试状态我们后面再说)

紧接着会走到我们的 doOnErrorInterceptors 方法

这个方法其实就是执行我们的监听者的 onError (在每次重试后都会被调用)方法

	private <T, E extends Throwable> void doOnErrorInterceptors(
			RetryCallback<T, E> callback, RetryContext context, Throwable throwable) {
		for (int i = this.listeners.length; i-- > 0;) {
			this.listeners[i].onError(context, callback, throwable);
		}
	}

因为经过监听者的过滤,我们的 context 可能会被改变,所以这里还会进行一次是否可以继续重试的判断

按照配置执行退避策略

如果还可以继续重试的话,则调用 backOffPolicy.backOff(backOffContext);

这个方法其实就是去执行我们的退避策略,比如暂停一段时间后再进行重试

有状态重试策略

可能有小伙伴会对这个有疑问,什么是有状态,什么是无状态

无状态重试比较好理解,最简单的场景就是我们直接对外调用,这个时候这个重试上下文在一个循环中完成所有的事,我们直接无脑进行重试就好了

但是有状态的重试,其实就需要依赖到我们上文提到的 retryState,一般在 Spring-Retry 中我们有两种情况需要用到有状态重试 事务异常需要回滚、以及熔断器模式下的重试 则需要进行有状态的重试

具体使用方法如 demo 所示

    public int testNullException() throws IllegalAccessException {

        // 当前状态的名称,当把状态放入缓存时,通过该key查询获取
        Object key = "mykey";
        // 是否每次都重新生成上下文还是从缓存中查询,即全局模式(如熔断器策略时从缓存中查询)
        boolean isForceRefresh = true;
        // 对 DuplicateKeyException 进行回滚
        BinaryExceptionClassifier rf = new BinaryExceptionClassifier(
            Collections.<Class<? extends Throwable>>singleton(DuplicateKeyException.class));
        RetryState state = new DefaultRetryState(key, isForceRefresh, rf);

        try {
            retryTemplate.execute(new RetryCallback<Object, IllegalAccessException>() {

                @Override
                public Object doWithRetry(RetryContext context) throws IllegalAccessException {
                    log.info("重试一下吧");
                    throw new IllegalAccessException();
                }
            }, new RecoveryCallback() {

                @Override
                public Object recover(RetryContext context) throws Exception {
                    log.info("重试一下吧2");
                    return null;
                }
            }, state);
        } catch (DuplicateKeyException e) {
            // 执行回滚操作
        }
        return 1;

    }

继续有状态的重试,还是失败的话,会继续执行

handleRetryExhausted(recoveryCallback, context, state);

这个方法就是 如果你存在 RecoveryCallback,就会执行此回调,否则直接抛出异常

最后 的话会进行相关的环境变量清理

至此我们的 RetryTemplate 已经分析完毕,当然还有一些非常细节的点我没有展开说明,感兴趣的小伙伴可以自己跟着 debug 分析一波,需要文中的 demo 可以与我联系,我可以发给你,开箱即 debug

闲言碎语

这是去年就想写的一篇文章,一直拖到了现在,国庆期间写了一下,然后又拖到了现在才发

假期期间朋友推荐了一部电影给我《死亡诗社》

理想和现实,自我追求与被规划着前行的压力,我觉得应该很多人都有过类似的体验

《死亡诗社》似乎给了仍对未来抱有向往的人一个答案

“我步入丛林,因为我希望生活的有意义,我希望活的深刻。汲取生命中所有的精华,把非生命的一切都击溃,以免让我在生命终结时,发现自己从来没有活过!

seize the day 珍惜现在 把握今天