准备知识:AOP、注解
1. 定义幂等相关的注解
/**
* 幂等注解
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* 幂等Key,只有在 {@link Idempotent#type()} 为 {@link IdempotentTypeEnum#SPEL} 时生效
*/
String key() default "";
/**
* 触发幂等失败逻辑时,返回的错误提示信息
*/
String message() default "您操作太快,请稍后再试";
/**
* 验证幂等场景,支持多种 {@link IdempotentSceneEnum}
*/
IdempotentSceneEnum scene() default IdempotentSceneEnum.RESTAPI;
/**
* 验证幂等类型,支持多种幂等方式
* RestAPI 建议使用 {@link IdempotentTypeEnum#TOKEN} 或 {@link IdempotentTypeEnum#PARAM}
* 其它类型幂等验证,使用 {@link IdempotentTypeEnum#SPEL}
*/
IdempotentTypeEnum type() default IdempotentTypeEnum.PARAM;
/**
* 设置防重令牌 Key 前缀,MQ 幂等去重可选设置
* {@link IdempotentSceneEnum#MQ} and {@link IdempotentTypeEnum#SPEL} 时生效
*/
String uniqueKeyPrefix() default "";
/**
* 设置防重令牌 Key 过期时间,单位秒,默认 1 小时,MQ 幂等去重可选设置
* {@link IdempotentSceneEnum#MQ} and {@link IdempotentTypeEnum#SPEL} 时生效
*/
long keyTimeout() default 3600L;
}
《消息队列如何保证幂等【方案篇】》中说到了三种幂等解决方案,最终全部浓缩到@Idempotent
一个注解上,注解里面各个字段的含义如下图所示:
重要的字段有两个,scene
和 type
Scene字段
代表是接口的防重复提交还是消息队列的防重复消费,通过一个枚举标识。
package org.opengoofy.index12306.framework.starter.idempotent.enums;
/**
* 幂等验证场景枚举
*/
public enum IdempotentSceneEnum {
/**
* 基于 RestAPI 场景验证(接口防重复提交)
*/
RESTAPI,
/**
* 基于 MQ 场景验证(消息队列防重复消费)
*/
MQ
}
Type字段
代表使用什么方式实现幂等,其中 TOKEN 和 PARAM 以及 SPEL 可以应用于接口防重复提交,SPEL 应用于消息队列防重复消费。
- 分布式锁:PARAM 和 SPEL。
- Token 令牌:TOKEN。
- 去重表:SPEL。
package org.opengoofy.index12306.framework.starter.idempotent.enums;
/**
* 幂等验证类型枚举
*/
public enum IdempotentTypeEnum {
/**
* 基于 Token 方式验证
*/
TOKEN,
/**
* 基于方法参数方式验证
*/
PARAM,
/**
* 基于 SpEL 表达式方式验证
*/
SPEL
}
2. 幂等 AOP 解析注解
使用Spring的AOP机制来提供幂等性保障。只需要在需要保证幂等性的方法上添加 @Idempotent
注解,并通过@annotation
注解定义增强原始方法的前置逻辑、后置逻辑。
简单来说,就是先获取到原始方法上的幂等注解(@Idempotent
),然后获取到对应的幂等处理实现类(@annotation(Idempotent)
)。通过实现类进行幂等前置逻辑,执行完成后操作被@Idempotent
注解修饰的原始业务方法,最终执行释放资源的后置逻辑。
/**
* 幂等注解 AOP 拦截器
*/
@Aspect
public final class IdempotentAspect {
/**
* 增强方法标记 {@link Idempotent} 注解逻辑
*/
@Around("@annotation(org.opengoofy.index12306.framework.starter.idempotent.annotation.Idempotent)")
public Object idempotentHandler(ProceedingJoinPoint joinPoint) throws Throwable {
// 获取到方法上的幂等注解实际数据
Idempotent idempotent = getIdempotent(joinPoint);
// 通过幂等场景以及幂等类型,获取幂等执行处理器
IdempotentExecuteHandler instance = IdempotentExecuteHandlerFactory.getInstance(idempotent.scene(), idempotent.type());
Object resultObj;
try {
// 执行幂等处理前置逻辑(保障原始业务方法幂等)
instance.execute(joinPoint, idempotent);
// 如果幂等处理逻辑没有抛异常,处理中间业务
resultObj = joinPoint.proceed();
// 处理幂等后置逻辑,比如释放资源或者锁之类的
instance.postProcessing();
} catch (RepeatConsumptionException ex) {
/**
* 该异常为消息队列防重复提交独有,触发幂等逻辑时可能有两种情况:
* * 1. 消息还在处理,但是不确定是否执行成功,那么需要返回错误,方便 RocketMQ 再次通过重试队列投递
* * 2. 消息处理成功了,该消息直接返回成功即可
*/
if (!ex.getError()) {
return null; // 消息处理成功
}
throw ex;
} catch (Throwable ex) {
// 客户端消费存在异常,需要删除幂等标识方便下次 RocketMQ 再次通过重试队列投递
instance.exceptionProcessing();
throw ex;
} finally {
// 清理幂等容器上下文
IdempotentContext.clean();
}
return resultObj;
}
public static Idempotent getIdempotent(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method targetMethod = joinPoint.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
return targetMethod.getAnnotation(Idempotent.class);
}
}
- Around(“@annotation(org.opengoofy.index12306.framework.starter.idempotent.annotation.Idempotent)”):@annotation(Idempotent)注解表示 idempotentHandler 方法为@Idempotent修饰的原始方法的增强方法。
- @Around为环绕通知,在idempotentHandler方法中通过ProceedingJoinPoint手动调用原始业务方法。
- 先获取原始业务方法上的幂等注解实际数据,根据幂等注解中的实际数据(Scene字段、Type字段)获取幂等执行处理器
// 获取到方法上的幂等注解实际数据 Idempotent idempotent = getIdempotent(joinPoint); // 通过幂等场景以及幂等类型,获取幂等执行处理器 IdempotentExecuteHandler instance = IdempotentExecuteHandlerFactory.getInstance(idempotent.scene(), idempotent.type());
- 通过幂等执行处理器instance执行原始业务的前置逻辑、后置逻辑
// 执行幂等处理前置逻辑(保障原始业务方法幂等) instance.execute(joinPoint, idempotent); // 如果幂等处理逻辑没有抛异常,处理中间业务 resultObj = joinPoint.proceed(); // 处理幂等后置逻辑,比如释放资源或者锁之类的 instance.postProcessing();
2.1. 获取幂等处理器
针对不同幂等验证场景(Scene字段)以及幂等处理类型(Type字段)拆分了不同的策略模式处理器,帮助我们实现高内聚低耦合。
/**
* 幂等执行处理器工厂
* <p>
* Q:可能会有同学有疑问:这里为什么要采用简单工厂模式?策略模式不行么?
* A:策略模式同样可以达到获取真正幂等处理器功能。但是简单工厂的语意更适合这个场景,所以选择了简单工厂
*/
public final class IdempotentExecuteHandlerFactory {
/**
* 获取幂等执行处理器
*
* @param scene 指定幂等验证场景类型
* @param type 指定幂等处理类型
* @return 幂等执行处理器
*/
public static IdempotentExecuteHandler getInstance(IdempotentSceneEnum scene, IdempotentTypeEnum type) {
IdempotentExecuteHandler result = null;
switch (scene) {
case RESTAPI -> {
switch (type) {
case PARAM -> result = ApplicationContextHolder.getBean(IdempotentParamService.class);
case TOKEN -> result = ApplicationContextHolder.getBean(IdempotentTokenService.class);
case SPEL -> result = ApplicationContextHolder.getBean(IdempotentSpELByRestAPIExecuteHandler.class);
default -> {
}
}
}
case MQ -> result = ApplicationContextHolder.getBean(IdempotentSpELByMQExecuteHandler.class);
default -> {
}
}
return result;
}
}
- RESTAPI 防止接口重复提交场景下:Type可以为PARAM、TOKEN、SPEL
- MQ 防止消息队列重复消费场景下:Type可以为SPEL
2.2 执行幂等前置逻辑
获取到对应的幂等处理器后,我们就开始执行幂等的真实处理逻辑(原始业务方法的前置逻辑)。
/**
* 抽象幂等执行处理器
*/
public abstract class AbstractIdempotentExecuteHandler implements IdempotentExecuteHandler {
/**
* 构建幂等验证过程中所需要的参数包装器
*
* @param joinPoint AOP 方法处理
* @return 幂等参数包装器
*/
protected abstract IdempotentParamWrapper buildWrapper(ProceedingJoinPoint joinPoint);
/**
* 执行幂等处理逻辑
*
* @param joinPoint AOP 方法处理
* @param idempotent 幂等注解
*/
public void execute(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
// 模板方法模式:构建幂等参数包装器 通过各策略实现类获取 IdempotentParamWrapper 参数
IdempotentParamWrapper idempotentParamWrapper = buildWrapper(joinPoint).setIdempotent(idempotent);
handler(idempotentParamWrapper);
}
}
不同的策略实现类(PARAM、TOKEN、SPEL)都会自定义实现幂等处理逻辑,比如 buildWrapper
方法和handler
方法,这里使用了模版方法模式进行了一个封装,让具体的实现细节下沉到实现类中。
获取到 IdempotentParamWrapper
参数后调用 handler
执行具体的幂等逻辑方法。
2.3 执行幂等后置逻辑:释放幂等相关资源
idempotentHandler
方法的最后,通过 instance.postProcessing();
调用幂等释放资源方法,进行分布式锁的解锁等行为,同样是各个幂等策略实现类(各个幂等处理器)自定义,有的需要释放资源,有的不需要。不需要释放的,空实现即可。
以分布式解锁为参考:
@Override
public void postProcessing() {
RLock lock = null;
try {
lock = (RLock) IdempotentContext.getKey(LOCK);
} finally {
if (lock != null) {
lock.unlock();
}
}
}
Example:消息队列防止重新消费
3.1 Idempotent注解字段值
Scene = MQ, Type = SPEL;
3.2 订单消费者业务逻辑
用户支付成功后,回调订单消费者
/**
* 支付结果回调订单消费者
*/
@Slf4j
@Component
@RequiredArgsConstructor
@RocketMQMessageListener(
topic = OrderRocketMQConstant.PAY_GLOBAL_TOPIC_KEY,
selectorExpression = OrderRocketMQConstant.PAY_RESULT_CALLBACK_TAG_KEY,
consumerGroup = OrderRocketMQConstant.PAY_RESULT_CALLBACK_ORDER_CG_KEY
)
public class PayResultCallbackOrderConsumer implements RocketMQListener<MessageWrapper<PayResultCallbackOrderEvent>> {
private final OrderService orderService;
@Idempotent(
uniqueKeyPrefix = "index12306-order:pay_result_callback:",
key = "#message.getKeys()+'_'+#message.hashCode()",
type = IdempotentTypeEnum.SPEL,
scene = IdempotentSceneEnum.MQ,
keyTimeout = 7200L
)
@Transactional(rollbackFor = Exception.class)
@Override
public void onMessage(MessageWrapper<PayResultCallbackOrderEvent> message) {
// ......
}
}
- onMessage方法被
@Idempotent
注解修饰,通过AOP机制对onMessage方法进行增强,完成幂等判断; - key:消息的唯一标识;
- Scene = MQ, Type = SPEL;
3.3 SpEL 表达式
key里面的参数就是 SpEL 表达式,是 Spring 提供的自然语言表达式。
SpEL(Spring Expression Language)是 Spring 框架提供的一种表达式语言,用于在运行时评估表达式。它支持在 Spring 应用程序中进行动态求值和访问对象的属性、方法调用、运算符操作等。
SpEL表达式的语法类似于其他编程语言的表达式语言,具有以下特点:
- 属性访问:可以使用点号(.)来访问对象的属性,例如 person.name。
- 方法调用:可以通过在表达式中使用方法名和参数来调用对象的方法,例如 person.getName()。
- 运算符操作:支持常见的算术运算符(如加减乘除)、逻辑运算符(如与、或、非)和比较运算符(如等于、大于、小于等)。
- 条件表达式:支持条件表达式,例如三元运算符 condition ? value1 : value2。
- …
我们分步骤解析这个 SpEL 表达式最终运行结果是什么?
1. #message.getKeys()
2. +'_'+
3. #message.hashCode()
共分为三部分,第一部分,通过请求入参 message 对象,获取属性 keys 值,然后再获取 message 对象的 hashCode 值,通过 _ 的方式拼接在一起,就得到了本次请求的唯一幂等 Key。
3.4 幂等处理逻辑(原始onMessage方法的前置逻辑)
/**
* 基于 SpEL 方法验证请求幂等性,适用于 MQ 场景
*/
@RequiredArgsConstructor
public final class **IdempotentSpELByMQExecuteHandler** extends AbstractIdempotentExecuteHandler implements IdempotentSpELService {
private final DistributedCache distributedCache;
private final static int TIMEOUT = 600;
private final static String WRAPPER = "wrapper:spEL:MQ";
@SneakyThrows
@Override
protected IdempotentParamWrapper buildWrapper(ProceedingJoinPoint joinPoint) {
Idempotent idempotent = IdempotentAspect.getIdempotent(joinPoint);
// 通过执行 SpEL 表达式获取值
String key = (String) SpELUtil.parseKey(idempotent.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs());
return IdempotentParamWrapper.builder()
.lockKey(key)
.joinPoint(joinPoint)
.build();
}
@Override
public void handler(IdempotentParamWrapper wrapper) {
// 拼接前缀和 SpEL 表达式对应的 Key 生成最终放到 Redis 中的唯一标识
String uniqueKey = wrapper.getIdempotent().uniqueKeyPrefix() + wrapper.getLockKey();
// 向 Redis 触发命令,如果值不存在则存储返回 True,值存在返回 False
Boolean setIfAbsent = ((StringRedisTemplate) distributedCache.getInstance())
.opsForValue()
.setIfAbsent(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMING.getCode(), TIMEOUT, TimeUnit.SECONDS);
// **如果值为 False,那么就代表要么消息已经执行完成了或者执行中,两个不同的状态需要执行不同的逻辑**
// 为此,需要再进行判断
if (setIfAbsent != null && !setIfAbsent) {
// 获取幂等标识对应的值,判断是否为已执行成功
String consumeStatus = distributedCache.get(uniqueKey, String.class);
// 如果已经执行成功了,那么 error 为 false;执行中 error 为 true
boolean error = IdempotentMQConsumeStatusEnum.isError(consumeStatus);
LogUtil.getLog(wrapper.getJoinPoint()).warn("[{}] MQ repeated consumption, {}.", uniqueKey, error ? "Wait for the client to delay consumption" : "Status is completed");
// 将异常抛出到上层
throw new RepeatConsumptionException(error);
}
IdempotentContext.put(WRAPPER, wrapper);
}
// ......
}
- 幂等处理器实现类
IdempotentSpELByMQExecuteHandler
中自定义实现了buildWrapper
方法和handler
方法 - 获取SpEL表达式的值,也就是获取消息的唯一标识 key。接着通过构造者设计模式的方式将
IdempotentParamWrapper
对象的lockKey
字段设置为key值。// 通过执行 SpEL 表达式获取值 String key = (String) SpELUtil.parseKey(idempotent.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs()); return IdempotentParamWrapper.builder() .lockKey(key) .joinPoint(joinPoint) .build();
- handler方法中通过去重表的逻辑判断消息有没有被重复消费。如果消息已经被消费成功了,那么抛出RepeatConsumptionException(false);如果消息还在被其他的消费者线程消费中,那么抛出RepeatConsumptionException(false)。
// 拼接前缀和 SpEL 表达式对应的 Key 生成最终放到 Redis 中的唯一标识 String uniqueKey = wrapper.getIdempotent().uniqueKeyPrefix() + wrapper.getLockKey(); // 去重表:向 Redis 触发命令,如果值不存在则存储返回 True,值存在返回 False Boolean setIfAbsent = ((StringRedisTemplate) distributedCache.getInstance()) .opsForValue() .setIfAbsent(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMING.getCode(), TIMEOUT, TimeUnit.SECONDS); // 如果值为 False,那么就代表要么消息已经执行完成了或者执行中,两个不同的状态需要执行不同的逻辑 // 为此,需要再进行判断 if (setIfAbsent != null && !setIfAbsent) { // 获取幂等标识对应的值,判断消息是否为已执行成功 String consumeStatus = distributedCache.get(uniqueKey, String.class); // 如果已经执行成功了,那么 error 为 false // 如果消息还在执行中,那么 error 为 true boolean error = IdempotentMQConsumeStatusEnum.isError(consumeStatus); LogUtil.getLog(wrapper.getJoinPoint()).warn("[{}] MQ repeated consumption, {}.", uniqueKey, error ? "Wait for the client to delay consumption" : "Status is completed"); // 将异常抛出到上层(携带error一起抛出) throw new RepeatConsumptionException(error); }
- RepeatConsumptionException中true和false很重要,如果为false,那么消息就不会再进行重新投递;如果为true,那么消息会触发延迟消费,等待之后RocketMQ重新投递。
/** * 幂等注解 AOP 拦截器 */ @Aspect public final class IdempotentAspect { /** * 增强方法标记 {@link Idempotent} 注解逻辑 */ @Around("@annotation(org.opengoofy.index12306.framework.starter.idempotent.annotation.Idempotent)") public Object idempotentHandler(ProceedingJoinPoint joinPoint) throws Throwable { Idempotent idempotent = getIdempotent(joinPoint); IdempotentExecuteHandler instance = IdempotentExecuteHandlerFactory.getInstance(idempotent.scene(), idempotent.type()); Object resultObj; try { instance.execute(joinPoint, idempotent); resultObj = joinPoint.proceed(); // 执行消费者onMessege方法 instance.postProcessing(); } catch (RepeatConsumptionException ex) { /** * 触发幂等逻辑时可能有两种情况,分别对应 error 为 true 还是 false * * 1. 消息还在处理,但是不确定是否执行成功,那么需要返回错误,方便 RocketMQ 再次通过重试队列投递 * * 2. 消息处理成功了,该消息直接返回成功即可 */ if (!ex.getError()) { return null; // 返回null代表成功 } throw ex; } catch (Throwable ex) { // 客户端消费存在异常,需要删除幂等标识方便下次 RocketMQ 再次通过重试队列投递 instance.exceptionProcessing(); throw ex; } finally { IdempotentContext.clean(); } return resultObj; } public static Idempotent getIdempotent(ProceedingJoinPoint joinPoint) throws NoSuchMethodException { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); Method targetMethod = joinPoint.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes()); return targetMethod.getAnnotation(Idempotent.class); } }
- 如果前置逻辑
execute
没有出现异常,说明消息可以正常消费,调用消息消费者的 onMessage 方法(joinPoint.proceed())。 - 如果前置逻辑
execute
出现异常,不会调用消息消费者的 onMessage 方法,并且通过捕获RepeatConsumptionException
异常,获取里面 error 变量为 true 或者 false:- 如果 error 为 true,代表需要抛异常让 RocketMQ 重试。
- 如果 error 为 false,代表消息已经消费过了,不执行业务逻辑,将异常吞掉返回 RocketMQ 消费成功即可。
- 如果前置逻辑
3.5 设置消费状态为已完成
如果获取到了幂等标识,然后正常业务逻辑也执行成功了,会调用 instance.postProcessing();
将幂等标识的完成状态设置为已完成。
获取唯一标识幂等 Key,并设置幂等 Key 的状态为已完成,流程结束。
@Override
public void postProcessing() {
IdempotentParamWrapper wrapper = (IdempotentParamWrapper) IdempotentContext.getKey(WRAPPER);
if (wrapper != null) {
Idempotent idempotent = wrapper.getIdempotent();
String uniqueKey = idempotent.uniqueKeyPrefix() + wrapper.getLockKey();
try {
distributedCache.put(**uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMED.getCode()**, idempotent.keyTimeout(), TimeUnit.SECONDS);
} catch (Throwable ex) {
LogUtil.getLog(wrapper.getJoinPoint()).error("[{}] Failed to set MQ anti-heavy token.", uniqueKey);
}
}
}
3.6 能保障永久幂等么?
根据postProcessing方法可以看出,消息的唯一标识uniqueKey存在过期时间。
distributedCache.put(**uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMED.getCode()**, idempotent.keyTimeout(), TimeUnit.SECONDS);
所以不能保障永久幂等,因为 Redis 内存资源比较珍贵,如果长时间保存幂等 Key,会造成 Redis 内存占用增加。