MQ-消息的可靠性

一,发送者的可靠性

1.生产者重试机制

当生产者发送消息时,出现了网络故障,导致了与MQ的连接中断。

可以配置重发,可以设置以下配置:

  • MQ连接超时时间
  • 失败后的初识等待时间
  • 失败后下次等待倍数
  • 最大重试次数

注意:MQ的发送是阻塞式的,也就是会阻塞当前线程。MQ异步是指只要把消息投递,不需要得到消费者的回应。

2.生产者确认机制

当出现以下问题时,需要生产者确认机制。有两种确认机制:Publish ConfirmPublish Return

  • MQ内部处理消息的进程发生了异常

  • 生产者发送消息到达MQ后未找到Exchange

  • 生产者发送消息到达MQ的Exchange后,未找到合适的Queue,因此无法路由

处理结果总结:

  • 当消息投递到MQ,但是路由失败时,通过Publisher Return返回异常信息,同时返回ack的确认信息,代表投递成功

  • 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功

  • 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功

  • 其它情况都会返回NACK,告知投递失败

其中acknack属于Publisher Confirm机制,ack是投递成功;nack是投递失败。而return则属于Publisher Return机制。

默认两种机制都是关闭状态,需要通过配置文件来开启。他们的状态是独立状态。

返回消息处理方式:

  • none:关闭confirm机制

  • simple:同步阻塞等待MQ的回执

  • correlated:MQ异步回调返回回执

二,MQ的可靠性

1.数据的持久化

为了提升性能,默认情况下MQ的数据都是在内存存储的临时数据,重启后就会消失。为了保证数据的可靠性,必须配置数据持久化,包括:

  • 交换机持久化

  • 队列持久化

  • 消息持久化

在开启持久化机制以后,如果同时还开启了生产者确认,那么MQ会在消息持久化以后才发送ACK回执,进一步确保消息的可靠性。

不过出于性能考虑,为了减少IO次数,发送到MQ的消息并不是逐条持久化到数据库的,而是每隔一段时间批量持久化。一般间隔在100毫秒左右,这就会导致ACK有一定的延迟,因此建议生产者确认全部采用异步方式。

2.LazyQueue

在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。但在某些特殊情况下,这会导致消息积压,比如:

  • 消费者宕机或出现网络故障

  • 消息发送量激增,超过了消费者处理速度

  • 消费者处理业务发生阻塞

一旦出现消息堆积问题,RabbitMQ的内存占用就会越来越高,直到触发内存预警上限。此时RabbitMQ会将内存消息刷到磁盘上,这个行为成为PageOut. PageOut会耗费一段时间,并且会阻塞队列进程。因此在这个过程中RabbitMQ不会再处理新的消息,生产者的所有请求都会被阻塞。

为了解决这个问题,从RabbitMQ的3.6.0版本开始,就增加了Lazy Queues的模式,也就是惰性队列。惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存

  • 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载)

  • 支持数百万条的消息存储

  • 动态监测消费者处理消息的速度,如果处理的比较慢,那么每次只需要从磁盘加载就可以。如果处理的的快,超过了磁盘加载的速度,那么就提前缓存部分消息到内存中。

而在3.12版本之后,LazyQueue已经成为所有队列的默认格式。因此官方推荐升级MQ为3.12版本或者所有队列都设置为LazyQueue模式。

三,消费者的可靠性

1.消费者确认机制

当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态。回执有三种可选值:

  • ack:成功处理消息,RabbitMQ从队列中删除该消息

  • nack:消息处理失败,RabbitMQ需要再次投递消息

  • reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息

一般reject方式用的较少,除非是消息格式有问题,那就是开发问题了。因此大多数情况下我们需要将消息处理的代码通过try catch机制捕获,消息处理成功时返回ack,处理失败时返回nack.

由于消息回执的处理代码比较统一,因此SpringAMQP帮我们实现了消息确认。并允许我们通过配置文件设置ACK处理方式,有三种模式:

  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用

  • manual:手动模式。需要自己在业务代码中调用api,发送ackreject,存在业务入侵,但更灵活

  • auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果:

    • 如果是业务异常,会自动返回nack

    • 如果是消息处理或校验异常,自动返回reject;

