RocketMQ消息幂等控制:借助ConcurrentHashMap的putIfAbsent方法实现

在Spring Boot中,利用ConcurrentHashMapputIfAbsent方法并结合AOP是实现RocketMQ消费端幂等性的一种简洁有效的方式。其核心思想是在处理消息前,先检查该消息的唯一标识是否已被处理过

下面的表格和代码将为你展示一个完整的实现方案。

实现方案概览

组件/步骤 核心职责 关键实现点
幂等性注解 (@Idempotent)​ 标记需要幂等处理的方法。 自定义一个注解。
消息唯一键 (Message Key)​ 消息的唯一业务标识。 使用RocketMQ消息的key(如订单号),而非msgId
幂等切面 (IdempotentAspect)​ 核心逻辑:拦截被@Idempotent标记的方法。 1. ​前置检查 ​:用ConcurrentHashMap.putIfAbsent(key, value)原子性地判断消息是否已处理。 2. ​后置处理​:若业务执行失败,从Map中移除key,允许重试。
ConcurrentHashMap 作为内存去重缓存。 存储消息key和处理状态(或结果)。适用于单机或开发环境,集群部署需换为Redis等分布式缓存。

代码实现步骤

1. 定义幂等性注解

创建一个自定义注解,用于标记需要实现幂等性的消息监听方法。

java 复制代码
import java.lang.annotation.*;

@Target(ElementType.METHOD) // 该注解可以作用在方法上
@Retention(RetentionPolicy.RUNTIME) // 注解在运行时有效
public @interface Idempotent {
    /**
     * 幂等key的前缀,可选,用于区分不同业务
     */
    String prefix() default "";
}

2. 实现幂等性切面

这是最核心的部分,它负责拦截被@Idempotent注解的方法,并完成幂等性检查。

java 复制代码
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;

@Aspect
@Component
public class IdempotentAspect {

    // 使用ConcurrentHashMap作为内存去重缓存
    // Key: 业务唯一标识 (例如: "order_123456")
    // Value: 可以存储处理结果(Object)或一个标记(如Boolean.TRUE),这里用Object存储结果
    private final ConcurrentHashMap<String, Object> processedMessages = new ConcurrentHashMap<>();

    @Around("@annotation(idempotent)") // 环绕通知,拦截带有@Idempotent注解的方法
    public Object checkIdempotent(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {

        // 1. 获取方法参数,通常第一个参数就是MessageExt或消息体
        Object[] args = joinPoint.getArgs();
        if (args == null || args.length == 0) {
            // 如果没有参数,直接执行原方法
            return joinPoint.proceed();
        }

        // 2. 从消息中提取唯一业务键(例如,消息的Key)
        // 这里假设监听器方法的第一个参数是MessageExt类型
        org.apache.rocketmq.common.message.MessageExt messageExt = (org.apache.rocketmq.common.message.MessageExt) args[0];
        String messageKey = messageExt.getKeys(); // 这是生产者设置的业务唯一标识,如订单号

        // 构建最终的缓存键,可以加上前缀防止不同业务冲突
        String cacheKey = idempotent.prefix() + messageKey;

        // 3. 核心:原子性检查 - 如果key不存在则放入,并返回null;如果已存在则返回当前值。
        Object existingResult = processedMessages.putIfAbsent(cacheKey, Boolean.TRUE);

        // 4. 判断是否已处理过
        if (existingResult != null) {
            // 消息已处理过,直接返回上次的结果(或抛出异常,或根据业务逻辑处理)
            // 这里示例是返回一个已处理的提示,实际应根据你的方法返回值类型调整
            System.out.println("消息已被消费,幂等处理,key: " + cacheKey);
            // 如果缓存里存的是业务结果,就返回existingResult。如果只存了标记,可以自定义返回逻辑。
            return "消息已被消费,无需重复处理"; // 示例返回,请根据实际业务修改
        }

        // 5. 如果是新消息,执行业务逻辑
        try {
            Object result = joinPoint.proceed(); // 执行真正的消费业务方法
            // 可选:将成功的结果也缓存起来,下次直接返回。注意处理异常情况。
            // processedMessages.put(cacheKey, result);
            return result;
        } catch (Throwable throwable) {
            // 业务执行失败,必须移除key,允许消息重试时能再次执行
            processedMessages.remove(cacheKey);
            throw throwable; // 抛出异常,让RocketMQ知道消费失败,触发重试
        }
        // 注意:如果业务非常关键,可以考虑增加缓存过期策略,防止Map无限膨胀。
    }
}

3. 在消息监听器中使用注解

在RocketMQ的消息监听器方法上添加@Idempotent注解。

typescript 复制代码
import org.apache.rocketmq.spring.annotation.ConsumeMode;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.stereotype.Service;

@Service
@RocketMQMessageListener(
        topic = "your_topic",
        consumerGroup = "your_consumer_group",
        consumeMode = ConsumeMode.CONCURRENTLY // 或ORDERLY
)
public class YourMessageListener implements RocketMQListener<MessageExt> {

