实际开发中,在一些关键业务逻辑上,为了使代码更加健壮,我们可能需要手动编写重试机制。SpringRetry是一个方便实现“重试”这一操作的轻量级框架,我们合理的使用SpringRetry能够简化重复的模板代码,提升代码可读性。这篇笔记我们简单介绍SpringRetry的使用。
SpringRetry的实现依赖于AOP和AspectJ,在SpringBoot工程中,我们引入spring-retry
的同时还需要引入AOP的起步依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
此外我们还需要在启动类上添加@EnableRetry
注解。
package com.gacfox.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;
@EnableRetry
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
SpringRetry有两个重要的注解:
@Retryable:标注了该注解的方法会由SpringRetry通过AOP增强,支持声明式的重试配置。
@Recover:标注该注解的方法俗称“兜底”方法,指重试仍不成功后的处理逻辑。
这里我们直接看一个例子。下面场景中,我们的业务逻辑是调用一个“退款”接口,这个操作看似简单,但其实需要我们仔细考虑。如果用户发起退款,但由于网络原因等问题,调用退款接口报错了,那到底退款成没成功呢?为避免用户重复退款要求我们在接口提供方实现严格的幂等性;而为尽最大可能确保完成接口调用,接口调用方则需要实现“重试”机制,这里接口调用方就是用到SpringRetry的地方。
package com.gacfox.demo.service.impl;
import com.gacfox.demo.model.ApiResult;
import com.gacfox.demo.service.DemoService;
import com.gacfox.demo.service.RefundService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Slf4j
@Service("demoService")
public class DemoServiceImpl implements DemoService {
@Resource
private RefundService refundService;
@Retryable(value = RuntimeException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2))
@Override
public ApiResult<?> callRefundServiceWithRetry(String orderId) {
log.info("尝试退款 订单号[{}]", orderId);
ApiResult<?> apiResult = refundService.refund(orderId);
if (!"0".equals(apiResult.getCode())) {
// 接口明确返回退款失败
log.info("订单号[{}]退款失败,原因[{}]。", orderId, apiResult.getMessage());
} else {
// 退款成功
log.info("退款成功");
}
return apiResult;
}
@Recover
public ApiResult<?> callRefundServiceRecovery(RuntimeException e, String orderId) {
log.error("退款异常: ", e);
log.info("订单号[{}]退款异常且重试无法恢复,记录日志等操作...", orderId);
return ApiResult.failure("系统异常,请稍后再试");
}
}
代码中,callRefundServiceWithRetry()
是我们编写的一段例子业务逻辑,它尝试调用退款方法refundService.refund(orderId)
。此处调用不成功其实分为两种情况,一种是接口明确返回失败状态,这种情况是不必重试的,我们直接将失败原因告知用户即可;而另一种是“其它调用异常”,它可能是由于微服务之间链路网络闪断或过载、微服务熔断、数据库连接超时等千奇百怪的原因造成的,这种情况我们就必须重试了。在@Retryable
注解里我们设置了3个属性:
value
:抛出该异常时触发自动重试maxAttempts
:最大重试次数,默认3次backoff
:补偿机制,@Backoff(delay = 2000, multiplier = 2)
表示起始的重试间隔为2秒,之后的重试时间间隔每次乘以2,即按照2秒、4秒、8秒的间隔重试注:value
指定需要重试的异常类型,此外我们还可以使用exclude
属性主动排除一些不需要重试的异常类型。
@Recover
是“兜底方法”,它的第一个参数通常是对应@Retryable
方法抛出的异常类型,其后的参数和返回值则与@Retryable
方法相同,触发兜底时,对应位置的参数将被传给兜底方法。兜底方法是否触发取决于重试方法抛出的异常类型,如果抛出的异常类型满足兜底方法的第一个参数类型,则兜底方法被执行(无论是否执行重试,exclude
的异常类型虽然不会执行重试,但如果满足兜底方法的异常类型,则也会执行兜底方法)。多个@Recover
是允许的,它们通过参数签名进行区分并由SpringRetry框架反射调用,通常多个@Recover
方法用来实现兜底处理不同的异常类型。
以上代码当调用refundService.refund(orderId)
时如果抛出了奇奇怪怪的异常,重试机制就会被触发,如果重试成功则正常返回调用结果,用户对此是无感知的,然而最终如果无法恢复则@Recover
方法将被执行,我们可能看到类似如下的日志:
2024-10-21 20:55:45.558 INFO 24640 --- [io-18080-exec-1] c.g.demo.service.impl.DemoServiceImpl : 尝试退款 订单号[000001]
2024-10-21 20:55:47.569 INFO 24640 --- [io-18080-exec-1] c.g.demo.service.impl.DemoServiceImpl : 尝试退款 订单号[000001]
2024-10-21 20:55:51.580 INFO 24640 --- [io-18080-exec-1] c.g.demo.service.impl.DemoServiceImpl : 尝试退款 订单号[000001]
2024-10-21 20:55:51.582 ERROR 24640 --- [io-18080-exec-1] c.g.demo.service.impl.DemoServiceImpl : 退款异常:
...
2024-10-21 20:55:51.582 INFO 24640 --- [io-18080-exec-1] c.g.demo.service.impl.DemoServiceImpl : 订单号[000001]退款异常且重试无法恢复,记录日志等操作...
此外我们还要知道,SpringRetry是同步(单线程)执行的,前面我们说过重试存在间隔时间,这些等待间隔也会导致当前处理线程的阻塞。如果是并发量较高的场景,我们需要警惕长数据库事务问题,此外还可能需要考虑优化重试策略、使用异步处理或选择非阻塞模型(例如Reactive)进行开发。
RetryTemplate
是SpringRetry中另一种风格的写法,它使用编程式配置来指定重试机制和“兜底”逻辑。RetryTemplate
能够实现更加细粒度的重试控制且有更丰富的配置选项,当然缺点是代码比较抽象。下面例子代码实现了前面相同的功能。
package com.gacfox.demo.service.impl;
import com.gacfox.demo.model.ApiResult;
import com.gacfox.demo.service.DemoService;
import com.gacfox.demo.service.RefundService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service("demoService")
public class DemoServiceImpl implements DemoService {
@Resource
private RefundService refundService;
private RetryTemplate retryTemplate;
@PostConstruct
public void init() {
// 初始化RetryTemplate
retryTemplate = new RetryTemplate();
Map<Class<? extends Throwable>, Boolean> retryableExceptions = new HashMap<>();
retryableExceptions.put(RuntimeException.class, Boolean.TRUE);
SimpleRetryPolicy simpleRetryPolicy = new SimpleRetryPolicy(3, retryableExceptions);
retryTemplate.setRetryPolicy(simpleRetryPolicy);
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(2000);
backOffPolicy.setMultiplier(2);
retryTemplate.setBackOffPolicy(backOffPolicy);
}
@Override
public ApiResult<?> callRefundServiceWithRetry(String orderId) {
return retryTemplate.execute(
context -> {
log.info("尝试退款 订单号[{}]", orderId);
ApiResult<?> apiResult = refundService.refund(orderId);
if (!"0".equals(apiResult.getCode())) {
// 接口明确返回退款失败
log.info("订单号[{}]退款失败,原因[{}]。", orderId, apiResult.getMessage());
} else {
// 退款成功
log.info("退款成功");
}
return apiResult;
}, context -> {
log.error("退款异常: ", context.getLastThrowable());
log.info("订单号[{}]退款异常且重试无法恢复,记录日志等操作...", orderId);
return ApiResult.failure("系统异常,请稍后再试");
});
}
}
代码中,我们先在这个服务Bean装载时初始化了RetryTemplate
对象,实际上这段代码也可以放在其它位置,比如将RetryTemplate
作为一个SpringBean来注册和复用也是可以的,只要我们能在使用RetryTemplate
前正确组装它就可以。callRefundServiceWithRetry()
方法中,我们使用了retryTemplate.execute()
方法,它接收两个参数,分别是带重试的业务逻辑和兜底逻辑。