Java:基于注解实现去重表消息防止重复消费

当前灵感、部分资源来自于知识星球 :拿个offer

如果侵权请联系删除

业务背景

当使用消息队列时,客户端重复消费可能会造成严重问题

因为消息队列具有持久性和可靠性的特性,确保消息能够倍成功传递给消费者。但是这也会导致客户端在一些情况下重复消费消息,EX:

  1. 网络故障/延迟(例如:导致ack对视,mq没有收到ack,认为消费者挂了或者消息处理失败,过一段时间重新发送消息)

  2. 客户端崩溃(例如:执行完了业务逻辑,在执行return 或者提交ack处理前的一瞬间,客户端崩溃了,同样是没有收到ack)

  3. 消息处理失败(例如:onMessage方法上面没有加上事务,代码执行出现异常,数据库不会回滚(脏数据保留)同时消息队列触发重试,两者结合导致严重的重复处理问题)

为了避免上述情况发生,需要在客户端实现一些机制保证消息不会被重复消费:

EX:

  1. 记录消费者已经处理的消息ID

  2. 使用分布式锁控制消费进程的唯一性

这些机制保证了消息能够被陈宫处理,同时也能够提高系统的可靠性、稳定性。

消息幂等性

消息队列当中RocketMQ实现异步、削峰填谷、解耦等功能的情况下,我们认为消息中间件是一个可靠的组件。

这里可靠性指的是:只要消息被成功投递到了消息中间件,就不会丢失,至少会被消费者消费一次。这是消息中间件的最基本特性之一,也就是我们常说的"AT LEAST ONCE",也就是消息至少被成功消费一次。

然而,这种可靠性特性也会导致消息被多次投递的情况。举个例子,仍然以之前的例子为例,如果消费程序 A 接收并完成消息 M 的消费逻辑后,正准备通知消息中间件"我已经消费成功了",但在此之前程序A又重启了,那么对于消息中间件来说,这个消息 M 并没有被成功消费过,因此消息中间件会继续投递这个消息。而对于消费程序A来说,尽管它已经成功消费了这个消息,但由于程序重启导致消息中间件继续投递,看起来就好像这个消息还没有被成功消费过一样。

在 RockectMQ 的场景中,这意味着同一个 messageId 的消息会被重复投递。由于消息的可靠投递是更重要的,所以避免消息重复投递的任务转移给了应用程序自身来实现。这也是 RocketMQ 文档强调消费逻辑需要自行实现幂等性的原因。实际上,这背后的逻辑是:在分布式场景下,保证消息不丢和避免消息重复投递是矛盾的,但是消息重复投递是可以解决的,而消息丢失则非常麻烦。

幂等设计

当前方案的优点在于:使用了redis消息去重表,不依赖事务,针对消息表本身做了状态的区分:消费中,消费完成

  1. 消息已经在消费中,抛出异常,消息触发延迟消费

  2. rocketmq场景下消费失败,间隔时间后再次发起消费流程

通过当前方案可以解决什么问题:

  1. 消息已经消费成功,第二条重复的消息将被直接幂等处理掉

  2. 并发场景下,依旧能满足不出现消息重复,即穿透幂等挡板的问题

  3. 支持上游业务生产者重发的重复业务 消息幂等问题

为什么要给初始化的幂等标识新增10分钟过期时间?

并发场景下,我们使用消息状态实现并发控制,让第二条消息不断被延迟消费(重试)。但是如果在此期间第一条消息也因为一些异常(机器重启/外部异常)导致未被成功消费,如何解决?因为每次查询时都会显示 "消费中" 的状态,延迟消费/重试会一直进行下去,直至最终被视为消费失败并被投递到死信Topic当中。(RocketMQ当中消息最多被消费16次)

解决方案:

插入消息表时,必须为每条消息设置一个最畅销费时间,EX:10min。这意味着,如果某个消息在消费过程当中执行时间超过了10分钟,被视为消费失败并从消息表中进行删除

抽象通用幂等组件

在我们的消息消费当中,防重复消费幂等组件是通用的

将通用的防止幂等组件同样在framework模块当中开发