2.失败重试机制

当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。

极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力,同样可以配置投递

  • MQ连接超时时间
  • 失败后的初识等待时间
  • 失败后下次等待倍数
  • 最大重试次数

在之前的测试中,本地测试达到最大重试次数后,消息会被丢弃。这在某些对于消息可靠性要求较高的业务场景下,显然不太合适了。

3.失败处理策略

为了应对上述情况Spring又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。

因此Spring允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由MessageRecovery接口来定义的,它有3个不同实现:

  • RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式

  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队

  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机

4.业务幂等性

我们在用户支付成功后会发送MQ消息到交易服务,修改订单状态为已支付,就可能出现消息重复投递的情况。如果消费者不做判断,很有可能导致消息被消费多次,出现业务故障。

举例:

  1. 假如用户刚刚支付完成,并且投递消息到交易服务,交易服务更改订单为已支付状态。

  2. 由于某种原因,例如网络故障导致生产者没有得到确认,隔了一段时间后重新投递给交易服务。

  3. 但是,在新投递的消息被消费之前,用户选择了退款,将订单状态改为了已退款状态。

  4. 退款完成后,新投递的消息才被消费,那么订单状态会被再次改为已支付。业务异常。

因此,我们必须想办法保证消息处理的幂等性。这里给出两种方案:

  • 唯一消息ID

  1. 每一条消息都生成一个唯一的id,与消息一起投递给消费者。

  2. 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库

  3. 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。

  • 业务状态判断

业务判断就是基于业务本身的逻辑或状态来判断是否是重复的请求或消息,不同的业务场景判断的思路也不一样。

例如我们当前案例中,处理消息的业务逻辑是把订单状态从未支付修改为已支付。因此我们就可以在执行业务时判断订单状态是否是未支付,如果不是则证明订单已经被处理过,无需重复处理。

5.兜底方案

还是举上面的例子,假如因为网络故障,支付服务无论在要用MQ发消息通知交易服务时宕机了,那不是无论如何保证不了订单的一致性?

其实思想很简单:既然MQ通知不一定发送到交易服务,那么交易服务就必须自己主动去查询支付状态。这样即便支付服务的MQ通知失败,我们依然能通过主动查询来保证订单状态的一致

其实思想很简单:既然MQ通知不一定发送到交易服务,那么交易服务就必须自己主动去查询支付状态。这样即便支付服务的MQ通知失败,我们依然能通过主动查询来保证订单状态的一致。

四.延迟消息

因此,电商中通常的做法就是:对于超过一定时间未支付的订单,应该立刻取消订单并释放占用的库存

例如,订单支付超时时间为30分钟,则我们应该在用户下单后的第30分钟检查订单支付状态,如果发现未支付,应该立刻取消订单,释放库存。

在RabbitMQ中实现延迟消息也有两种方案:

  • 死信交换机+TTL

  • 延迟消息插件

1.死信交换机

当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):

  • 消费者使用basic.reject或   basic.nack声明消费失败且消息的requeue参数设置为false

  • 消息是一个过期消息,超时无人消费

  • 要投递的队列消息满了,无法投递

如果一个队列中的消息已经成为死信,并且这个队列通过dead-letter-exchange属性指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机就称为死信交换机(Dead Letter Exchange)。而此时加入有队列与死信交换机绑定,则最终死信就会被投递到这个队列中

死信交换机有什么作用呢?

  1. 收集那些因处理失败而被拒绝的消息

  2. 收集那些因队列满了而被拒绝的消息

  3. 收集因TTL(有效期)到期的消息

前面两种作用场景可以看做是把死信交换机当做一种消息处理的最终兜底方案,与消费者重试时讲的RepublishMessageRecoverer作用类似。

而最后一种场景,大家设想一下这样的场景:

如图,有一组绑定的交换机(ttl.fanout)和队列(ttl.queue)。但是ttl.queue没有消费者监听,而是设定了死信交换机hmall.direct,而队列direct.queue1则与死信交换机绑定,RoutingKey是blue:

 注意:尽管这里的ttl.fanout不需要RoutingKey,但是当消息变为死信并投递到死信交换机时,会沿用之前的RoutingKey,这样hmall.direct才能正确路由消息。

