说在前面

RocketMQ在底层存储上借鉴了Kafka,但是也有它独到的设计,本文主要关注深刻影响着RocketMQ性能的底层文件存储结构,中间会穿插一点点Kafka的东西以作为对比。

例子

Commit Log,一个文件集合,每个文件1G大小,存储满后存下一个,为了讨论方便可以把它当成一个文件,所有消息内容全部持久化到这个文件中;Consume Queue:一个Topic可以有多个,每一个文件代表一个逻辑队列,这里存放消息在Commit Log的偏移值以及大小和Tag属性。

为了简述方便,来个例子

假如集群有一个Broker,Topic为binlog的队列(Consume Queue)数量为4,如下图所示,按顺序发送这5条内容各不相同消息。

RocketMQ高性能之底层存储设计_第1张图片

先简单关注下Commit Log和Consume Queue。

RocketMQ高性能之底层存储设计_第2张图片

RMQ的消息整体是有序的,所以这5条消息按顺序将内容持久化在Commit Log中。Consume Queue则用于将消息均衡地排列在不同的逻辑队列,集群模式下多个消费者就可以并行消费Consume Queue的消息。

Page Cache

了解了每个文件都在什么位置存放什么内容,那接下来就正式开始讨论这种存储方案为什么在性能带来的提升。

通常文件读写比较慢,如果对文件进行顺序读写,速度几乎是接近于内存的随机读写,为什么会这么快,原因就是Page Cache。

RocketMQ高性能之底层存储设计_第3张图片

先来个直观的感受,整个OS有3.7G的物理内存,用掉了2.7G,应当还剩下1G空闲的内存,但OS给出的却是175M。当然这个数学题肯定不能这么算。

OS发现系统的物理内存有大量剩余时,为了提高IO的性能,就会使用多余的内存当做文件缓存,也就是图上的buff / cache,广义我们说的Page Cache就是这些内存的子集。

OS在读磁盘时会将当前区域的内容全部读到Cache中,以便下次读时能命中Cache,写磁盘时直接写到Cache中就写返回,由OS的pdflush以某些策略将Cache的数据Flush回磁盘。

但是系统上文件非常多,即使是多余的Page Cache也是非常宝贵的资源,OS不可能将Page Cache随机分配给任何文件,Linux底层就提供了mmap将一个程序指定的文件映射进虚拟内存(Virtual Memory),对文件的读写就变成了对内存的读写,能充分利用Page Cache。不过,文件IO仅仅用到了Page Cache还是不够的,如果对文件进行随机读写,会使虚拟内存产生很多缺页(Page Fault)中断。

RocketMQ高性能之底层存储设计_第4张图片

每个用户空间的进程都有自己的虚拟内存,每个进程都认为自己所有的物理内存,但虚拟内存只是逻辑上的内存,要想访问内存的数据,还得通过内存管理单元(MMU)查找页表,将虚拟内存映射成物理内存。如果映射的文件非常大,程序访问局部映射不到物理内存的虚拟内存时,产生缺页中断,OS需要读写磁盘文件的真实数据再加载到内存。如同我们的应用程序没有Cache住某块数据,直接访问数据库要数据再把结果写到Cache一样,这个过程相对而言是非常慢的。

但是顺序IO时,读和写的区域都是被OS智能Cache过的热点区域,不会产生大量缺页中断,文件的IO几乎等同于内存的IO,性能当然就上去了。

说了这么多Page Cache的优点,也得稍微提一下它的缺点,内核把可用的内存分配给Page Cache后,free的内存相对就会变少,如果程序有新的内存分配需求或者缺页中断,恰好free的内存不够,内核还需要花费一点时间将热度低的Page Cache的内存回收掉,对性能非常苛刻的系统会产生毛刺。

刷盘

刷盘一般分成:同步刷盘和异步刷盘

RocketMQ高性能之底层存储设计_第5张图片

同步刷盘

在消息真正落盘后,才返回成功给Producer,只要磁盘没有损坏,消息就不会丢。

RocketMQ高性能之底层存储设计_第6张图片

一般只用于金融场景,这种方式不是本文讨论的重点,因为没有利用Page Cache的特点,RMQ采用GroupCommit的方式对同步刷盘进行了优化。

异步刷盘

读写文件充分利用了Page Cache,即写入Page Cache就返回成功给Producer,RMQ中有两种方式进行异步刷盘,整体原理是一样的。

RocketMQ高性能之底层存储设计_第7张图片

刷盘由程序和OS共同控制

