Broker是RocketMQ的核心,大部分“重量级”工作都是由Broker完成的,包括接收Producer发过来的信息、处理Consumer的消费消息请求、消息的持久化存储、消息的HA机制以及服务端过滤功能等。
一、消息存储文件
分布式队列因为有高可靠性的要求,所以数据要通过磁盘进行持久化存储。用磁盘存储消息,速度会不会很慢呢?能满足实时性和高吞吐量的要求吗?
实际上,磁盘有时候会比你想象的快很多,有时候也会比你想象的慢很多,关键在如何使用,使用得当,磁盘的速度完全可以匹配上网络的数据传输速度。目前的高性能磁盘,顺序写速度可以达到600MB/s,超过了一般网卡的传输速度,这是磁盘比想象的快的地方。但是磁盘随机写的速度只有大概100KB/s,和顺序写的性能相差6000倍!
RocketMQ主要存储的文件包括Comitlog文件、ConsumeQueue文件、IndexFile文件。RocketMQ将所有主题的消息存储在同一个文件中,确保消息发送时顺序写文件,尽最大的能力确保消息发送的高性能与高吞吐量。但由于消息中间件一般是基于消息主题的订阅机制,这样便给按照消息主题检索消息带来了极大的不便。为了提高消息消费的效率,RocketMQ引入了ConsumeQueue消息队列文件,每个消息主题包含多个消息消费队列,每一个消息队列有一个消息文件。IndexFile索引文件,其主要设计理念就是为了加速消息的检索性能,根据消息的属性快速从Commitlog文件中检索消息。RocketMQ是一款高性能的消息中间件,存储部分的设计是核心,存储的核心是IO访问性能。
1)CommitLog:消息存储文件,所有消息主题的消息都存储在CommitLog文件中。
2)config:运行期间一些配置信息,主要包括:
consumerFilter.json:主题消息过滤信息
consumerOffset.json:集群消费模式消息消费进度
delayOffset.json:延时消息队列拉取进度
subscriptionGroup.json:消息消费组配置信息
topics.json:topic配置属性
3)ConsumeQueue:消息消费队列,消息到达CommitLog文件后,将异步转发到消息消费队列,供消息消费者消费。
4)IndexFile:消息索引文件,主要存储消息Key与Offset的对应关系。
5)abort:如果存在abort文件说明Broker非正常关闭,该文件默认启动时创建,正常退出之前删除。
6)checkpoint:文件检测点,存储commitlog文件最后一次刷盘时间戳,comsumequeue最后一次刷盘时间,Index索引文件最后一次刷盘时间戳。
Commitlog文件
该目录下的文件主要存储消息,commitlog文件默认大小为1G,可在broker配置文件中设置mappedFileSizeCommitlog属性来修改。
ConsumeQueue文件
RocketMQ基于主题订阅模式实现消息消费,消费者关心的是一个主题下的所有消息,但由于同一主题的消息不连续地存储在commitlog文件中,试想一下如果消息消费者直接从消息存储文件(CommitLog)中去遍历查找订阅主题下的消息,效率将极其低下,RocketMQ为了适应消息消费的检索需求,设计了消息消费队列文件(ConsumeQueue),该文件可以看成是CommitLog关于消息消费的“索引”文件,consumeQueue的第一级目录为消息主题,第二级目录为主题的消息队列。
每一个Consumequeue条目不会存储消息的全量信息,单个ConsumeQueue文件中默认包含30万个条目,单个文件的长度为30w*20字节,单个ConsumeQueue文件可以看作是一个ConsumeQueue条目的数组,其下标为ConsumeQueue的逻辑偏移量,消息消费进度存储的偏移量即逻辑偏移量。ConsumeQueue即为Commitlog文件的索引文件,其构建机制是当消息到达Commitlog文件后,由专门的线程产生消息转发任务,从而构建消息消费队列文件与下文提到的索引文件。
Index索引文件
消息消费队列是RocketMQ专门为消息订阅构建的索引文件,提高根据主题与消息队列检索消息的速度。另外RocketMQ引入了Hash索引机制为消息建立索引,HashMap的设计包含两个基本点:Hash槽与Hash冲突的链表结构。RocketMQ索引文件布局如图,IndexFile总共包含IndexHeader、Hash槽、Hash条目(数据):
- Hash槽:一个IndexFile默认包含500万个Hash槽,每个Hash槽存储的是落在该Hash槽的hashcode最新的 Index 索引。
- Index条目列表:默认一个索引文件包含2000w个条目,每一个Index条目会存储key的hashcode及消息对应的物理偏移量。RocketMQ将消息索引键与消息偏移量映射关系写入到IndexFile。
值得关注的是,IndexFile条目中存储的不是消息索引key而是消息属性key的HashCode,在根据key查找时需要根据消息物理偏移量找到消息进而再验证消息key的值,之所以只存储消息HashCode而不存储具体的key,是为了将Index条目设计为定长结构,才能方便地检索与定位条目。
CheckPoint文件
checkpoint的作用是记录commitlog、ConsumeQueue、index文件的刷盘时间点,文件固定长度为4k,其中只用该文件的前面24个字节。
二、消息发送存储流程
Step1:如果当前Broker停止工作或Broker为SLAVE角色或当前Rocket不支持写入则拒绝消息写入;如果消息主题长度超过127个字符、消息属性长度超过32767个字符将拒绝该消息写入。
Step2:如果消息的延迟级别大于0,将消息的原主题名称与原消息队列ID存入消息属性中,用延迟消息主题SCHEDULE_TOPIC、消息队列ID更新原先消息的主题与队列,这是并发消息消费重试关键的一步。
Step3:获取当前可以写入的Commitlog文件。Commitlog文件存储目录为${ROCKET_HOME}/store/commitlog目录,每一个文件默认1G,一个文件写满后再创建另外一个,以该文件中第一个偏移量为文件名,偏移量小于20位用0补齐。这样根据物理偏移量能快速定位到消息。MappedFileQueue可以看作是${ROCKET_HOME}/store/commitlog文件夹,而MappedFile则对应该文件夹下一个个的文件。
Step4:在写入CommitLog之前,先申请putMessageLock,也就是将消息存储到CommitLog文件中是串行的。
Step5:设置消息的存储时间,如果mappedFile为空,表明${ROCKET_HOME}/store/commitlog目录下不存在任何文件,说明本次消息是第一次消息发送,用偏移量0创建第一个commit文件,文件为00000000000000000000,如果文件创建失败,抛出CREATE_MAPEDFILE_FAILED,很有可能是磁盘空间不足或权限不够。
Step6:将消息追加到MappedFile中。
Step7:创建全局唯一消息ID。
Step8:获取该消息在消息队列的偏移量。CommitLog中保存了当前所有消息队列的当前待写入偏移量。
Step9 :根据消息体的长度、主题的长度、属性的长度结合消息存储格式计算消息的总长度。如果消息长度+END_FILE_MIN_BLANK_LENGTH大于CommitLog文件的空闲空间,则返回AppendMessageStatus.END_OF_FILE, Broker会重新创建一个新的CommitLog文件来存储该消息。
Step10:将消息内容存储到ByteBuffer中,然后创建AppendMessageResult。这里只是将消息存储在MappedFile对应的内存映射buffer中,并没有刷写到磁盘。
Step11 :更新消息队列逻辑偏移量。处理完消息追加逻辑后将释放putMessageLock锁。
Step12:DefaultAppendMessageCallback#doAppend只是将消息追加在内存中,需要根据是同步刷盘还是异步刷盘方式,将内存中的数据持久化到磁盘。然后执行HA主从同步复制。
RocketMQ通过使用内存映射文件来提高IO访问性能,不论是Commitlog,ConsumerQueue,还是IndexFile,单个文件都被设计为固定长度,如果一个文件写满以后再创建一个新文件,文件名就为该文件第一条消息对应的全局物理偏移量。由于使用了内存映射,只要存在于存储目录下的文件,都需要对应创建内存映射文件,如果不定时将已消费的消息从存储文件中删除,会造成极大的内存压力与资源浪费,所以RocketMQ采取定时删除存储文件的策略,也就是说在存储文件中,第一个文件不一定是00000000000000000000,因为该文件在某一时刻会被删除。
TransientStorePool:短暂的存储池。RocketMQ单独创建一个MappedByteBuffer内存缓存池,用来临时存储数据,数据先写入该内存映射中,然后由commit线程定时将数据从该内存复制到与目的物理文件对应的内存映射中。RokcetMQ引入该机制主要的原因是提供一种内存锁定,将当前堆外内存一直锁定在内存中,避免被进程将内存交换到磁盘。
三、实时更新ConsumeQueue、IndexFile文件
消息消费队列文件、消息属性索引文件都是基于CommitLog文件构建的,当消息生产者提交的消息存储在Commitlog文件中,ConsumeQueue、IndexFile需要及时更新,否则消息无法及时被消费,根据消息属性查找消息也会出现较大延迟。RocketMQ通过开启一个线程ReputMessageServcie来准实时转发CommitLog文件更新事件,相应的任务处理器根据转发的消息及时更新ConsumeQueue、IndexFile文件。
Broker服务器在启动时会启动ReputMessageService线程,并初始化一个非常关键的参数reputFfomOffset,该参数的含义是ReputMessageService从哪个物理偏移量开始转发消息给ConsumeQueue和IndexFile。如果允许重复转发,reputFromOffset设置为CommitLog的提交指针;如果不允许重复转发,reputFromOffset设置为Commitlog的内存中最大偏移量。ReputMessageService线程每执行一次任务推送休息1毫秒就继续尝试推送消息到消息消费队列和索引文件。
Step1:返回reputFromOffset偏移量开始的全部有效数据(commitlog 文件)。然后循环读取每一条消息。
Step2:从result返回的ByteBuffer中循环读取消息,一次读取一条,创建DispatchRequest对象。如果消息长度大于0,则调用doDispatch方法。最终将分别调用 CommitLogDispatcherBuildConsumeQueue (构建消息消费队 )、CommitLogDispatcherBuildlndex (构建索引文件)
根据消息更新ConumeQueue
消息消费队列转发任务实现类为:CommitLogDispatcherBuildConsumeQueue。
Step1:根据消息主题与队列ID,先获取对应的ConumeQueue文件,其逻辑比较简单,因为每一个消息主题对应一个消息消费队列目录,然后主题下每一个消息队列对应一个文件夹,然后取出该文件夹最后的ConsumeQueue文件即可。
Step2:根据consumeQueueOffset计算ConumeQueue中的物理地址,将内容追加到ConsumeQueue的内存映射文件中(本操作只追加并不刷盘), ConumeQueue的刷盘方式固定为异步刷盘模式。
根据消息更新Index索引文件
Hash索引文件转发任务实现类:CommitLogDispatcherBuildIndex。
获取或创建IndexFile文件并获取所有文件最大的物理偏移量。如果该消息的物理偏移量小于索引文件中的物理偏移,则说明是重复数据,忽略本次索引构建。如果消息的唯一键不为空,则添加到Hash索引中,以便加速根据唯一键检索消息。
四、消息队列与索引文件恢复
由于 RocketMQ 存储首先将消息全量存储在 CommitLog 文件中,然后异步生成转发任务更新 ConsumeQueue、Index 文件。如果消息成功存储到 CommitLog 文件中,转发任务未成功执行,此时消息服务器 Broker 由于某个原因宕机,导致 CommitLog、ConsumeQueue、IndexFile文件数据不一致。如果不加以人工修复的话,会有一部分消息即便在 CommitLog 文件中存在,但由于并没有转发到 ConsumeQueue,这部分消息将永远不会被消费者消费。
RocketMQ是如何使CommitLog、ConsumeQueue达到最终一致性的呢?
RocketMQ关于存储文件的加载流程
Step1:判断上一次退出是否正常。Broker在启动时创建abort文件,在退出时通过注册 JVM 钩子函数删除 abort 文件。如果下一次启动时存在 abort 文件。 说明 Broker 是异常退出的,CommitLog 与 ConsumeQueue 数据有可能不一致,需要进行修复。
Step2:加载延迟队列,RocketMQ定时消息相关。
Step3:加载Commitlog文件,加载 ${ROCKET_HOME}/store/commitlog 目录下所有文件并按照文件名排序。
Step4:加载消息消费队列,其思路与Commitlog大体一致,遍历消息消费队列根目录,获取该Broker存储的所有主题,然后遍历每个主题目录,获取该主题下的所有消息消费队列,然后分别加载每个消息消费队列下的文件,构建ConumeQueue对象。
Step5:加载存储检测点,检测点主要记录 commitLog 文件、ConsumeQueue 文件、Index 索引文件的刷盘点。
Step6:加载索引文件,如果上次异常退出,而且索引文件上次刷盘时间小于该索引文件最大的消息时间戳该文件将立即销毁。
Step7:根据 Broker 是否是正常停止执行不同的恢复策略。
Step8:恢复 ConsumeQueue 文件后,将在 CommitLog 实例中保存每个消息消费队列当前的存储逻辑偏移量,这也是消息中不仅存储主题、消息队列 ID 还存储了消息队列偏移量的关键所在。
Broker 正常停止文件恢复
Step1:Broker正常停止再重启时,从倒数第三个文件开始进行恢复,如果不足 3 个文件,则从第一个文件开始恢复。
Step2:遍历 CommitLog 文件,每次取出一条消息,如果查找结果为 true 并且消息的长度大于 0 表示消息正确,校验指针向前移动到本消息的长度;如果查找结果为 true 并且消息的长度等于 0,表示已到该文件的末尾,如果还有下一个文件,则循环此步骤,否则跳出循环;如果查找结构为 false,表明该文件未填满所有消息,跳出循环,结束遍历文件。
Step3:更新MappedFileQueue的flushedWhere与committedPosition指针。
Step4:删除offset之后的所有文件。遍历目录下的文件,如果offset小于文件的起始偏移量,说明该文件是有效文件后面创建的,加入到待删除文件列表。
正常停止的时,Broker 会将 IndexFile 和 ConsumeQueue 都更新好,所以如果 Broker 正常停止的话,恢复过程只是修正commit 指针和 flush 指针。
Broker 异常停止文件恢复
异常文件恢复的步骤与正常停止文件恢复的流程基本相同,其主要差别有两个。首先,正常停止默认从倒数第三个文件开始进行恢复,而异常停止则需要从最后一个文件往前走,找到第一个消息存储正常的文件。其次,如果 CommitLog 目录没有消息文件,如果在消息消费队列 ConsumeQueue 目录下存在文件,则需要销毁。
Step1:首先判断文件的魔数。如果文件中第一条消息的存储时间等于 0,说明该消息存储文件中未存储任何消息。
Step2:对比文件第一条消息的时间戳与检测点,文件第一条消息的时间戳小于文件检测点 checkpoint 说明该文件部分消息是可靠的,则从该文件开始恢复。
Step3:如果找到MappedFile,则遍历 MappedFile 中的消息,验证消息的合法性,并将消息重新转发到消息消费队列与索引文件。
Step4:如果未找到有效MappedFile,则设置 CommitLog 目录的 flushedWhere、 committedWhere指针都为 0,并销毁消息消费队列文件。
存储启动时所谓的文件恢复主要完成flushedPosition、committedWhere指针的设置、消息消费队列最大偏移量加载到内存,并删除flushedPosition之后所有的文件。如果Broker异常启动,在文件恢复过程中,RocketMQ会将最后一个有效文件中的所有消息重新转发到消息消费队列与索引文件,确保不丢失消息,但同时会带来消息重复的问题,纵观RocktMQ的整体设计思想,RocketMQ保证消息不丢失但不保证消息不会重复消费,故消息消费业务方需要实现消息消费的幂等设计。
五、文件刷盘机制
RocketMQ 的存储与读写是基于JDK NIO 的内存映射机制(MappedByteBuffer)的,消息存储时首先将消息追加到内存,再根据配置的刷盘策略在不同时间进行刷写磁盘,默认为异步刷盘。索引文件的刷盘并不是采取定时刷盘机制,而是每更新一次索引文件就会将上一次的改动刷写到磁盘。
SYNC_FLUSH (同步刷盘):消息追加到内存映射文件的内存中后,立即将数据从内存刷写到磁盘文件。
ASYNC_FLUSH (异步刷盘):在消息追加到内存后立刻返回给消息发送端。RocketMQ 使用一个单独的线程按照某一个设定的频率执行刷盘操作。
六、过期文件删除机制
由于 RocketMQ 操作 CommitLog、ConsumeQueue文件是基于内存映射机制并在启动的时候会加载 CommitLog、ConsumeQueue 目录下的所有文件,为了避免内存与磁盘的浪费,不可能将消息永久存储在消息服务器上,所以需要引人一种机制来删除己过期的文件。RocketMQ 顺序写 CommitLog 文件、ConsumeQueue 文件,所有写操作全部落在最后一个 CommitLog 或 ConsumeQueue 文件上,之前的文件在下一个文件创建后将不会再被更新。
RocketMQ 清除过期文件的方法是: 如果非当前写文件在一定时间间隔内没有再次被更新,则认为是过期文件,可以被删除,RocketMQ 不会关注这个文件上的消息是否全部被消费。默认每个文件的过期时间为 72 小时 ,通过在 Broker 配置文件中设置 fileReservedTime 来改变过期时间,单位为小时。
RocketMQ 会每隔 10s 调度一次清除过程,检测是否需要清除过期文件。RocketMQ会每隔10s调度一次cleanFilesPeriodically,检测是否需要清除过期文件。执行频率可以通过设置cleanResourceInterval,默认为10s。
RocketMQ在如下三种情况任意之一满足的情况下将继续执行删除文件操作。
- 指定删除文件的时间点,RocketMQ通过deleteWhen设置一天的固定时间执行一次删除过期文件操作,默认为凌晨4点。
- 磁盘空间是否充足,如果磁盘空间不充足,则返回true,表示应该触发过期文件删除操作。
- 预留,手工触发,可以通过调用excuteDeleteFilesManualy方法手工触发过期文件删除,目前RocketMQ暂未封装手工触发文件删除的命令。
七、总结
Commitlog, 消息存储文件,RocketMQ为了保证消息发送的高吞吐量,采用单一文件存储所有主题的消息,保证消息存储是完全的顺序写,但这样给文件读取同样带来了不便,为此,RocketMQ为了方便消息消费构建了消息消费队列文件,基于主题与队列进行组织,同时RocketMQ为消息实现了Hash索引,可以为消息设置索引键,根据索引能快速从Commitlog文件中检索消息。
当消息达到Commitlog文件后,会通过ReputMessageService线程接近实时地将消息转发给消息消费队列文件与索引文件。为了安全起见,RocketMQ引入了abort文件,记录Broker的停机是正常关闭还是异常关闭,在重启Broker时为了保证Commitlog文件、消息消费队列文件与Hash索引文件的正确性,分别采取不同的策略来恢复文件。
RocketMQ不会永久存储消息文件,消息消费队列文件,而是启用文件过期机制并在磁盘空间不足或默认在凌晨4点删除过期文件,文件默认保存72小时,并且在删除文件时并不会判断该消息文件上的消息是否被消费。