对于系统中出现的异常,有的需要告警出来,譬如参数校验不通过,写操作因为幂等的原因失败;有的则需要进行业务重试,譬如 rpc 调用超时。如何设计一个优良的重试机制呢?个人认为应当具备下面几点。
- 侵入性小:实现重试的代码逻辑与现有的业务逻辑应尽可能地分离,所谓不侵入,少耦合,重试逻辑与正常逻辑解耦。
- 动态配置:重试的最大次数、重试的间隔时间、是否采用线程池进行重试、是否异步重试等,可在项目正常运行期间动态配置而无需项目重启。
- 通用性强:适用于绝大多数需要重试的业务场景。
言归正传,如何实现一个优良的重试机制呢?这里我采用的是注解+切面的方式。在需要进行重试的方法上加上一个用以重试的自定义注解,在切面中实现具体的重试逻辑。整体的设计如下:
一.设计一个注解 @Retry,其中定义了两个字段 bizNo
和 bizType
,后续根据不同的业务类型实现不同的业务逻辑。
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Retry {
/**
* 业务单号
* @return
*/
String businessNo();
/**
* 业务类型
* @return
*/
TradeBizType businessType();
}
二. 设计一个切面,用以正常的业务代码执行的时候,横向添加重试逻辑。
@Aspect
@Component
public class RetryAspect {
/**
* @param point
* @param reTry
* @return
* @throws Throwable
*/
@Around("(@annotation(reTry))")
public Object around(ProceedingJoinPoint point, Retry reTry) throws Throwable {
Object result = null;
try{
//1.业务逻辑正常执行
result = point.proceed();
//2.返回 true 时,表明重试成功,需删除异常表对应的数据。
if(result){
deleteRetry(point, reTry);
}
} catch (Exception e){
//3.出现异常则保存异常条目进异常表或更新原有异常表数据(reTryTimes、reTryVersion、gmt_modified)
saveOrUpdateRetry(point, reTry, e);
}
return result;
}
}
三. 也许细心的小可爱已经发现了,在第二步,result 为 true-成功时,我怎么知道是重试任务的成功执行还是初次任务的成功执行呢?仅有当 result 为重试任务的成功执行结果时,我才需要去删除对应的重试记录条目。此时,可以借助当前线程的线程名称来判断。具体实现如下:
//...
result = point.proceed();
if(Thread.currentThread().getName().startsWith("BIZ_RETRY")){
deleteBizRetry(point, bizRetry);
}
//...
**四. 你也许会问了,线程名称的默认名称不是类似于 Thread-0
的么?在哪儿自定义的以 BIZ_RETRY 开头呢?忘了告诉你,具体怎么调用重试任务了。这里我采用的是 EventBus+Executor 线程池。实现将大批量的重试任务异步并多线程并发执行。首先定义一个EventBusUtil 工具类,里头定义了执行重试任务的线程池,异步处理的 EventBus 以及监听器 Listener。 **
public class EventBusUtils {
//1.在这我们设置了处理重试任务的线程所特有的线程名前缀
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(4, 8, 10000,
TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100000), new ThreadFactoryBuilder()
.setNameFormat("BIZ_RETRY-%d").build());
private static AsyncEventBus eventBus = new AsyncEventBus(EventBusUtils.class.getSimpleName(), EXECUTOR);
private static EventListener listener = new EventListener();
static {
//2.监听器的注册以及预启动所有的核心线程
eventBus.register(listener);
EXECUTOR.prestartAllCoreThreads();
}
/**
* @param bean
* @param methodName
* @param args
*/
public static void invoke(Object bean, String methodName, Object... args) {
Event event = new Event(bean, methodName, args);
eventBus.post(event);
}
}
五.有了上面处理重试任务的工具类,那么具体应该怎么调用呢?一般情况下,我们会通过定时任务按照一定的间隔时间触发重试,先从重试表中获取符合条件的重试任务,比如重试次数小于等于3次的,重试次数大于3次还没成功的,姑且认为它朽木不可雕也。然后调用 EventBusUtils 的静态方法 invoke 进行重试。到这还没结束呢,invoke 方法的参数应该如何填写呢?
六.上面抛出的问题就涉及到了重试表应该怎么设计,包含哪些必要的字段,我们知道 EventBus 是基于反射的,要想依托它来调用特定的业务重试逻辑,必然需要知道执行什么类的什么方法,参数列表是什么。所以我们的重试表中应该含有 Calss 类型的 目标类,String 类型的 methodName,还有 MethodParam[] params,这三个属性可以封装成一个类,在 saveOrUpdateRetry() 方法中将相应的信息填入重试表中。p.s:第一步其中 MethodParam 是我们自定义的类。大致的代码示例如下:
public class RetryContext {
/**
* 目标类
*/
private Class targetClass;
/**
* 方法名称
*/
private String methodName;
/**
* 方法参数
*/
private MethodParam[] methodParams;
static class MethodParam {
/**
* 参数类型
*/
private Class type;
/**
* 值
*/
private String value;
}}
七.看到这,大家应该清楚了,核心的实现 invoke 的逻辑:
//...省略从重试表获取 context 的过程
RetryEventBusUtils.invoke(ApplicationContextUtil.getBean(context.getTargetClass()), context.getMethodName(), context.getMethodArgs());
end