对于一个 通用的 MQ 模型来说: 包含生产者Producer, MQ Server(Broker) 和消费者 Consumer
其中, 1, 2, 4三个场景都是跨网络的, 而跨网络就肯定会有丢消息的可能.
然后关于3这个环节, 通常MQ存盘时都会先写入操作系统的缓存page cache中, 然后再由操作系统异步的将消息写入硬盘. 中间存在有个时间差, 就可能会造成消息丢失. 如果服务挂, 缓存中还没有来得及写入硬盘的消息就会丢失.
这是MQ场景都会面对的通用的丢消息问题. RocketMQ场景下如何保证休息的不丢失
由于 RocketMQ 的事务消息机制就是为保证零丢失来设计的.
但是若深入分析, 还需要理解这个事务消息到底是不是可靠的. 我们以最常见的电商订单场景为例:
(1) 为什么要发送个 half 消息? 作用是什么?
这个 half 消息是在订单系统进行下单操作前发送, 并且对下游服务的消费者是不可见的. 这个消息的作用更多地体现在确认 RocketMQ 的服务是否正常. 相当于嗅探 RocketMQ 服务是否处在正常状态, 并且通知 RocketMQ: Producer 端就要发一个很重要的消息啦, 你做好准备!
(2) half 消息如果写入失败怎么办?
若是没有 half 消息这个流程, 那通常会在订单系统中先完成下单, 再发送消息给 MQ. 这时候写入消息到MQ, 如果失败就会非常尴尬呀. 而half 消息如果写入失败, 就可以认为MQ的服务是有问题的, 此时就不能通知下游服务. 不过可以在下单时给订单一个状态标记, 然后等待MQ 服务正常后再进行补偿操作, 等MQ服务正常后重新下单通知下游服务.
(3) 订单系统写数据库失败怎么办?
这个问题可以通过比较 没有使用事务消息机制时会怎么办?
若未使用事务消息, 我们只能判断下单失败, 抛出异常, 就不再往MQ发消息, 这样至少保证不会对下游服务进行错误的通知. 但是这样的话, 如果过一段时间数据库恢复过来, 这个消息就无法再次发送。当然, 也可以设计另外的补偿机制, 例如将订单数据缓存起来, 再启动一个线程定时尝试往数据库写. 而如果使用事务消息机制, 就可以有一种更简洁的方案.
若下单时, 写数据库失败(可能是数据库崩, 需要等一段时间才能恢复). 那我们可以另外找个地方把订单消息先缓存起来(Redis 或者其他方式), 然后给RocketMQ返回一个 UNKNOWN 状态. 这样RocketMQ就会过一段时间来回查事务状态. 我们就可以在回查事务状态时再尝试把订单数据写入数据库, 如果数据库这时候已经恢复, 那就能完整正常的下单, 再继续后面的业务. 这样一来, 属于这个订单的消息就不会因为数据库临时崩而丢失.
(4 )half消息写入成功后 RocketMQ 宕机怎么办?
在事务消息的处理机制中, 未知状态的事务状态回查是由 RocketMQ 的Broker主动发起. 也就是说如果出现这种情况, RocketMQ 就不会回调到事务消息中回查事务状态的服务. 这时, 我们就可以将订单一直标记为"新下单"的状态. 等RocketMQ恢复之后, 只要存储的消息没有丢失, RocketMQ就会再次继续状态回查的流程.
(5) 下单成功后如何优雅的等待支付成功?
在订单场景下, 通常会要求下单完成后, 客户在一定时间内, 例如10分钟内完成订单支付, 支付完成后才会通知下游服务进行进一步的营销补偿。
如果不用事务消息, 那通常会怎么办?
最简单的方式是启动一个定时任务, 每隔一段时间扫描订单表, 比对未支付的订单的下单时间, 将超过时间的订单回收。这种方式显然存在很大问题的, 需要定时扫描很庞大的一个订单信息, 这对系统是个不小的压力.
那更进一步的方案可以怎么考虑呢? 使用RocketMQ提供的延迟消息机制. 往MQ发一个延迟1分钟的消息, 消费到这个消息后去检查订单的支付状态, 如果订单已经支付, 就往下游发送下单的通知. 而如果没有支付, 就再发一个延迟1分钟的消息. 最终在第十个消息时把订单回收.这个方案就不用对全部的订单表进行扫描, 而只需要每次处理一个单独的订单消息.
那如果使用上述的事务消息呢?我们就可以用事务消息的状态回查机制来替代定时的任务. 在下单时, 给Broker返回一个UNKNOWN的未知状态. 而在状态回查的方法中去查询订单的支付状态, 这样整个业务逻辑就会简单很多. 只需要配置RocketMQ中的事务消息回查次数(默认15次)和事务回查间隔时间(messageDelayLevel
), 就可以更优雅的完成这个支付状态检查的需求.
(6) 事务消息机制的作用
整体来说, 在订单这个场景下, 消息不丢失的问题实际上就还是转化成了下单这个业务与下游服务的业务的分布式事务一致性问题. 而事务一致性问题一直以来都是一个非常复杂的问题。而RocketMQ的事务消息机制,实际上只保证了整个事务消息的一半, 其保证的是订单系统下单和发消息这两个事件的事务一致性, 而对下游服务的事务并没有保证. 但即便如此, 也是分布式事务的一个很好的降级方案.
(1) 同步刷盘
可以简单的把RocketMQ的刷盘方式 flushDiskType
配置成同步刷盘就可以保证消息在刷盘过程中不会丢失.
使用 Dledger 技术搭建的 RocketMQ 集群中, Dledger 会通过两阶段提交的方式保证文件在主从之间成功同步.
简单来说, 数据同步会通过两个阶段: 一个是
uncommitted
阶段, 一个是commited
阶段.Leader Broker上的 Dledger 收到一条数据后, 会标记为
uncommitted
状态, 然后他通过自己的 DledgerServer 组件将这个uncommitted
数据发给Follower Broker的 DledgerServer 组件。接着 Follower Broker的 DledgerServer收到
uncommitted
消息之后, 必须返回一个ack
给 Leader Broker的 Dledger. 然后如果Leader Broker 收到超过半数的 Follower Broker 返回的ack
之后, 就会把消息标记为committed
状态.再接下来, Leader Broker上的 DledgerServer 就会发送
committed
消息给 Follower Broker上的 DledgerServer, 让他们把消息也标记为committed
状态. 这样, 就基于Raft协议完成了两阶段的数据同步.
正常情况下, 消费者端都是需要先处理本地事务然后再给MQ一个ACK响应,这时MQ就会修改 offset,将消息标记为已消费, 从而不再往其他消费者推送消息. 所以在Broker的这种重新推送机制下, 消息是不会在传输过程中丢失的/ 但是也会有下面这种情况会造成服务端消息丢失:
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_4");
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
new Thread(){
public void run(){
//处理业务逻辑
System.out.printf("%s Receive New Messages: %s %n",
Thread.currentThread().getName(), msgs);
}
};
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
这种异步消费的方式, 就有可能造成消息状态返回后消费者本地业务逻辑处理失败造成消息丢失的可能.
NameServer 在RocketMQ中, 扮演的是一个路由中心的角色, 提供到 Broker 的路由功能。而Kafka是用 zookeeper 和一个作为Controller的Broker一起来提供路由服务, 整个功能是相当复杂的; RabbitMQ是由每一个Broker来提供路由服务. 而只有RocketMQ把这个路由中心单独抽取了出来, 并独立部署.
NameServer 集群中任意多的节点挂掉, 都不会影响他提供的路由功能. 那如果集群中所有的NameServer节点都挂了呢?
大多数人会认为在生产者和消费者中都会有全部路由信息的缓存副本, 那整个服务可以正常工作一段时间. 其实这个问题可以做实验去验证, 当 NameServer 全部挂了后, 生产者和消费者是立即就无法工作的.
那对于消息不丢失的问题, 在这种情况下, RocketMQ相当于整个服务都不可用, 那自身肯定无法给我们消息不丢失的保障. 只能自己设计一个降级方案来处理这个问题。例如在订单系统中, 如果多次尝试发送RocketMQ不成功, 那就只能另外找给地方(Redis、文件或者内存等)把订单消息缓存下来, 然后起一个线程定时的扫描这些失败的订单消息, 尝试往RocketMQ发送. 这样等RocketMQ的服务恢复过来后, 就能第一时间把这些消息重新发送出去.整个这套降级的机制, 都是必须要进行精心地考虑设计的.
完整进行分析之后, 整个RocketMQ消息零丢失的方案其实很简洁明了:
那这套方案是不是就非常完美呢? 其实很明显, 这整套的消息零丢失方案, 在各个环节都大量的降低了系统的处理性能以及吞吐量. 在很多场景下, 这套方案带来的性能损失的代价可能远远大于部分消息丢失的代价. 所以, 当在设计RocketMQ使用方案时, 需要根据实际的业务情况来考虑. 例如: 若针对所有服务器都在同一个机房的场景, 完全可以把Broker配置成异步刷盘来提升吞吐量. 而在有些对消息可靠性要求没有那么高的场景, 在生产者端就可以采用其他一些更简单的方案来提升吞吐 而采用定时对账, 补偿的机制来提高消息的可靠性. 而如果消费者不需要进行消息存盘, 那使用异步消费的机制带来的性能提升也是非常显著的.
如果我们有个大数据系统,需要对业务系统的日志进行收集分析, 这时候为了减少对业务系统的影响, 通常都会通过MQ来做消息中转. 而这时候, 对消息的顺序就有一定的要求. 例如考虑下面这一系列的操作:
这样一组操作, 正常用户积分要变成9分. 但是如果顺序乱了, 这个结果就全部对不了. 这时, 就需要对这一组操作, 保证消息都是有序的.
MQ的顺序问题分为全局有序和局部有序:
首先 需要分析下这个问题, 在通常的业务场景中, 全局有序和局部有序哪个更重要? 其实在大部分的MQ业务场景, 我们只需要能够保证局部有序就可以啦. 例如我们用QQ聊天, 只需要保证一个聊天窗口里的消息有序就可以. 而对于电商订单场景, 也只要保证一个订单的所有消息是有序的就可以了. 至于全局消息的顺序, 并不会太关心. 而通常意义下, 全局有序都可以压缩成局部有序的问题. 例如以前我们常用的聊天室, 就是个典型的需要保证消息全局有序的场景. 但是这种场景, 通常可以压缩成只有一个聊天窗口的QQ来理解. 即整个系统只有一个聊天通道, 这样就可以用QQ那种保证一个聊天窗口消息有序的方式来保证整个系统的全局消息有序.
然后 落地到RocketMQ. 通常情况下, 发送者发送消息时, 会通过MessageQueue轮询的方式保证消息尽量均匀的分布到所有的MessageQueue上, 而消费者也就同样需要从多个MessageQueue上消费消息. 而MessageQueue是RocketMQ存储消息的最小单元, 互相之间的消息都是互相隔离的, 在这种情况下, 是无法保证消息全局有序的.
而对于局部有序的要求, 只需要将有序的一组消息都存入同一个MessageQueue里, 这样MessageQueue的FIFO设计天生就可以保证这一组消息的有序. RocketMQ中, 可以在发送者发送消息时指定一个MessageSelector对象, 让这个对象来决定消息发入哪一个MessageQueue. 这样就可以保证一组有序的消息能够发到同一个MessageQueue里.
通常所谓的保证Topic全局消息有序的方式, 就是将Topic配置成只有一个MessageQueue队列(默认是4个). 这样天生就能保证消息全局有序了. 这个说法其实就是将聊天室场景压缩成只有一个聊天窗口的QQ一样的理解方式. 而这种方式对整个Topic的消息吞吐影响是非常大的, 如果这样用, 基本上就没有用MQ的必要了.
在正常情况下, 使用MQ都会要尽量保证他的消息生产速度和消费速度整体上是平衡的, 但是如果部分消费者系统出现故障, 就会造成大量的消息积累. 这类问题通常在实际工作中会出现得比较隐蔽. 例如某一天一个数据库突然挂了, 大家大概率就会集中处理数据库的问题. 等好不容易把数据库恢复过来了, 这时基于这个数据库服务的消费者程序就会积累大量的消息, 或者网络波动等情况, 也会导致消息大量的积累.这在一些大型的互联网项目中, 消息积压的速度是相当恐怖的.
对于RocketMQ来说, 有个最简单的方式来确定消息是否有积压: 使用web控制台, 就能直接看到消息的积压情况.
在Web控制台的主题页面, 可以通过 Consumer管理 按钮实时看到消息的积压情况.
另外, 也可以通过mqadmin指令在后台检查各个Topic的消息延迟情况.
还有RocketMQ也会在自身的 ${storePathRootDir}/config
目录下落地一系列的json文件, 也可以用来跟踪消息积压情况.
根据RocketMQ的负载均衡的特性:
若 Topic下 的MessageQueue配置得是足够多的, 那每个Consumer实际上会分配多个MessageQueue来进行消费. 这个时候, 就可以简单的通过增加Consumer的服务节点数量来加快消息的消费, 等积压消息消费完了, 再恢复成正常情况. 最极限的情况是把Consumer的节点个数设置成跟MessageQueue的个数相同. 但是如果此时再继续增加Consumer的服务节点就没有用了.
而如果Topic下的MessageQueue配置得不够多的话, 那就不能用上面这种增加Consumer节点个数的方法. 这时如果要快速处理积压的消息, 可以创建一个新的Topic, 配置足够多的MessageQueue. 然后把所有消费者节点的目标Topic转向新的Topic, 并紧急上线一组新的消费者, 只负责消费旧Topic中的消息, 并转储到新的Topic中, 这个速度是可以很快的. 然后在新的Topic上, 就可以通过增加消费者个数来提高消费速度, 之后再根据情况恢复成正常情况.
在官网中,还分析了一个特殊的情况: 如果RocketMQ原本是采用的普通方式搭建主从架构, 而现在想要中途改为使用Dledger高可用集群, 这时候如果不想历史消息丢失, 就需要先将消息进行对齐, 也就是要消费者把所有的消息都消费完, 再来切换主从架构. 因为Dledger集群会接管RocketMQ原有的CommitLog日志, 所以切换主从架构时, 如果有消息没有消费完, 这些消息是存在旧的CommitLog中的, 就无法再进行消费了. 这个场景下也是需要尽快的处理掉积压的消息.