2.DelayExchange插件

基于死信队列虽然可以实现延迟消息,但是太麻烦了。因此RabbitMQ社区提供了一个延迟消息插件来实现相同的效果。

官方网址:

DelayExchange插件

### MQ 消息可靠性的实现方案与原理 #### 一、消息可靠性定义 消息可靠性是指在分布式系统中,确保消息能够被正确传递而不发生丢失或重复的现象。其核心目标是满足 **“不重不漏”** 的原则[^1]。 --- #### 二、常见实现方式 ##### 1. 生产端的可靠性保障 为了防止生产者发送的消息因网络或其他原因而丢失,通常采用以下措施: - **事务消息**:通过引入两阶段提交协议,在数据库和消息队列之间建立强一致性关系。只有当消息成功写入消息队列后,才会提交本地事务[^4]。 - **同步刷盘**:某些消息中间件(如 RocketMQ)提供同步刷盘模式,即每条消息都会先持久化到磁盘后再返回 ACK 给生产者,从而降低数据丢失风险[^3]。 ```python from rocketmq.client import Producer, Message producer = Producer('test_producer') producer.set_namesrv_addr('localhost:9876') def send_message(): msg = Message('TopicTest') msg.set_keys('Key') msg.set_body('Message body'.encode('utf-8')) ret = producer.send_sync(msg) # 同步发送并等待确认 print(f"Send Result Status:{ret.status}") ``` --- ##### 2. 存储端的可靠性设计 存储层的设计直接影响着整个系统的稳定性,以下是几种典型策略: - **多副本复制**:大多数现代消息队列都支持多副本机制(如 Kafka 和 RabbitMQ),通过将消息分发至多个节点来提高容错能力。一旦某个节点失效,其他备份节点可继续服务请求[^2]。 - **日志追加写法**:类似于 LSM 树结构的操作逻辑,每次新增记录均以顺序追加形式存入文件尾部,减少随机 IO 开销的同时增强了性能表现。 --- ##### 3. 消费端的一致性维护 为了避免消费者重复消费同一份数据或者遗漏部分重要信息,需采取如下手段加以防范: - **幂等性控制**:对于可能多次触发相同操作的任务场景,应提前规划好对应的去重校验规则,比如利用唯一 ID 或时间戳字段判断当前动作是否已经执行完毕。 - **手动签收机制**:允许客户端显式告知服务器已完成某项工作之后再清除对应缓存中的原始内容,这样即便中途出现问题也能及时发现并补救。 ```java // Java伪代码展示如何设置手动ACK模式 Channel channel = connection.createChannel(); channel.basicConsume(queueName, false, (consumerTag, message) -> { try { processMessage(message); channel.basicAck(message.getEnvelope().getDeliveryTag(), false); // 手动确认已接收 } catch (Exception e) { System.err.println("Failed to process and ack the message"); } }); ``` --- #### 三、解决实际问题的方法论 针对不同类型的错误情况,有相应的应对办法: | 错误类型 | 描述 | 推荐解决方案 | |----------------|----------------------------------------------------------------------------------------|------------------------------------------------------------------------------| | 消息丢失 | 发送过程中由于各种意外状况造成的数据遗失 | 使用定时轮询补偿机制定期检查未完成状态下的任务列表,并重新发起尝试 | | 数据冗余 | 单次事件引发多重响应 | 设计具备防抖特性的接口函数;借助 Redis 缓存临时保存最近访问历史 | | 跨库协调失败 | 当涉及多方协作时难以达成共识 | 构建专门用于跟踪进度变化的日志表格,配合双相提交流程逐步推进直至最终定稿 | 上述提到的 “本地消息表” 方法就是一种典型的跨库一致性的实践案例[^5]。它通过额外增加一张映射关联关系的辅助型实体对象集合,用来记录待办事项的状态流转轨迹,进而达到全局视角下统一管理的目的。 --- ### 总结 综上所述,要构建一套健壮可靠的 MQ 系统架构并非易事,需要综合考量各方面因素共同作用的结果。从源头抓起做好预防准备固然重要,但事后救济同样不可或缺。唯有如此才能真正意义上做到全方位无死角覆盖所有潜在隐患点位。 ---
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值