1.自定义幂等注解
java 复制代码
/**
 * 幂等注解:防止雄安锡独立额消费者重复消费消息
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoMQDuplicateConsume {
    /**
     * 设置防重令牌Key前缀
     */
    String keyPrefix() default "";

    /**
     * 通过SpEL表岛是生成的唯一key
     */
    String key();

    /**
     *设置防重令牌Key过期时间,单位为秒,默认1小时
     */
    long keyTimeout()default 3600L;

}
详细解释Retention注解以及参数作用
核心作用

指定该自定义注解在运行时的生命周期范围

也就是:该注解在编译之后是否保留、能否被JVM读取、能否通过反射获取

写上RUNTIME表示:

注解不仅会存在于源码、字节码当中,还会再运行时保留,JVM允许通过反射读取到它

这就是为什么aop切面、拦截器、spring等框架能够通过反射判断方法上是否有你的注解

三种参数取值以及区别

|-----------|-----------------------|------------|------------------------------------|
| 枚举值 | 中文含义 | JVM/反射可否读取 | 典型使用场景 |
| SOURCE | 只保留在源码中,编译后丢弃 | 不可读取 | Lombok、@Override、@SuppressWarnings |
| CLASS(默认) | 源码 → class 文件,但运行时不可见 | 不可读取 | 一般很少自定义使用,编译器/框架内部 |
| RUNTIME | 源码 → class → 运行时都存在 | 可读取(可反射) | Spring 注解、AOP 注解、校验注解 |

为什么幂等注解上面必须使用@Retention(RetentionPolicy.RUNTIME)

当前我们的注解使用类似:

java 复制代码
@NoDuplicateSubmit
public void saveOrder() {}

SpringAOP通过反射判断方法是否包含该注解:(我们之前后管平台的防幂等注解aop切面就用到了这个方法)

java 复制代码
method.getAnnotation(NoDuplicateSubmit.class);

如果你的注解不是 RUNTIME:

  • SOURCE → 编译后信息消失,AOP 永远检测不到

  • CLASS → class 文件有,但 JVM 运行时不加载注解信息

  • 只有 RUNTIME → JVM 才能读取注解

所以:

不使用 RUNTIME,你的幂等注解就无法在切面中生效。

2.SpEL表达式解析工具类
java 复制代码
package com.ycy.onecoupon.framework.idempotent.utils;

import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.util.ArrayUtil;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

import java.lang.reflect.Method;
import java.util.List;
import java.util.Optional;

/**
 * SpEL表达式解析工具
 * 用于将字符串形式的SpEL解析为实际值,常用于缓存Key、幂等Key等场景
 */
public final class SpELUtil {

    /**
     * 校验并返回实际使用的SpEL表达式
     * 如果过SpEL包含SpEL标识符,调用parse方法解析实际值并返回
     * 否则直接返回原始字符串
     *
     * @param spEL SpEL表达式
     * @param method 方法
     * @param contextObj 上下文对象
     * @return 实际使用的SpEL表达式
     */
    public static Object parseKey(String spEL, Method method,Object[] contextObj){
        //定义需要识别的SpEL标记符(#:变量引用:T(java.lang.Math),"T(" :类型引用开头)
        List<String> spELFlag = ListUtil.of("#", "T(");

        //Optional:处理null出现的空指针
        //findFirst可能找到匹配的标识符------非空
        //          找不到------null空指针,异常

        Optional<String> optional = spELFlag.stream().
                filter(spEL::contains).//对每个元素检查SpEL表达式是否包含它
                findFirst();//拿到第一个标识符

        if(optional.isPresent())//optional当中是否有值
            return parse(spEL,method,contextObj);
        //不包含SpEL标记符,原样返回
        return spEL;

    }

