RocketMQ消息幂等控制:借助Redis实现

在Spring Boot项目中,使用Redis为RocketMQ消费端实现幂等性是一种高效且可靠的方式。下面我将为您提供一个清晰的方案和代码实现。

消息重复消费的原因

首先,了解消息为何会重复至关重要,这有助于我们设计更有针对性的解决方案。主要原因包括 :

  • 生产者重发:发送消息后未及时收到Broker的确认响应(如网络闪断),生产者会尝试重发。
  • 消费者重试:消费者处理消息时发生异常或超时,RocketMQ的重试机制会再次投递同一条消息。
  • 消费进度提交延迟:消费者消费成功后,其偏移量(Offset)是定期提交给Broker的。在提交前若消费者重启,会从上一个偏移量重新消费。

理解了这些原因,我们就可以在消费端构建防线了。下面的表格概述了基于Redis的幂等性核心实现方案。

核心组件/步骤 职责描述 关键实现点
幂等性注解 (@Idempotent)​ 标记需要幂等处理的消息监听方法。 自定义一个注解,可设置Redis键的过期时间。
唯一业务标识 用于判断消息是否重复的关键。 建议使用消息的业务键(如订单号),而非RocketMQ的msgId
Redis幂等键 在Redis中存储已处理消息的标识。 键的组成:业务前缀 + 唯一业务标识。值:可存储状态或固定标记。
AOP切面 (IdempotentAspect)​ 核心逻辑:拦截被@Idempotent标记的方法。 1. ​前置检查 ​:使用Redis的setIfAbsent方法原子性地尝试添加标识。 2. ​后置处理​:若业务执行失败,可选择删除Key,允许消息重试。

代码实现步骤

1. 添加项目依赖

确保您的pom.xml文件中包含了Spring Data Redis的依赖。

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. 配置Redis连接

application.ymlapplication.properties中配置Redis连接信息。

yaml 复制代码
spring:
  redis:
    host: localhost
    port: 6379
    # password: your-password-if-any
    database: 0

3. 定义幂等性注解

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

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

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 幂等键的前缀,用于区分不同业务
     */
    String prefix() default "idempotent";

    /**
     * Redis键的过期时间,默认5分钟
     */
    long expire() default 300L;
}

4. 实现幂等性切面(AOP)

这是最核心的部分,负责实际的幂等性检查逻辑。

kotlin 复制代码
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.common.message.MessageExt;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
@Slf4j
public class IdempotentAspect {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 环绕通知,拦截带有@Idempotent注解的方法
     */
    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        // 1. 获取方法参数,这里假设第一个参数是MessageExt类型
        Object[] args = joinPoint.getArgs();
        MessageExt messageExt = null;
        for (Object arg : args) {
            if (arg instanceof MessageExt) {
                messageExt = (MessageExt) arg;
                break;
            }
        }
        if (messageExt == null) {
            // 如果没有MessageExt参数,直接执行原方法
            return joinPoint.proceed();
        }

        // 2. 提取消息的唯一业务标识(例如消息的Key,通常由生产者设置,如订单号)
        String messageKey = messageExt.getKeys();
        if (StringUtils.isBlank(messageKey)) {
            log.warn("消息的Key为空,无法进行幂等校验,将正常处理消息。MsgId: {}", messageExt.getMsgId());
            return joinPoint.proceed();
        }

        // 3. 构建Redis中的幂等键
        String redisKey = idempotent.prefix() + ":" + messageKey;

        // 4. 核心:原子性校验 - 使用setIfAbsent (SET key value NX EX)
        // 如果key不存在则设置成功并返回true,表示是第一次处理。
        // 如果key已存在则设置失败并返回false,表示是重复消息。
        Boolean isFirstConsume = stringRedisTemplate.opsForValue().setIfAbsent(
                redisKey,
                "PROCESSED", // 值可以简单设置一个标记
                idempotent.expire(),
                TimeUnit.SECONDS
        );