先谈谈OS,当程序顺序写文件时,首先写到Cache中,这部分被修改过,但却没有被刷进磁盘,产生了不一致,这些不一致的内存叫做脏页(Dirty Page)。

RocketMQ高性能之底层存储设计_第8张图片

脏页设置太小,Flush磁盘的次数就会增加,性能会下降;脏页设置太大,性能会提高,但万一OS宕机,脏页来不及刷盘,消息就丢了。

RocketMQ高性能之底层存储设计_第9张图片

一般不是高配玩家,用OS的默认值就好,如上图。

RocketMQ高性能之底层存储设计_第10张图片

RMQ想要性能高,那发送消息时,消息要写进Page Cache而不是直接写磁盘,接收消息时,消息要从Page Cache直接获取而不是缺页从磁盘读取。

好了,原理回顾完,从消息发送和消息接收来看RMQ中被mmap后的Commit Log和Consume Queue的IO情况。

RMQ发送逻辑

发送时,Producer不直接与Consume Queue打交道。上文提到过,RMQ所有的消息都会存放在Commit Log中,为了使消息存储不发生混乱,对Commit Log进行写之前就会上锁。

RocketMQ高性能之底层存储设计_第11张图片

消息持久被锁串行化后,对Commit Log就是顺序写,也就是常说的Append操作。配合上Page Cache,RMQ在写Commit Log时效率会非常高。

Commit Log持久后,会将里面的数据Dispatch到对应的Consume Queue上。

RocketMQ高性能之底层存储设计_第12张图片

每一个Consume Queue代表一个逻辑队列,是由ReputMessageService在单个Thread Loop中Append,显然也是顺序写。

消费逻辑底层

消费时,Consumer不直接与Commit Log打交道,而是从Consume Queue中去拉取数据

RocketMQ高性能之底层存储设计_第13张图片

拉取的顺序从旧到新,在文件表示每一个Consume Queue都是顺序读,充分利用了Page Cache。

光拉取Consume Queue是没有数据的,里面只有一个对Commit Log的引用,所以再次拉取Commit Log。

RocketMQ高性能之底层存储设计_第14张图片

Commit Log会进行随机读

RocketMQ高性能之底层存储设计_第15张图片

但整个RMQ只有一个Commit Log,虽然是随机读,但整体还是有序地读,只要那整块区域还在Page Cache的范围内,还是可以充分利用Page Cache。

RocketMQ高性能之底层存储设计_第16张图片

在一台真实的MQ上查看网络和磁盘,即使消息端一直从MQ读取消息,也几乎看不到进程从磁盘拉数据,数据直接从Page Cache经由Socket发送给了Consumer。

对比Kafka

文章开头就说到,RMQ是借鉴了Kafka的想法,同时也打破了Kafka在底层存储的设计。

RocketMQ高性能之底层存储设计_第17张图片

Kafka中关于消息的存储只有一种文件,叫做Partition(不考虑细化的Segment),它履行了RMQ中Commit Log和Consume Queue公共的职责,即它在逻辑上进行拆分存,以提高消费并行度,又在内部存储了真实的消息内容。

RocketMQ高性能之底层存储设计_第18张图片

这样看上去非常完美,不管对于Producer还是Consumer,单个Partition文件在正常的发送和消费逻辑中都是顺序IO,充分利用Page Cache带来的巨大性能提升,但是,万一Topic很多,每个Topic又分了N个Partition,这时对于OS来说,这么多文件的顺序读写在并发时变成了随机读写。

RocketMQ高性能之底层存储设计_第19张图片

这时,不知道为什么,我突然想起了「打地鼠」这款游戏。对于每一个洞,我打的地鼠总是有顺序的,但是,万一有10000个洞,只有你一个人去打,无数只地鼠有先有后的出入于每个洞,这时还不是随机去打,同学们脑补下这场景。

当然,思路很好的同学马上发现RMQ在队列非常多的情况下Consume Queue不也是和Kafka类似,虽然每一个文件是顺序IO,但整体是随机IO。不要忘记了,RMQ的Consume Queue是不会存储消息的内容,任何一个消息也就占用20 Byte,所以文件可以控制得非常小,绝大部分的访问还是Page Cache的访问,而不是磁盘访问。正式部署也可以将Commit Log和Consume Queue放在不同的物理SSD,避免多类文件进行IO竞争。

说在后面

更多精彩的文章,请关注我的微信公众号: 艾瑞克的技术江湖
RocketMQ高性能之底层存储设计_第20张图片