    /**
     * 解析SpEL表达式并返回表达式执行结果
     * @param spEL SpEL表达式
     * @param contextObj 上下文对象
     * @return 解析的字符串值
     */
    public static Object parse(String spEL, Method method ,Object[]contextObj){
        //默认参数名解析工具类,通过反射和字节码信息解析方法参数名
        DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
        //SpEL语句解析类
        SpelExpressionParser parser = new SpelExpressionParser();
        //得到SpEL对应的表达式对象
        Expression exp = parser.parseExpression(spEL);
        //得到当前方法参数名
        String[] params = discoverer.getParameterNames(method);
        //SpEL执行所需的上下文,用于存储表达式可访问的变量
        StandardEvaluationContext context = new StandardEvaluationContext();
        //参数不为空
        if(ArrayUtil.isNotEmpty(params)){
            //将对象存到上下文当中(将方法参数名和对应的实参参数绑定为SpEl上下文变量,例如context.setVariable("id", 1001))
            for(int len=0;len<params.length;len++){
                context.setVariable(params[len],contextObj[len]);
            }
        }
        //返回SpEL表达式解析执行出来的对象值
        return exp.getValue(context);
    }
}

代码思路:

  1. 识别当前字符串是否是SpEL语句:包含 # 、T( 作为前缀

  2. 如果不是SpEL字符串,直接返回

  3. 是SpEL字符串,进行执行:

    1. 声明参数名解析类、SpEL解析类、表达式类

    2. 通过参数名解析类解析当前方法得到参数名称

    3. 声明上下文用于存储当前能够得到的所有参数以及对应的实参,用于后续SpEL解析

    4. 解析所有参数,对应其实参一起存入上下文

    5. 表达式对象调用上下文调用对应参数执行,返回结果

(如果需要给SpEL提供参数的话需要使用上下文(当前我遇到的情况都是的))

3.状态枚举类
java 复制代码
/**
 * MQ消费幂等状态枚举
 */
@Getter
@RequiredArgsConstructor
public enum IdempotentMQConsumeStatusEnum {
    /**
     *消费中
     */
    CONSUMING("0"),
    /**
     * 已经消费
     */
    CONSUMED("1");


    private final String code;

    /**
     * 消费状态等于消费中,返回失败
     * @param consumeStatus 消费状态
     * @return 是否消费失败
     */
    public static boolean isError(String consumeStatus){
        return ObjectUtil.equals(CONSUMING.code,consumeStatus);
    }
}
4.自定义切面控制器
java 复制代码
/**
 * 防止消息队列消费者重复消费消息切面控制器
 */
@Slf4j
@Aspect
@RequiredArgsConstructor
public class NoMQDuplicateConsumeAspect {

    private final StringRedisTemplate stringRedisTemplate;