    @Override
    @Idempotent(prefix = "ORDER_") // 使用自定义的幂等注解
    public void onMessage(MessageExt message) {
        // 1. 切面会在此方法执行前进行拦截和幂等检查
        // 2. 如果能执行到这里,说明这条消息是第一次处理

        String messageBody = new String(message.getBody());
        String orderId = message.getKeys(); // 获取业务唯一标识
        System.out.println("处理业务消息,订单ID: " + orderId + ", 消息体: " + messageBody);

        // ... 你的业务逻辑,例如更新订单状态、扣减库存等
        // 如果这里的业务逻辑执行失败抛出异常,切面会捕获并移除key,消息会重试。
    }
}

⚠️ 重要注意事项

  1. 缓存选择与局限性​:

    • **ConcurrentHashMap​ 的方案仅适用于单实例部署的Spring Boot应用**。如果你的消费者是多个实例的集群部署,由于每个实例有自己独立的JVM和Map,无法共享去重状态,此方案会失效。
    • 生产环境推荐 :在集群环境下,必须使用分布式缓存(如 Redis )替代ConcurrentHashMap。可以使用Redis的SET key value NX(等同于setIfAbsent)命令来实现相同的原子性去重逻辑。
  2. 内存管理​:

    • 内存中的ConcurrentHashMap会不断增长,需要防止内存溢出。可以考虑为缓存设置一个合理的过期时间或使用有界Map(如Guava Cache)。
  3. 业务键的可靠性​:

    • 确保从消息中提取的业务键(如messageKey)是全局唯一的,并且由消息生产者可靠地设置。这是幂等性判断的基石。

这个方案通过在方法调用前进行原子性的"检查-标记"操作,有效解决了并发下的重复消费问题。你可以根据实际部署环境和业务需求,调整缓存的具体实现。

相关推荐
百***864635 分钟前
springboot整合libreoffice(两种方式,使用本地和远程的libreoffice);docker中同时部署应用和libreoffice
spring boot·后端·docker
MZ_ZXD00142 分钟前
springboot流浪动物救助平台-计算机毕业设计源码08780
java·spring boot·后端·python·spring·flask·课程设计
没有bug.的程序员44 分钟前
Spring 全家桶在大型项目的最佳实践总结
java·开发语言·spring boot·分布式·后端·spring
掘金码甲哥1 小时前
🎨 新来的外包,在大群分享了它的限流算法的实现
后端
在坚持一下我可没意见1 小时前
Spring IoC 入门详解:Bean 注册、注解使用与 @ComponentScan 配置
java·开发语言·后端·spring·rpc·java-ee
用户21411832636021 小时前
Claude Skills实战指南:Skill Seekers 自动生成 SiliconFlow API 技能
后端
b***9101 小时前
【SpringBoot3】Spring Boot 3.0 集成 Mybatis Plus
android·前端·后端·mybatis
leonardee1 小时前
Android和JAVA面试题相关资料
java·后端
w***4242 小时前
Spring Boot 条件注解:@ConditionalOnProperty 完全解析
java·spring boot·后端
q***73552 小时前
删除文件夹,被提示“需要来自 TrustedInstaller 的权限。。。”的解决方案
android·前端·后端