RocketMQ 原理
- 生产者:如何选择Message Queue、如何发送顺序消息、事务消息、延迟消息
- Broker:消息如何存储、文件如何清理、主从同步与故障转移
- 消费者:如何实现负载、rebalance、重试与死信队列
一、生产者
1.1、消息发送规则
生产者如何选择Message Queue?
从producer的send方法开始追踪,发现调用的是MQFaultStrage的选择队列的方法,这个类是MQ负载均衡的核心类,MessageQueueSelector有三个实现类
- (a)SelectMessageQueueByHash(默认),轮询的方式
- (b)SelectMessageQueueByRandom:随机选择一个队列
- (c)SelectMessageQueueByMachineRoom:返回空,没有实现
也可以自定义MessageQueueSelector策略。
1.2、顺序消息
场景:一个客户提交了一笔订单,然后支付,后面又退款了,这三条消息分别是,订单消息、支付消息、退款消息。
有序分为全局有序和局部有序,全局有序就是不管有几个生产者在写入,有几个消费者,消费的顺序和生产的顺序都是一致,这个实现比较麻烦,而且即使实现了也会对MQ的性能产生很大影响,所以顺序消息其实就是局部有序。
要保证消息有序:
1、生产者发送消息的时候,到达Broker应该是有序的。所以对于生产者,不能使用多线程异步发送,而是单线程顺序发送。
2、写入Broker的时候,应该是顺序写入的。也就是相同主题的消息应该集中写 入,选择同一个Message Queue,而不是分散写入。
3、消费者消费的时候只能有一个线程。否则由于消费的速率不同,有可能出现 记录到数据库的时候无序。
1.3、事务消息
场景:随着应用的拆分,单体架构变成分布式架构,每个服务模块都有自己的数据库。一个业务流程的完成,需要经过多次接口的调用,或者MQ消息的发送及消费。如何保证多个DML操作的原子性,本地事务可以保证原子性(undo log),这个就是分布式事务问题。
如何让本地数据库操作和消息发送都成功?
实现原理:
- 1、生产者先发送一条消息到Broker,把这个消息状态标记为“未确认”。
- 2、Broker通知生产者消息接收成功,现在你可以执行本地事务。
- 3、生产者执行本地事务。 < 本地事务成功 or 失败? >
- 4、确认 or 丢弃
整体流程:
- 1、生产者先发送一条消息到Broker。
- 2、MQ服务端持久化成功之后,向发送方ACK确认消息已经发送成功,此时消息为half 半消息。
- 3、Broker通知生产者消息接收成功后,可以开始执行本地事务。
- 3、生产者执行本地事务 < 本地事务成功 or 失败? >,发送方根据本地事务执行结果,向Broker提交二次确认(commit or rollback)
- 4、MQ server 收到 commit 状态,则将消息标记为可投递,订阅方(消费者)将收到消息;如果收到rollback 状态,则删除消息。
- 5、如果存在网络问题或者发送方重启等特殊情况,二次确认最终没有到达MQ server ,则经过固定时间MQ server 对消息发起回查。
- 6、发送方收到回查后,检查对应的本地事务的最终结果。
- 7、发送方根据检查结果,再次提交二次确认,MQ server 按照步骤4对half消息进行操作。
生产者执行本地事务,会有三个状态:
- commit:表示事务消息被提交,会被正确分发给消费者
- rollback:表示事务消息被回滚,消息删除
- unknown:表示不确定事务有没有成功,broker会主动发起回查
默认回查次数15次,第一次间隔6s,后续每次间隔60s。
1.4、延迟消息
场景:订单超时未支付自动关闭
在开源的版本中,延迟消息只能支持特定等级的消息。商业版本可以任意指定时间。
一共18个等级:
1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
实现原理:
broker端内置延迟消息处理能力,将延迟消息通过一个临时存储进行暂存,到期后才投递给目标topic。
1、producer 将一个消息发送给topic中;
2、broker判断这个是个延迟消息后,将其通过临时存储进行暂存;
3、broker内部通过一个延迟服务(delay service)检查消息是否到期,将到期的 消息投递到目前topic中;
4、消费者消费目前topic中的延迟消息。
临时存储和延迟服务都是在broker中实现,对业务透明。RocketMQ的消息重试也是基于延迟消息来完成的。在消息消费失败后,将其重新当做延迟消息投递回broker。
二、Broker
RocketMQ的消息存储和Kafka有所不同,没有按照分区存储消息。
RocketMQ官方解释:
- 每个分区存储的整个消息数据,尽管每个分区都是顺序写,但是随着分区的数量增加,从操作系统角度来看,写入变成了随机写。
- 由于数据文件分散,很难一次把多个数据文件刷盘。
所以 RocketMQ 设计了一种新的文件存储方式,就是所有的topic的所有消息全部写入同一个文件,这叫做集中式存储,这样就能保证绝对的顺序写。
优点:
- 队列轻量化,单个队列数据量非常少
- 对磁盘的极致顺序写,避免了磁盘竞争,不会因为队列增加导致随机写。
当然这样会导致消费变的复杂了,Kafka中一个topic 下面是partition有独立的文件,只要在topic里面找消息就好了,Kafka把这个 consumer group跟topic的offset关系保存在一个特殊的topic中,而RocketMQ变成了一个统一的巨大的commitLog中去查找消息,需要遍历全部的消息,效率太低了。怎么处理呢?
每个consumer group 只查找自己topic 的offset 信息,可以为每个consumer group 把它们消费的topic的最后消费到的offset单独存储在一个地方,这个存储消息offset偏移量的对象就是consumer queue。
也就是说,消息在broker存储的时候,不仅写入commitLog,同时也把commitLog中的最新的offset异步写入对应的consumer queue。
消费者下消费消息的时候,先从consumer queue读取持久化消息的起始物理位置offset,大小size和消息Tag的hashcode,随后再从commitLog中进行读取等待拉取的消息的真正的实体内容。
总结:
- consumer queue 里面是消息的索引,没有存消息实体内容。
- 写是完全的顺序写,但是读却变成了完全的随机读(对应commitLog)
- 消费消息,先读consumer queue,在读commitLog,增加了读的开销
2.1、物理存储文件分析
commitLog:
- 文件集合,默认大小1G,第一个文件写满了,第二个文件会以初始偏移量命名,比如起始偏移量10000000,第二个文件名为10000000,以此类推。
和Kafka一样,消费完之后不会删除。
- 优点是可以被多个 consumer group重复消费, 只要修改 consumer group就可以从头消费,每个 consumer group 自己维护自己的offset。
- 另一个就是支持消息回溯,随时可以搜索。
consumer queue:
- 一个topic有多个consumer queue,每一个代表一个逻辑队列,这里存放消息在commitLog的偏移值offset、大小size、和Tag属性。
- consumer queue对应每个topic和queueId下面的文件,单个文件由30w条数据组成,大小600万个字节(约6M),当一个consumer queue文件写满之后,则写入下一个文件。
index file :
- Message有一个keys参数,它用来检索消息的,所以如果创建了keys,服务端就会创建索引文件,以空格分割每个关键字都会产生一个索引。单个indexFile可以保持2000w个索引,文件固定大小约400M。
- 索引的目的就是根据关键字快速定位消息
- 索引结构是HashMap,就是一种hash索引,由于是hash索引可以尽量设置为唯一不重复
2.2、存储关键技术(持久化-刷盘)
page cache概念:
cpu要读取或者操作磁盘上的数据,必须把磁盘的数据加载到内存,这个是由硬件结构和访问速度差异决定的,这个加载有个固定的单位,叫Page,标准的页大小是4KB。如果要提升减少磁盘IO,可以把访问过的Page在内存缓存起来,这个内存区域就是page cache。
page cache本身就是对数据文件的预读取,还有一个问题就是虚拟内存分为内核空间和用户空间,page cache属于内核空间,用户空间访问不了,因此读取数据还需要从内核空间copy到用户空间的缓冲区。这个copy过程降低了数据访问的速度,所以这个用的了零拷贝。
把page cache的数据在用户空间做一个地址映射,这样用户空间可以通过指针直接读写page cache,不需要系统调用read、write。
RocketMQ中使用mmap(内存映射),不论commit log还是 consumer queue都采用了mmap,而Kafka使用的是sendfile。
2.3、文件清理策略
哪些文件需要清理?
主要清理是commit log 和 consumer queue 过期文件。
什么情况下这些文件变成过期文件?
默认是超过72小时的文件
过期的文件什么时候删除?
- 通过定时任务,每天4点,删除这些过期文件
- 如果说磁盘已经快写满了?磁盘使用空间超过75%开始删除过期文件,超过85%会开始批量清理文件,不管有没有过期,直到空间充足,超过95%会拒绝写入。
三、消费者
集群消费模式下,如果需要提高消费者的负载能力,必然要增加消费者的数量,消费者的数量增加了,如何保证负载均衡?
3.1、消费端的负载均衡与rebalance
消费者增减时会引起rebalance,所以从消费者启动代码入手,start()里面调用了rebalanceService。在消费者启动或者有消费者挂掉的时候,默认最多20s就会做一次rebalance,让所有的消费者可以尽量的均匀消费队列的消息。
Rebalance的流程如下:
- 消费者启动:当消费者启动时,会向Broker发送心跳信息,表明自己处于可用状态。
- 消费者注册:消费者通过向Name Server注册自己所属的消费者组以及订阅的主题和标签。
- 消息队列分配:Name Server接收到消费者注册信息后,会为消费者组分配消息队列。分配的策略可以是平均分配、哈希分配或者根据消费者配置的规则进行分配。
- 消费者拉取:消费者根据分配到的消息队列进行拉取消息,开始消费。
- 消费者宕机或新加入消费者:如果有消费者宕机或新加入消费者,会触发Rebalance。Rebalance会重新计算消息队列的分配情况。Rebalance是由nameserver执行的操作。它负责重新计算消息队列的分配情况,并将相关信息通知给各个broker。消费者在此过程中不直接参与,它们只负责根据nameserver提供的分配情况来消费消息。
- Rebalance计算:Rebalance计算会根据消费者的在线状态和负载情况,重新分配消息队列给消费者。根据具体的Rebalance算法,可能会考虑消费者的消费进度、消费能力和网络延迟等因素。
- 消费者负载均衡:根据Rebalance计算的结果,Broker会通知各个消费者重新分配消息队列。消费者收到重新分配的消息队列后,会重新拉取消息进行消费。
- 消费者消费:重新分配消息队列后,消费者会从新分配到的消息队列拉取消息,然后进行消费处理。
通过Rebalance,RocketMQ能够保证消费者组中的消费者在消费消息时,能够合理地负载均衡,提高整个系统的消费能力和可用性。
- 分配策略
rocket提供多个分配策略,消费机器不同但是内部代码是相同的,所以会选择相同的分配策略,通过相同的策略来保证不同的消费者执行Rebalance后得到相同的分配结果。这里大家可能会提问是否会存在一些切换的间隙,分配不一致的情况呢?的确存在,这时可能会导致不同消费者重复消费同一个queue的消息,但是这种状态是短暂的,而且可以通过幂等校验来规避业务上的影响。通过多次的Rebalance最终会达到稳定。稳定会持续到下一次平衡关系的破坏。 - 消费关系分配完成后客户端会对比之前的消费关系是否发生改变,如果有新增的queue则将其加入到pullRequest请求队列中,如果之前消费的某个queue被移除,则将其标记为drop,后续流程通过这个标记会将其从pullRequest队列中剔除
AllocateMessageQueueStrategy 提供了6种策略也可以自定义实现
- AllocateMessageQueueAveragely:连续分配(默认)
- AllocateMessageQueueAveragelyByCircle:每人轮流一个
- AllocateMessageQueueByConfig:通过配置
- AllocateMessageQueueConsistentHash:一致性哈希
- AllocateMessageQueueByMachineRoom:指定一个broker的topic中的 queue消费
- AllocateMachineRoomNearby:按Broker的机房就近分配
建议:队列的数量>=消费者数量
3.2、消费端重试与死信队列
消费者正常消费时return ConsumeConcurrentlyStatus.CONSUME_SUCCESS,就是发送ACK,告诉broker消费成功了,可以更新offset;
如果消费者出现异常(网络故障,数据库不可用),返回给broker的是REConsume_later,表示稍后重试。
服务端会为consumer group 创建一个%RETRY%开头的重试队列。重试队列的消息一段时间后再次发送给消费者,如果还有异常,会再次进入重试队列,重试的时间间隔会不断衰减,10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h最多16次,这个就是延迟消息的时间等级,从level 3 开始。
重试16次之后 没有成功,这个消息就会丢到死信队列。broker会创建一个死信队列,名字是%DLQ%+ConsumerGroupName,最后人工技改,可以写一个线程专门订阅死信队列 消费消息。