RabbitMQ 如何防止重复消费?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. 是否理解重复消费产生的根源
    面试官不仅想知道 “如何防止”,更想确认你是否清楚 为什么会发生重复消费 —— 这往往比解决方案本身更能体现对消息队列机制的掌握。

  2. 幂等性设计的落地能力
    分布式系统中“防止重复”的本质是实现幂等。面试官希望听到你能结合具体的业务场景(如数据库插入、状态更新、计数类操作)给出可执行的方案,而不是泛泛而谈“用唯一索引”。

  3. 权衡与选型思维
    不同幂等方案的性能、复杂度、适用范围各不相同。面试官会观察你是否能针对并发高低、数据一致性要求、吞吐量等因素做出合理取舍。

  4. 对 RabbitMQ 特性的认知边界
    部分候选人会误以为 RabbitMQ 本身提供了 “恰好一次” 的语义,面试官想借此判断你是否清楚:RabbitMQ 只在协议层面保证消息不丢失,并不内置全局去重机制

核心答案

防止 RabbitMQ 重复消费没有一招鲜的方案,核心原则是在消费端实现业务幂等
常见手段包括:

  • 数据库唯一约束(如订单号、消息 ID 做唯一索引);
  • 基于 Redis 的去重表(利用 setnx 短时缓存消息 ID);
  • 版本号乐观锁(更新时判断数据版本);
  • 状态机前置判断(检查当前状态是否允许转换)。

比较推荐的做法:生产者给每条消息附加全局唯一的业务 ID(如 UUID),消费者根据该 ID 配合持久化的去重存储(DB 或 Redis)来保证消息只被处理一次。

深度解析

原理 / 机制

重复消费的根本原因是网络不可靠分布式系统确认机制的妥协

  • 生产者重试:生产者发送消息后未收到 Broker 确认,触发重试 → 队列中出现多条相同内容的消息。
  • 消费者自动 ACK 后宕机:业务逻辑执行完成,但在提交 ACK 前消费者挂掉 → 消息重新入队,被其他节点再次消费。
  • RabbitMQ 集群故障转移:镜像队列切换 Master 时,可能因消息未完全同步而重新投递。

因此,防止重复消费不是 MQ 本身能承诺的事,必须由消费端自己兜底。

代码示例

场景:消费一个“创建订单”消息,防止重复下单。

1. 基于数据库唯一索引

-- 订单表增加唯一约束:business_id 由生产者生成并携带
ALTER TABLE `order` ADD UNIQUE KEY `uk_business_id` (`business_id`);
@RabbitListener(queues = "order.queue")
public void handleCreateOrder(Message message) {
    String businessId = message.getMessageProperties().getHeader("business_id");
    try {
        // 业务逻辑:INSERT INTO order ... 重复时会抛 DuplicateKeyException
        orderService.createOrder(businessId, ...);
    } catch (DuplicateKeyException e) {
        // 已经处理过,直接确认,不做业务回滚
        log.info("重复消息,忽略:{}", businessId);
    }
}

2. 基于 Redis 去重表(短时效场景)

@RabbitListener(queues = "order.queue")
public void handle(Channel channel, Message message) {
    String msgId = message.getMessageProperties().getMessageId(); // 需生产者设置
    // 利用 setnx 尝试占锁,有效期设为业务处理预估时间的 2~3 倍
    Boolean success = redisTemplate.opsForValue()
            .setIfAbsent("duplicate:" + msgId, "1", Duration.ofMinutes(5));
    if (Boolean.FALSE.equals(success)) {
        // 已消费过
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        return;
    }
    try {
        // 执行真正的业务逻辑
        doBusiness(message);
        channel.basicAck(deliveryTag, false);
    } catch (Exception e) {
        // 处理失败时需删除 Redis 中的标记,允许重试
        redisTemplate.delete("duplicate:" + msgId);
        channel.basicNack(deliveryTag, false, true);
    }
}

对比分析:几种幂等方案的适用场景

方案优点缺点推荐场景
数据库唯一索引强一致,实现简单,无需额外存储中间件性能受限于 DB,对已有表可能需改结构核心交易数据,必须绝对一致
Redis 去重表高性能,适合高频短时去重依赖 Redis 高可用,有效期难精准预估非核心业务,或对延迟敏感的场景
版本号乐观锁利用业务字段,不改变表结构需要实体自带版本字段,且仅适用于更新操作更新库存、修改已存在的数据
业务状态机前置判断完全贴合业务,无需额外技术组件需要业务逻辑支持 “已处理” 状态,有时需串行化处理流程型业务(工单、审批流)

最佳实践

  1. 消息必须携带全局唯一 ID
    生产者为每条消息设置 messageId(UUID),或者使用业务上天然唯一的订单号、请求号。这是幂等判断的基石。

  2. 先做幂等判断,再执行业务
    将 “去重检查” 放在业务事务的同一范围内,避免因事务回滚导致标记未清除。

  3. 合理设置去重标记的有效期
    如果使用缓存去重,有效期应大于消息最大重试间隔 + 业务处理峰值耗时,防止重复消息过期后再次被消费。

  4. 重试幂等也要考虑
    消费者业务失败重试时,幂等标记应当被清除(或通过版本号机制允许重试),否则死信消息永远无法被正确重试。

常见误区

  • 以为手动 ACK + 单队列就能杜绝重复
    消费者处理完、ACK 前宕机,消息依然会重复投递。ACK 机制保证的是至少一次,不是恰好一次。

  • 过度依赖 MQ 的“去重插件”
    RabbitMQ 社区有一些去重插件,但它们通常针对消息进入队列时去重,而非消费端去重,且需承担性能损耗。生产环境极少依赖插件解决业务幂等

  • 使用 Redis 做永久去重
    把消息 ID 永不过期地存入 Redis,会导致内存爆炸。应该设置合理的 TTL,并接受极端情况下(TTL 内未重试,但很久后重试)极低概率的重复。

总结

防止 RabbitMQ 重复消费的核心不在于 MQ 本身,而在于消费端的幂等设计。通过生产者注入全局唯一 ID,配合数据库唯一约束或 Redis 短期缓存,在业务执行前拦截重复消息,才是互联网高并发场景下最稳妥、最通用的实践方案。记住:幂等是分布式系统的一致性与性能妥协后的最优解