    //java文本块,使用""""定义多行字符串而不需要手动\n,手动转义引号,手动拼接字符串,增强文本可读性
    //好处:较短的lua脚本无需写在resources当中、无需加载脚本,直接调用
    //缺点:语法错误难以检查
    private static final String LUA_SCRIPT = """
            local key= KEYS[1]
            local value=ARGV[1]
            local expire_time_ms= ARGV[2]
            return redis.call('SET',key,value,'NX','GET','PX',expire_time_ms)
            """;
    /**
     * 脚本解析:
     * set key value :写入键值对
     * nx :仅当key不存在时执行写入
     * get:redis 6.2新增的选项,作用:返回key的旧值(set之前的)
     * key不存在:返回nil key存在:返回旧值(字符串)
     * px:设置过期时间,单位:毫秒    (ex:设置过期时间为秒)
     */
    @Around("@annotation(com.ycy.onecoupon.framework.idempotent.annotations.NoMQDuplicateConsume)")
    public Object noMQRepeatConsume(ProceedingJoinPoint joinPoint)throws  Throwable{
        NoMQDuplicateConsume noMQDuplicateConsume = getNoMQDuplicateConsumeAnnotation(joinPoint);
        String uniqueKey = noMQDuplicateConsume.keyPrefix() + SpELUtil.parseKey(noMQDuplicateConsume.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs());
        String absentAndGet = stringRedisTemplate.execute(
                RedisScript.of(LUA_SCRIPT, String.class),
                List.of(uniqueKey),
                IdempotentMQConsumeStatusEnum.CONSUMING.getCode(),
                String.valueOf(TimeUnit.SECONDS.toMillis(noMQDuplicateConsume.keyTimeout()))
        );

        //返回值不为空,证明已经有
        if (ObjectUtil.isNotEmpty(absentAndGet)) {
            //检查状态是不是正在执行
            boolean errorFlag = IdempotentMQConsumeStatusEnum.isError(absentAndGet);
            //可以肯定的是当前消息先前一定是存在的,但是有两种情况:
            // 1.当前相同消息在执行 ,抛出异常原因:防止当前执行出现异常(预防)
            // 2.相同消息执行完毕(执行完毕后续无需再次执行,直接结束即可,无需重试
            log.warn("[{}] MQ repeated consumption, {}.", uniqueKey, errorFlag ? "Wait for the client to delay consumption" : "Status is completed");
            if(errorFlag){
                throw new ServiceException(String.format("消息消费幂等异常,幂等标识:%s",uniqueKey));
            }
            return null;
        }

        //正常执行
        Object result;
        try{
            //执行注解标记方法原逻辑
            result = joinPoint.proceed();

            //防重复key,调整状态为消费完成,设置新的过期时间.过期时间内防止重复执行
            stringRedisTemplate.opsForValue().set(uniqueKey,IdempotentMQConsumeStatusEnum.CONSUMED.getCode(),noMQDuplicateConsume.keyTimeout(),TimeUnit.SECONDS);

        } catch (Throwable ex) {
            //删除幂等key,让消息队列的重试能够正常执行
            stringRedisTemplate.delete(uniqueKey);
            //抛出异常,让消息队列重试(正常情况)
            // 能执行这里代码是正常执行,这和上面的抛出异常是两种情况,不会同时被触发
            throw ex;
        }
        return result;
    }


    public static NoMQDuplicateConsume getNoMQDuplicateConsumeAnnotation(ProceedingJoinPoint joinPoint)throws NoSuchMethodException{
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        //第二个参数:拿到对应的方法签名,拿到被代理对象的原始方法,之后拿到jvm反射层面实际定义的方法参数类型
        //直接使用signature去拿参数类型是aop对方法签名的解析结果可能和上面出现差异
        Method targetMethod = joinPoint.getTarget().getClass().getMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
        return targetMethod.getAnnotation(NoMQDuplicateConsume.class);
    }

}

在消息消费端实现幂等控制时,当前方案不再复用之前"防前端重复提交" 所采用的Redisson分布式锁模式,而是构建一套基于Redis的幂等状态表。本方案贴合消息队列处理特性,对消息在不同的生命周期进行更加细化的管理。

为什么使用幂等状态表

常规分布式锁仅有"被占用"、"未占用"两种不同状态。而我们的幂等状态表允许我们自定义更加丰富、适配业务的转台,例如我们当前方案当中的"正在消费"、"消费完成"等。对于MQ场景,这些状态是必要的:

  1. 消费过程中可能失败,需要告知 MQ 进行重试。

  2. 消费完成后同一条消息无需再被执行,应直接跳过。

分布式锁在MQ场景当中有着天然的限制:

  1. 锁状态无法表达MQ需要的多种状态

  2. 锁续期适合长耗时业务,但是对于短流程的MQ消费并不必要

下面介绍我们当前切面处理类对幂等状态表的处理逻辑

两种消费状态处理策略

两种核心状态:

  1. 正在执行

  2. 执行完成

执行完毕

若状态表中已记录该消息为"执行完成",说明业务逻辑已经成功处理过该消息。此时重新收到相同消息时,可以直接跳过,不再重复执行对应方法。

这避免了重复消费,也避免了业务侧产生重复数据。

执行当中

相较于执行完毕状态,我们需要进一步判断:

  • 当前任务正在执行,但是一定成功吗?

不一定。消费逻辑可能出现异常、业务问题

应对策略:

  1. 当前消息和正在执行的消息冲突,aop主动抛出异常,提示MQ进行后续重试当前消息执行,避免多个消费者并发处理内容相同消息。下次重试时:

    1. 消费完成,return

    2. 之前相同消息失败,删除了key,当前消息能够执行

  2. 当前执行确实发生异常时,删除当前幂等key,释放执行资格,抛出异常,MQ触发后续重试。

