RabbitMQ 如何防止重复消费?

#中间件#面试八股

RabbitMQ 如何防止重复消费?

结论:RabbitMQ 本身不提供去重能力,它的投递语义是至少一次(At-Least-Once)。所以"防止重复消费"实际是两件事:尽量少产生重复(ack 时机、消费超时、重投策略),以及在消费端做幂等(唯一键、状态机、去重表)。前者降低概率,后者兜底正确性。

一、重复是怎么产生的

             ┌──────────── ① ack 丢失 ────────────┐
Consumer ────┤ 处理完成,ack 在网络上丢了            │──> Broker 认为未确认,重投
             ├──────────── ② 处理超时 ────────────┤
             │ 处理时间 > consumer_timeout(30min)   │──> 连接被关闭,消息 requeue
             ├──────────── ③ 主动 nack/requeue ────┤
             │ 业务异常,basicNack(requeue=true)    │──> 同一条消息再次投递
             └──────────── ④ 节点故障 ────────────┘
               未确认消息在副本切换后重新投递

注意第 ④ 条:仲裁队列切换 Leader 时,未 ack 的消息会被重新投递给新消费者,这是"至少一次"语义的必然结果,不是 Bug。

二、先减少重复来源

来源缓解手段注意
ack 丢失无法彻底避免,靠幂等兜底不要用 autoAck
处理超时提高 consumer_timeout 或缩小单条处理粒度超时配置过大,故障恢复会变慢
无限重投限制重试次数,超限进死信队列否则同一条消息会打爆消费端
预取过多basicQos(prefetch) 控制未确认条数prefetch 太大,故障时重投量也大
多消费者竞争同一队列多消费者是正常设计重复与并发无关,只与 ack 有关

三、消费端幂等的四种做法

方案实现适用
唯一键插入业务表建唯一索引,重复插入报错即忽略创建类操作(下单、建账)
去重表msg_id 唯一索引,处理前先插入通用,需清理历史数据
状态机校验只允许 待支付 → 已支付,重复消息因状态不符被拒状态流转类业务
分布式锁 / SETNXRedis SET key NX EX 抢占处理权短时高频重复,注意锁超时

四、代码:去重表 + 手动 ack

channel.basicQos(20);                       // 未确认上限,控制重投规模
channel.basicConsume("order.pay.q", false, (tag, msg) -> {
    String msgId = msg.getProperties().getMessageId();   // 生产端必须设置
    try {
        // 唯一索引冲突 = 已处理过,直接 ack
        if (!dedupMapper.tryInsert(msgId)) {
            channel.basicAck(tag, false);
            return;
        }
        orderService.handle(new String(msg.getBody(), UTF_8));
        channel.basicAck(tag, false);
    } catch (BusinessException e) {
        // 业务不可恢复:进死信,不要 requeue 造成死循环
        channel.basicNack(tag, false, false);
    } catch (Exception e) {
        // 临时故障:重投,靠幂等保证不重复生效
        channel.basicNack(tag, false, true);
    }
});

tryInsert 必须与业务处理在同一个数据库事务里提交,否则会出现"去重记录写了、业务没执行"的假成功。

五、幂等键怎么选

候选键唯一性说明
业务流水号强首选,天然唯一且可追溯,跨系统对账也能用
消息 messageId中依赖生产端自己设置,重投时不变,但跨生产者不保证唯一
消费位点弱重平衡或重置位点后失效,只适合做辅助判断
Redis SETNX 键弱必须设过期时间,否则键无限膨胀

选键的原则是"定位一次业务意图",而不是"定位一次投递"。同一次投递重投多次,业务意图只应生效一次。

六、常见追问

  • 用 messageId 去重可靠吗? 前提是生产端自己设置 messageId 且全局唯一(比如业务流水号)。Broker 自动生成的 ID 在重投时不变,但跨生产者无法保证唯一,业务键更稳妥。
  • deliveryTag 能当去重键吗? 不能。它是信道内的投递序号,每次重投都会变,重启后从头编号。
  • 为什么不能用 autoAck 来"避免重复"? autoAck 在消息发出后立即确认,消费端崩溃时消息已经丢了,用丢消息换不重复,方向就错了。
  • 重复消费一定有害吗? 取决于业务。查询类、覆盖写类天然幂等;扣款、发券、发短信必须显式去重,最好在数据库层用唯一约束兜底,而不是只靠应用判断。

小结

  • RabbitMQ 的语义是至少一次,重复是协议行为而非异常。
  • 减少重复靠 ack 时机、prefetch、重试上限;消除影响靠幂等。
  • 幂等的第一道防线是数据库唯一约束,应用层判断只能作为优化。
  • 临时故障重投、永久故障进死信,是消费端必须区分的两条路径。
笔记加载中…