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)是全局唯一的,并且由消息生产者可靠地设置。这是幂等性判断的基石。

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

相关推荐
why技术4 小时前
1K+Star的开源项目能给一个在校大学生带来什么?
前端·人工智能·后端
克莱恩~莫雷蒂4 小时前
Spring Boot 中 controller层注解
java·spring boot·后端
追逐时光者5 小时前
分享 4 款基于 .NET 开源免费、实用的文件搜索工具,效率提升利器!
后端·.net
程序新视界5 小时前
什么是OLTP ,MySQL是如何支持OLTP的?
数据库·后端·mysql
songroom5 小时前
dbpystream webapi: 一次clickhouse数据从系统盘迁至数据盘的尝试
后端·clickhouse·阿里云
Lisonseekpan6 小时前
为什么要避免使用 `SELECT *`?
java·数据库·后端·sql·mysql·oracle
lecepin7 小时前
AI Coding 资讯 2025-10-29
前端·后端·面试
Tony Bai7 小时前
【Go模块构建与依赖管理】01 前世今生:从 GOPATH 的“混乱”到 Go Modules 的“秩序”
开发语言·后端·golang
Python私教7 小时前
从零构建 Swing 计算器:深入理解 Java GUI 开发核心机制
后端