rabbitMq消息队列如何防止重复消费

6 阅读3分钟

RabbitMQ 防止重复消费需要从 生产者幂等、消息去重、消费者幂等 三层防护,核心在于业务幂等设计而非依赖 MQ 本身。


一、重复消费的根源

表格

环节场景结果
生产者重试网络超时未收到 ACK,重复发送消息内容相同,ID 不同
MQ 投递消费者处理成功但 ACK 超时,MQ 重新入队同一消息 ID 再次投递
消费者异常处理成功但崩溃未 ACK,或手动 NACK消息重回队列重复消费

关键认知:RabbitMQ 的 At-Least-Once 语义保证消息不丢,但不保证不重复。


二、三层防护策略

第一层:生产者幂等(防重复发)

java

复制

// 方案:业务唯一 ID(如订单号)作为 MessageId
Message message = MessageBuilder.withBody(json.getBytes())
    .setMessageId(orderNo)  // 业务唯一键
    .setContentType("application/json")
    .build();

// 配合 Broker 去重(RabbitMQ 插件或业务表)
rabbitTemplate.convertAndSend("order.exchange", "order.routing", message);

进阶:启用 RabbitMQ Deduplication 插件(或自研),Broker 层根据 MessageId 去重。


第二层:消息去重(消费者入口)

java

复制

@Component
public class OrderConsumer {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @RabbitListener(queues = "order.queue")
    public void onMessage(Message message, Channel channel) throws IOException {
        String msgId = message.getMessageProperties().getMessageId();
        
        // 1. Redis 去重(SETNX 原子操作)
        Boolean isNew = redisTemplate.opsForValue()
            .setIfAbsent("mq:consumer:" + msgId, "1", 24, TimeUnit.HOURS);
        
        if (!isNew) {
            log.warn("重复消息,直接 ACK: {}", msgId);
            channel.basicAck(deliveryTag, false);  // 确认消费,不处理业务
            return;
        }
        
        // 2. 执行业务
        try {
            processOrder(message);
            channel.basicAck(deliveryTag, false);
        } catch (Exception e) {
            // 失败不 ACK,进入死信队列或重试
            channel.basicNack(deliveryTag, false, false);
        }
    }
}

去重存储选型

表格

方案适用场景TTL 设置
Redis SETNX高频、短周期(24h)消息有效期 + 冗余
MySQL 唯一索引低频、长周期、需审计永久或定期归档
布隆过滤器超大规模、允许极小误判无,内存高效

第三层:消费者业务幂等(最终防线)

无论消息是否重复,业务操作本身必须幂等。

java

复制

@Service
public class OrderService {
    
    // 方案1:数据库唯一约束(推荐)
    @Transactional
    public void createOrder(OrderDTO dto) {
        try {
            orderMapper.insert(new Order(dto.getOrderNo(), ...));
            // 唯一索引冲突时自动抛异常,捕获后视为成功
        } catch (DuplicateKeyException e) {
            log.info("订单已存在,幂等返回: {}", dto.getOrderNo());
            return;  // 视为成功,不抛异常
        }
    }
    
    // 方案2:状态机幂等(复杂业务)
    @Transactional
    public void payOrder(String orderNo, BigDecimal amount) {
        Order order = orderMapper.selectForUpdate(orderNo);
        
        // 状态机校验:已支付直接返回
        if (order.getStatus() == OrderStatus.PAID) {
            log.info("订单已支付,幂等返回: {}", orderNo);
            return;
        }
        
        // 只有待支付状态才执行扣款
        if (order.getStatus() != OrderStatus.PENDING) {
            throw new IllegalStateException("订单状态异常: " + order.getStatus());
        }
        
        // 执行支付...
        orderMapper.updateStatus(orderNo, OrderStatus.PAID);
    }
}

三、关键配置:ACK 模式与重试

yaml

复制

spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual          # 手动 ACK,控制精确
        retry:
          enabled: true
          max-attempts: 3                 # 本地重试3次
          initial-interval: 1000ms
        default-requeue-rejected: false   # 重试失败不入队,进死信
    template:
      retry:
        enabled: true                     # 生产者重试

为什么不用自动 ACK?

  • auto:消息一出队列就 ACK,消费者崩溃则消息丢失 ❌
  • manual:业务成功后 ACK,失败可 NACK/重试/进死信 ✅

四、极端场景:Exactly-Once 实现

RabbitMQ 原生不支持 Exactly-Once,需业务层实现:

plain

复制

生产者 ──► 开启事务/Confirm ──► 发送消息 ──► 本地事务表记录"已发送"
                              │
                              ▼
消费者 ──► 拉取消息 ──► 开启本地事务 ──► 业务处理 + 插入消费记录
                              │
                              ▼
                         同一事务内 ACK 消息

代价:吞吐量下降 10 倍以上,仅金融级场景使用。


五、面试完美回答

"防止重复消费分三层:生产者用业务唯一 ID 保证消息不重复发;消费者入口用 Redis SETNX 或数据库去重表拦截重复消息;业务层必须幂等,数据库唯一约束或状态机校验兜底。ACK 模式用手动确认,失败进死信队列避免无限重试。核心原则是业务幂等为主,MQ 去重为辅,因为网络抖动不可避免,不能依赖 MQ 保证 Exactly-Once。"

加分点

  • 提到 手动 ACK + 死信队列 防止消息堆积
  • 对比 Redis SETNX vs 布隆过滤器 的选型场景
  • 说明 Exactly-Once 的业务代价,体现工程权衡