        if (Boolean.FALSE.equals(isFirstConsume)) {
            // 消息已被消费过,进行幂等处理
            log.info("消息已被消费,幂等处理。业务键: {}", messageKey);
            // 根据你的业务需求,这里可以直接返回成功,或者抛出特定异常
            // 例如,如果监听器方法有返回值,可以返回一个已消费的状态
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; // 注意:需根据实际监听器返回类型调整
        }

        // 5. 如果是新消息,执行业务逻辑
        try {
            Object result = joinPoint.proceed(); // 执行真正的消息消费业务逻辑
            // 业务执行成功,Key会保留在Redis中直到过期
            return result;
        } catch (Exception e) {
            // 业务执行失败!可以选择删除Key,允许消息重试时再次处理
            // 注意:这需要根据异常类型谨慎判断,只有确定需要重试的业务异常才删除Key
            stringRedisTemplate.delete(redisKey);
            log.error("处理消息发生异常,已清除幂等锁,允许重试。业务键: {}", messageKey, e);
            throw e; // 重新抛出异常,让RocketMQ知道消费失败,触发重试机制
        }
    }
}

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

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

typescript 复制代码
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.common.message.MessageExt;

@Component
@RocketMQMessageListener(topic = "YOUR_TOPIC", consumerGroup = "YOUR_CONSUMER_GROUP")
@Slf4j
public class YourRocketMQListener implements RocketMQListener<MessageExt> {

    @Override
    @Idempotent(prefix = "order", expire = 600) // 为订单业务设置10分钟的幂等窗口
    public void onMessage(MessageExt message) {
        // 切面会在此方法执行前进行拦截和幂等检查
        // 如果能执行到这里,说明这条消息是第一次处理

        String messageBody = new String(message.getBody());
        String orderId = message.getKeys(); // 获取业务唯一标识,例如订单号
        log.info("开始处理订单消息,订单ID: {}, 消息体: {}", orderId, messageBody);

        // ... 你的核心业务逻辑,例如创建订单、更新库存等
        // 这里的逻辑需要保证自身是幂等的,例如使用数据库唯一约束、乐观锁等作为最终防线。

        log.info("订单处理完成。订单ID: {}", orderId);
    }
}

关键注意事项

  1. 业务键而非消息ID :务必使用消息的业务唯一标识(message.getKeys())​ 作为去重依据,而不是RocketMQ自动生成的msgId。因为生产者重发时,同一条业务的两次发送会产生不同的msgId,但Keys应该是相同的 。
  2. 异常处理策略 :在切面的catch块中删除Redis键是一种策略,但需谨慎。应确保只有在业务处理失败且允许消息重试时才删除键。对于一些不可重试的异常(如业务逻辑错误),则不应删除键。
  3. Redis键的过期时间 :设置合理的过期时间(expire)。它应该长于消息的最大可能处理时间,但也不能过长导致Redis内存浪费。过期机制可以自动清理旧数据,避免存储无限增长。
  4. 集群环境 :与ConcurrentHashMap不同,Redis是中心化存储,因此此方案天然支持分布式消费者集群,多个实例会共享同一个Redis进行幂等判断。
  5. 最终一致性 :此方案是"消费前校验",对于极端的并发场景(如两条相同消息同时到达),Redis的setIfAbsent的原子性可以保证只有一条成功。但最坚实的保障仍应是业务逻辑本身的幂等性(如数据库唯一索引、乐观锁更新),Redis方案是高效的第一道屏障 。
相关推荐
间彧4 小时前
RocketMQ消息幂等控制:借助数据库唯一约束实现
后端
霸道流氓气质4 小时前
SpringBoot+MybatisPlus+自定义注解+切面实现水平数据隔离功能(附代码下载)
java·spring boot·后端
间彧4 小时前
RocketMQ消息幂等控制:借助ConcurrentHashMap的putIfAbsent方法实现
后端
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