消息队列如何保证幂等—AOP【代码篇】

准备知识: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一个注解上,注解里面各个字段的含义如下图所示:
在这里插入图片描述

重要的字段有两个,scenetype

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表达式的语法类似于其他编程语言的表达式语言,具有以下特点:

  1. 属性访问:可以使用点号(.)来访问对象的属性,例如 person.name。
  2. 方法调用:可以通过在表达式中使用方法名和参数来调用对象的方法,例如 person.getName()。
  3. 运算符操作:支持常见的算术运算符(如加减乘除)、逻辑运算符(如与、或、非)和比较运算符(如等于、大于、小于等)。
  4. 条件表达式:支持条件表达式,例如三元运算符 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);
        }
    }
    
    
    1. 如果前置逻辑execute没有出现异常,说明消息可以正常消费,调用消息消费者的 onMessage 方法(joinPoint.proceed())
    2. 如果前置逻辑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 内存占用增加。

  • 12
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值