两个抛出异常是否冲突?

不冲突

  1. 第一个抛出异常是为了防止影响当前同任务执行+同任务的兜底 延迟重试;作用场景:并发重复冲突

  2. 第二个抛出异常则是当前执行异常,正常重试;作用场景:消费失败场景

即使出现了当前消息重新尝试+后续相同消息,也会再次被当前aop拦截、处理,无需担心重复执行两遍

为什么使用SpEL构建幂等表key?

MQ 幂等的本质是:

为每条消息生成一个唯一业务标识(幂等 key),用来判断消息是否已被消费。

不同消息的 couponTaskId、userId、orderId、messageId 都可能不同,所以幂等 key 必须能够从方法参数中动态提取业务字段。

为什么注释上不直接编写java代码

注解的参数要求:

  • 只能是编译期常量

  • 不能执行代码

  • 不能引用对象属性

无法在注释代码当中写:

java 复制代码
@NoMQDuplicateConsume(key = msg.couponTaskId)
使用SpEL

使用SpEL能够满足我们的需求

通过aop环绕拿到当前方法的joinPoint,解析SpEL

java 复制代码
SpELUtil.parseKey(noMQDuplicateConsume.key(), method, joinPoint.getArgs());

基于如下机制:

  • AOP 拦截方法时能拿到方法签名与参数值

  • SpEL 引擎能基于参数名、方法、参数值解析表达式

优点:

  • 支持任意字段、多层级对象和方法的调用

  • 支持表达式拼接,灵活度更高

  • 运行时动态解析,适配aop和注解机制,适配当前业务

  • 不侵入业务

  • 通用性、可复用性强

5.幂等配置类添加配置
java 复制代码
/**
 * 幂等组件相关配置类
 */
//虽然没有添加@Configuration注解但是因为使用了meta-inf.spring包下面的自动配置文件,bean方法能够生效
public class IdempotentConfiguration {
    /**
     * 防止用户重复提交表单信息切面控制器
     */
    //模块当中应入依赖,提供有效的redis配置,触发自动装配
    @Bean
    public NoDuplicateSubmitAspect noDuplicateSubmitAspect(RedissonClient redissonClient){
        return new NoDuplicateSubmitAspect(redissonClient);
    }

    /**
     * 防止消息队列消费者重复消费消息切面控制器
     */
    @Bean
    public NoMQDuplicateConsumeAspect noMQDuplicateConsumeAspect(StringRedisTemplate stringRedisTemplate) {
        return new NoMQDuplicateConsumeAspect(stringRedisTemplate);
    }
}

为什么需要:

因为幂等注解aop控制器当中需要用到RedissonClient、StringRedisTemplate这些基础组件

但是作为framework模块,不可能、没必要添加pom文件用于配置

通过自动装配机制提供迷瞪组件配置类

  • 业务模块存在对应的Redis Bean前提下自动注入

  • 模块保持可移植性、独立性

  • 简化框架依赖管理

相关推荐
hunter1990101 小时前
redisson分布式锁实践总结
分布式
没有bug.的程序员1 小时前
大规模微服务下的 JVM 调优实战指南
java·jvm·spring·wpf·延迟
哈哈老师啊1 小时前
Springboot学生选课系统576i3(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
数据库·spring boot·后端
北友舰长1 小时前
基于Springboot+vue大型商场应急预案管理系统的设计与实现【Java毕业设计·安装调试·代码讲解·文档报告】
java·vue.js·spring boot·mysql·商场·应急处理·应急
赵庆明老师1 小时前
在ASP.NET Core Web Api中添加身份验证和授权
java·前端·asp.net
菜鸟小九1 小时前
redis基础(java客户端)
java·redis·bootstrap
苦学编程的谢1 小时前
RabbitMQ_6_高级特性(3)
分布式·rabbitmq
七宝大爷1 小时前
第一个CUDA程序:从向量加法开始
android·java·开发语言
__万波__1 小时前
二十三种设计模式(十一)--享元模式
java·设计模式·享元模式