前言
大家好,这里是程序员阿亮,相信大家已经学习或者使用过我们的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 宕机重启,内存中的消息依然会灰飞烟灭。
-
解决方案 :实现全面持久化。
-
Exchange 持久化 :声明交换机时设置
durable=true。 -
Queue 持久化 :声明队列时设置
durable=true。 -
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,此时不方便用数据库唯一索引控制。
也就是令牌的解决方案。
-
操作:
-
消费者收到消息后,先用 Message ID(或业务标识)去 Redis 中执行
SETNX命令操作。 -
如果 SETNX 成功,说明是首次消费,继续执行后续业务逻辑;完成后将状态持久化。
-
如果 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 分布式锁或乐观锁来保证业务的幂等性。
在实际生产中,对于核心业务(如支付、交易),宁可接受系统性能降低和少量重复消息(靠幂等性解决),也绝不能容忍哪怕一条消息的丢失。

