消息的丢失和消息的补偿
消息的丢失在三个环节,发送环节,消息的存储环节,消息的消费环节。
消息既然存在丢失的可能,就需要有补偿措施。消息的防丢失和消息的补偿措施,都会增加系统的复杂度,然后带来性能的损耗。需要进行权衡。
若我们的项目中引入了消息队列(MQ),那么消息丢失问题便成为我们不得不面对的挑战。今天,我们就来深入探讨一下消息究竟是如何丢失的。
假设我们的业务场景是这样的:用户通过订单系统下单,订单系统完成支付并扣减用户余额后,会向 RocketMQ 发送消息,随后积分系统会从 RocketMQ 中消费该消息,为用户增加相应积分。
然而,有一天有用户反馈,在支付订单并完成余额扣减后,自己的积分却并未增长。经过仔细排查日志,我们发现仅存在向 MQ 推送消息的日志记录,而积分系统消费该消息的日志却毫无踪迹。这就意味着,积分系统未能为用户发放积分,究其原因,是消息在传输过程中丢失了。
在系统的核心链路中,消息丢失问题一旦发生,可能会引发一系列恶劣后果。为了有效解决此类问题,我们必须深入探究消息在何种情况下会发生丢失。
推送消息丢失的应对策略:消息补偿或事务消息
生产者在发送消息时,可能会因网络故障或 broker 的 master 节点宕机等原因,导致消息丢失。例如,订单系统完成支付后向 RocketMQ 发送消息,若此时遭遇网络故障或 broker 的 master 节点宕机,消息便会丢失。
针对这种情况,我们可以采用同步发送消息结合消息补偿的机制。具体而言,当消息发送出现异常时,我们将消息保存至消息表,并定时扫描该消息表,对未成功发送的消息进行重试。
1.订单系统完成支付,扣减余额
try{
2.发送消息
}catch{
3.消息发送失败保存消息表
}
但是假如第1步和第2步执行完毕,系统宕机了,此时数据库数据回滚了消息发送成功了 咋办? 此时可以将扣减余额和保存消息放在1个事务里。如果扣减余额失败就不保存消息,如果扣减余额成功就保存消息,此时由于数据库的事务特性可以保证2者的一致性。最后启动1个定时器定时扫描消息表发送消息即可,发送消息的时候还要做好幂等,否则可能会出现消息的重复消费。
1.订单系统完成支付,扣减余额
2.保存消息表
事务消息
上面的第一种方案伪代码如下:
1.订单系统完成支付,扣减余额
try{
2.发送消息
}catch{
3.消息发送失败保存消息表
}
然而,若第 1 步和第 2 步执行完毕,系统却突然宕机,此时数据库数据回滚,而消息却发送成功了,这种情况该如何应对呢?
基于 RocketMQ 的事务消息机制,我们首先会向 MQ 发送一条 half 消息。
若发送 half 消息或响应失败,则不执行本地事务。
若成功执行本地事务,便根据本地事务的结果进行 rollback(回滚)或 commit(提交),之后直接结束流程。
要是回调失败,就等待 RocketMQ 后续的定时任务扫描 half 消息,并向订单系统发起询问。在整个过程中,即便某个服务宕机,也不会对业务造成影响!
异步刷盘引发的消息丢失问题及解决方案:改为同步刷盘
接下来,假设订单系统向 MQ 推送消息的过程一切正常,消息成功抵达 MQ。此时,订单系统会认为消息写入成功,但这是否就意味着消息一定不会丢失呢?
答案是否定的,此时仍无法保证消息不丢失。让我们来深入分析一下。通过之前对相关知识的了解,相信大家都清楚,当消息写入 MQ 后,MQ 会先将消息写入到 os cache,也就是操作系统的缓存区,其本质仍是内存。
也就是说,你以为发送成功的消息,可能仅仅存在于内存之中,尚未写入磁盘。那么,倘若此时机器宕机,os cache 中的消息数据便会随之丢失,道理就是如此。
当消息到达 RocketMQ,若采用异步刷盘方式,可能消息对应的 commit log 还停留在 page cache 中,尚未刷新到磁盘。此时,若 broker 的物理机宕机并重启,page cache 中的数据就会丢失。
针对这一问题的解决方案,就是将异步刷盘改为同步刷盘。具体操作是修改 broker 的配置文件,把其中的 flushDiskType 配置设置为 SYNC_FLUSH。默认情况下,该配置值为 ASYNC_FLUSH,即异步刷盘。调整为同步刷盘后,只要 MQ 告知我们消息发送成功,那就意味着消息已经存储在磁盘之中。
同步刷盘后磁盘故障导致消息丢失的应对:主从同步 + 磁盘备份
现在,假设消息已经成功刷新到磁盘,是否就能确保万无一失了呢?
显然,此时仍无法完全保证。因为即便数据已保存到磁盘,但若磁盘发生故障,数据依然可能丢失。
即便选择了同步刷盘,消息存储到磁盘后仍可能存在丢失的风险。当磁盘出现故障时,我们可以通过主从同步 + 冗余备份磁盘的方式,尽量减少消息的丢失。
只需使用 RocketMQ 的高可用集群模式即可。也就是说,若返回消息发送成功的响应,那就代表 Master Broker 已经将数据同步到了 Slave Broker 中,确保数据有多个备份。
自动 ACK 导致的消息丢失问题及解决:手动 ACK
消息保存到 MQ 后,若消费者在消费消息时未进行 ACK(确认),MQ 就会误以为消息已消费成功,从而跳到下一个消息的 offset(偏移量)。此时,我们可以通过手动 ACK 机制来确保消息不丢失。
对于 Kafka 和 RabbitMQ 而言,默认的消费模式就是上述自动提交的模式,因此有可能导致消息丢失。
而 RocketMQ 的消费者有所不同,它本身就需要手动返回消息处理成功的响应。
所以,对于 Consumer 消息丢失的解决方案其实很简单,就是将自动提交改为手动提交。
订阅关系不一致导致消息丢失问题及解决:确保订阅关系一致
当同一消费组下的不同消费者订阅关系存在差异时,就可能引发消息丢失。具体表现为,两个消费者订阅了不同的 topic,但它们的 GroupName 却相同。由于每个 consumer 都会向 broker 上报自身的订阅信息,当 groupName 相同时,会出现两者订阅信息相互覆盖的情况,进而导致消息丢失。这一问题是由 org.apache.rocketmq.broker.client.ConsumerGroupInfo#updateSubscription
方法所引发的。
读写队列缩容不当导致消息丢失问题及解决:先缩写后缩读
在 RocketMQ 中,读写队列在消息路由和消费过程中发挥着关键作用。消息发送时,会根据写队列个数返回路由信息;而消息消费时,则按照读队列个数返回路由信息。并且,在物理文件层面,只有写队列才会创建对应的文件。
举个例子,若写队列个数设置为 8,读队列个数设置为 4。此时,会创建 8 个文件夹,分别代表 0、1、2、3、4、5、6、7。但在消息消费时,路由信息仅返回 4,在具体拉取消息时,就只会消费 0、1、2、3 这 4 个队列中的消息,而 4、5、6、7 队列中的消息则完全不会被消费。
反之,若写队列个数为 4,读队列个数为 8。在生产消息时,消息只会发送到 0、1、2、3 队列中;但在消费消息时,却会从 0、1、2、3、4、5、6、7 所有队列中尝试消费。当然,由于 4、5、6、7 队列中原本就没有消息,假设 ConsumerGroup 有两个消费者,实际上只有第一个消费者在真正消费消息(0、1、2、3),第二个消费者根本无法消费到消息。
为确保程序正常运行,必须满足 readQueueNums >= writeQueueNums
的条件。最佳实践是设置 readQueueNums = writeQueueNums
。
或许有人会问,RocketMQ 为什么要区分读写队列呢?直接强制 readQueueNums = writeQueueNums
不就解决问题了吗?其实,RocketMQ 设置读写队列数的目的在于方便队列的缩容和扩容。
相关参考:RocketMQ 读写队列
缩容示例:假设一个 topic 在每个 broker 上创建了 128 个队列,现在需要将队列缩容到 64 个,且要求 100%不会丢失消息,同时无需重启应用程序。具体操作如下:
-
先缩容写队列,从 128 个缩至 64 个,即写队列由 0、1、2……127 变为 0、1、2……63。等待 64、65、66……127 中的消息全部消费完毕后,
-
再缩容读队列,同样从 128 个缩至 64 个。需要注意的是,如果同时缩容写队列和读队列,可能会导致部分消息未被消费,因为写队列中可能还有消息,但读队列已被缩容,此时写队列中的消息将永远无法被消费到。
扩容操作与缩容相反。在扩容时,首先增加可读队列个数,保证 Consumer 先完成监听,再增加可写队列个数,使得 Producer 可以向新增加的队列发送消息。
消息零丢失解决方案
事务消息或消息表解决发送端消息丢失
基于 RocketMQ 的事务消息机制,首先会向 MQ 发送一条 half 消息。若发送 half 消息或响应失败,则不执行本地事务;若成功执行本地事务,便根据本地事务的结果进行 rollback(回滚)或 commit(提交),之后直接结束流程。要是回调失败,就等待 RocketMQ 后续的定时任务扫描 half 消息,并向相关系统(如订单系统)发起询问。在整个过程中,即便某个服务宕机,也不会对业务造成影响。
主从同步 + 同步刷盘解决 Broker 端消息丢失
为解决消息临时存储在 os cache 而未刷新到磁盘所导致的消息丢失问题,我们可以采取以下措施。
熟悉 RocketMQ 的小伙伴都知道,Broker 存在两种刷盘机制,即同步刷盘和异步刷盘。
解决的具体方法是:将异步刷盘改为同步刷盘。具体操作是修改 broker 的配置文件,把其中的 flushDiskType
配置设置为 SYNC_FLUSH
。默认情况下,该配置值为 ASYNC_FLUSH
,即异步刷盘。调整为同步刷盘后,只要 MQ 告知我们消息发送成功,那就意味着消息已经存储在磁盘之中。
接下来,要解决磁盘故障导致的消息丢失问题。
这一问题其实很容易解决,只需使用 RocketMQ 的高可用集群模式即可。也就是说,若返回消息发送成功的响应,那就代表 Master Broker 已经把数据同步到了 Slave Broker 中,确保数据有多个备份。
如此一来,即便 Master Broker 突然宕机,也可以通过 Dledger 技术实现主从的自动切换,使用我们备份的数据。
手动 ACK 解决 Consumer 端消息丢失
我们已经确保了生产者和 Broker 的消息不会丢失,那么消费者处理消息时是否会导致消息丢失呢?答案是肯定的。
例如,积分系统获取到消息后,还未执行相应的操作,就先向 broker 返回这条消息的 offset,声称这条消息已经处理过了。然而,此时系统突然宕机,这就导致 MQ 认为这条消息已经处理完成,而实际上并未处理,从而造成这条消息丢失。
对于 Kafka 和 RabbitMQ 而言,默认的消费模式就是上述自动提交的模式,因此有可能导致消息丢失。
而 RocketMQ 的消费者有所不同,它本身就需要手动返回消息处理成功的响应。
所以,解决 Consumer 端消息丢失的方案其实很简单,就是将自动提交改为手动提交。
消费者避免使用异步消费
消费者应尽量避免异步调用回调函数 consumeMessage
。采用异步调用,可能会出现业务处理失败,但却返回了消息消费成功的异常情况。
例如以下不当操作!
//注册消息监听器处理消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage
(List msgs, ConsumeConcurrentlyContext context){
//开启子线程异步处理消息
new Thread() {
public void run() {
//对消息进行处理
}.start();
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
消息零丢失方案的优缺点剖析
在系统中部署一套消息零丢失方案,旨在无论何种场景下都能确保消息传输的绝对可靠性,这听起来无疑颇具吸引力,而这也正是该方案的一大显著优势——它能保障系统数据的准确无误,杜绝消息丢失情况的发生。
然而,这一方案也并非十全十美,它存在一些不容忽视的缺点。
首先,引入该方案后,系统的复杂度显著提升。以事务消息的实现方式为例,其背后涉及诸多复杂的机制与流程,这无疑增加了系统架构的复杂程度。
更为严重的是,该方案会对系统性能造成显著冲击。原本系统每秒能够处理数万条消息,但在引入消息零丢失方案后,处理能力可能骤降至每秒仅数千条。究其原因,事务消息的复杂性使得消息生产过程耗时大幅增加;同时,采用同步刷盘策略,消息需写入磁盘后才会返回成功响应,这无疑进一步延长了处理时间。相比之下,若消费者采用异步处理消息方式,直接返回成功结果,整个消息处理流程的速度将大幅提升。由此可见,消息零丢失方案对系统性能的影响不容小觑。
通常而言,对于涉及金钱、交易以及核心数据的系统和核心链路,采用消息零丢失方案是较为合理的选择。例如,支付系统、订单系统、积分系统等,这些系统的数据准确性和可靠性至关重要,任何消息丢失都可能导致严重的后果。
然而,对于一些非核心场景,即便丢失部分数据也不会对系统整体运行产生重大影响,此时则无需采用这套复杂的方案,或者可以对方案进行适当简化。例如,将事务消息机制调整为失败后重试若干次的机制,同时将刷盘策略改为异步刷盘,以此在保障一定可靠性的前提下,尽量降低系统复杂度和性能损耗。