RocketMQ高性能之底层存储设计

6,273 阅读8分钟

说在前面

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

例子

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

为了简述方便,来个例子

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

发送消息

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

RMQ文件全貌

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

Page Cache

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

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

Free命令

先来个直观的感受,整个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)中断。

映射缺页

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

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

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

关于mmap,多说一句,关于这个函数复杂的用法这里不描述,但是有一点要记住,调用了mmap并且传入了一个文件的fd,是在进程地址空间(虚拟内存)分配了一段连续的地址用以映射文件,内核是不会分配真实的物理内存来将文件装入内存。最终还是要通过缺页中断来分配内存,但是这样明显是会影响性能,所以最好要调用madvise传入WILLNEED的策略用以Page cache的预热,防止这块内存又变冷被回收。

刷盘

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

刷盘方式总览

同步刷盘

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

同步刷盘——GroupCommit

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

异步刷盘

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

RMQ异步刷盘方式

刷盘由程序和OS共同控制

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

脏页原理

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

Linux脏页配置

上图为centos系统的默认配置,dirty_ratio为阻塞式flush的阈值,而dirty_background_ratio是非阻塞式的flush。要想获得比较好的性能,推荐将这两个值再调大一些,然后性能测试下。

RMQ消费场景对性能的影响

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

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

RMQ发送逻辑

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

Commit Log顺序写

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

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

Consume Queue顺序写

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

消费逻辑底层

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

Consume Queue顺序读

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

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

Commit Log随机读

Commit Log会进行随机读

Commit Log整体有序的随机读

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

运行中的RMQ磁盘与网络情况

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

对比Kafka

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

Kafka分区模型

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

Partition顺序读写

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

Kafka Partition随机读写情况

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

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

说在后面

更多精彩的文章,请关注我的微信公众号: 艾瑞克的技术江湖