论成败,大不了重头再来,聊一聊 Spring Retry

原创 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 比较简单,只需要:

  1. 在应用配置类上添加 @EnableRetry 以打开Spring Retry的自动配置;
  2. 在需要重试的方法上添加 @Retryable 注解,注解中添加重试的错误类型;
  3. 在重试方法后添加 @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 的用法,下面我们会稍微了解一下它是如何设计和运作的~


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<Class<? extends Annotation>> retryableAnnotationTypes = new LinkedHashSet<Class<? extends Annotation>>(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, E extends Throwable> T execute(RetryCallback<T, E> retryCallback) throws E;

    <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback, RecoveryCallback<T> recoveryCallback) throws E;

    <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback, RetryState retryState) throws E, ExhaustedRetryException;
    
    <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback, RecoveryCallback<T> recoveryCallback, RetryState retryState)
            throws E;
}

紧接着 RetryTemplate 实现 RetryOperations 的方法,它也是 Spring Retry 最最核心的部分,代码如下(节选):

public class RetryTemplate implements RetryOperations {

    /**
    以下对应 RetryOperations 的实现方法
    */
    @Override
    public final <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback)
            throws E {
        return doExecute(retryCallback, null, null);
    }

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

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

    @Override
    public final <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback,
            RecoveryCallback<T> recoveryCallback, RetryState retryState)
            throws E, ExhaustedRetryException {
        return doExecute(retryCallback, recoveryCallback, retryState);
    }

    protected <T, E extends Throwable> T doExecute(RetryCallback<T, E> retryCallback,
            RecoveryCallback<T> 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.<E>wrapIfNecessary(e);
                }

                if (state != null && context.hasAttribute(GLOBAL_STATE)) {
                    break;
                }
            }

            exhausted = true;
            return handleRetryExhausted(recoveryCallback, context, state);

        }
        catch (Throwable e) {
            throw RetryTemplate.<E>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)。下图可以更直观地描述其流程:

流程图.png

在打开拦截器监听器之后,重试策略会通过上下文判断是否进行重试,如果需要重试则会运行需要重试部分的代码,一旦出现相匹配的异常就会被监听器捕获,执行回退策略,再次回到循环起点;如果重试策略判断不再重试,则会运行相对应的 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实践

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容