解决 RabbitMQ 的可靠性投递与消息重复消费问题思路

前言

大家好,这里是程序员阿亮,相信大家已经学习或者使用过我们的MQ

MQ是我们在分布式系统中来进行解耦、削峰的利器,可以大大提高项目的并发量,但是实际上使用之后也带来了许多额外的问题。

一、消息的可靠性投递

一条消息从生产者发出,到消费者消费完成,一共要经历四个核心阶段。任何一个阶段出现问题,都可能导致消息丢失。

我们要保证消息的可靠投递,就必须在每一环都加上"保险":

1. 生产者到交换机(Producer -> Exchange):Confirm 机制

消息从生产者发往 Exchange 时,可能会因为网络波动或 Exchange 不存在而失败

  • 解决方案 :开启 Publisher Confirms(发送方确认机制)

  • 原理:生产者发送消息后,到达我们的Exchange,Exchange处理(持久化、确认能处理并接受)后RabbitMQ 会异步返回一个 ACK(成功)或 NACK(失败)的应答。如果收到 NACK 或超时未收到应答,生产者可以重发消息。

  • 最佳实践:结合本地消息表或定时任务。将要发送的消息先落库保存,状态标记为"发送中",收到 ACK 后更新为"发送成功";后台定时任务轮询"发送中"的消息进行补偿重试。

2. 交换机到队列(Exchange -> Queue):Return 机制

消息到达了 Exchange,但是因为 RoutingKey 写错了,或者对应的 Queue 没有绑定,导致消息无法路由被丢弃。

  • 解决方案 :开启 Publisher Returns(消息退回机制) ,并将 mandatory 参数设置为 true

  • 原理 :当消息无法被路由到任何队列时,RabbitMQ 会将消息退回给生产者。生产者可以通过实现 ReturnCallback 接口来捕获这些"迷路"的消息,进行人工干预或记录日志。我们就可以通过注册回调来进行兜底

  • 备用方案 :设置备份交换机(Alternate Exchange),当主交换机无法路由消息时,自动转交给备份交换机(通常绑定一个专门的报警队列)。

3. RabbitMQ 自身存储:持久化机制(Persistence)

即使消息成功到达了队列,如果 RabbitMQ 宕机重启,内存中的消息依然会灰飞烟灭。

  • 解决方案 :实现全面持久化

    1. Exchange 持久化 :声明交换机时设置 durable=true

    2. Queue 持久化 :声明队列时设置 durable=true

    3. Message 持久化 :发送消息时设置消息的 deliveryMode=2(Spring Boot 中默认就是持久化的)。

  • 注意:持久化会带来一定的性能损耗,但在金融或交易场景下,这点损耗是换取数据安全的必要代价。

4. 队列到消费者(Queue -> Consumer):手动 ACK

消费者拉取到消息后,如果在处理业务逻辑的过程中发生了宕机(比如报异常、OOM),而此时 RabbitMQ 已经自动把消息删了,这就造成了真正的业务数据丢失。

  • 解决方案 :关闭自动确认,开启手动 ACK(Manual Acknowledgment)

  • 原理 :消费者处理完所有业务逻辑,甚至数据库事务提交之后,才显式地调用 basic.ack 告诉 RabbitMQ 可以删除消息了。如果业务抛出异常,可以调用 basic.nack 并让消息重新入队(requeue=true),或者丢入死信队列(DLX)后续人工处理。

二、如何解决消息重复消费问题(保证幂等性)?

会发现为了解决消息的可靠性投递问题,我们经常会使用大量的重试机制去解决问题,比如说通过confirm机制或者return机制的回调,我们可以在producer那里进行重试,基于本地消息表进行重试,队列对消息进行重新投递等,这都需要我们的消费者那边对消息具有幂等性。

核心思想:幂等性(Idempotency) 无论这个消息被消费多少次,产生的结果必须和消费一次是一模一样的。

解决重复消费的核心在于:给每一条消息赋予一个全局唯一的标识(Message ID),并在消费端做去重校验。

方案 1:数据库唯一索引(最常用且绝对可靠)

这是最简单直接且强一致性的方案。

  • 操作 :在数据库业务表中,针对能够唯一标识该业务的字段(如 order_id流水号)建立唯一索引(Unique Key)

  • 效果 :当第二条重复消息过来执行 Insert 操作时,数据库会抛出 DuplicateKeyException 异常。我们只需在代码里 catch 这个异常,直接当作消费成功返回 ACK 即可。

方案 2:Redis 分布式锁 / SETNX(适用于复杂业务逻辑)

如果业务不仅是 Insert,还包含复杂的 Update 操作,或者涉及调用第三方 API,此时不方便用数据库唯一索引控制。

也就是令牌的解决方案。

  • 操作

    1. 消费者收到消息后,先用 Message ID(或业务标识)去 Redis 中执行 SETNX 命令操作。

    2. 如果 SETNX 成功,说明是首次消费,继续执行后续业务逻辑;完成后将状态持久化。

    3. 如果 SETNX 失败,说明这条消息已经被处理过(或正在处理),直接丢弃并返回 ACK。

  • 注意:务必给 Key 设置过期时间(TTL),防止死锁。

方案 3:乐观锁机制(版本号校验)

常用于状态流转更新的场景(比如订单状态从"未支付"变成"已支付")。

  • 操作 :在数据表中增加一个 version 字段。每次更新数据时不仅验证主键,还要验证版本号。

    复制代码
    UPDATE orders SET status = 'PAID', version = version + 1 
    WHERE order_id = 1001 AND version = 1;
  • 效果:重复的消息由于带的还是旧版本号,Update 会返回 0 条受影响行数,业务感知到后直接当做已消费处理。


总结

构建一个健壮的分布式消息系统,本质上是在可用性、性能与一致性之间做权衡。

  • 100% 不丢消息:开启 Confirm 和 Return 机制,做好交换机/队列/消息的全面持久化,消费端必须手动 ACK。

  • 防重复消费:引入全局唯一 ID,利用数据库唯一索引、Redis 分布式锁或乐观锁来保证业务的幂等性。

在实际生产中,对于核心业务(如支付、交易),宁可接受系统性能降低和少量重复消息(靠幂等性解决),也绝不能容忍哪怕一条消息的丢失。

相关推荐
飞火流星020272 小时前
验证kafka队列中的数据是否是被压缩后的数据
分布式·kafka·验证kafka队列中的数据格式·验证kafka数据压缩·验证kafka数据是否已被压缩
Coder_Boy_3 小时前
技术交流总结:分布式、数据库、Spring及SpringBoot核心知识点梳理
数据库·spring boot·分布式·spring·微服务
shanchahua1234563 小时前
解冻支付功能-分布式数据一致性(分布式事务)
分布式
Coder_Boy_3 小时前
技术交流总结:分布式、数据库、Spring及SpringBoot核心知识点梳理(实现参考)
数据库·spring boot·分布式·spring·架构
Ronin3053 小时前
订阅者模块
rabbitmq
小程故事多_803 小时前
详解Kafka重平衡与分区重分配,核心差异、原理及实操辨析
分布式·kafka·linq
七夜zippoe3 小时前
性能测试实战:Locust负载测试框架深度指南
分布式·python·性能测试·locust·性能基准
飞火流星020273 小时前
kafka设置数据压缩的方式及作用
分布式·kafka·kafka数据压缩·kafka压缩配置级别·kafka数里压缩配置作用·kafka数据压缩配置级别
没有bug.的程序员17 小时前
Gradle 构建优化深度探秘:从 Java 核心到底层 Android 物理性能压榨实战指南
android·java·开发语言·分布式·缓存·gradle