跟我学RocketMQ之消息持久化原理与Mmap
大家好,跟我学RocketMQ系列并没有结束。随着笔者对RocketMQ的学习与感悟不断深入,我们的旅程也在继续。
本文我将带领读者朋友们一睹RocketMQ实现高性能消息存储的原理,以及它背后的核心Mmap的风采。
RocketMQ消息持久化(消息不丢失)原理
在之前的文章中我们已经得知,broker通过调用以下代码实现消息持久化
putMessageResult = this.brokerController.getMessageStore().putMessage(msgInner);
我们先不深入putMessage方法的内部实现,单说它背后的原理:CommitLog的顺序写入机制。
CommitLog顺序写,其实就是一个WAL,即write ahead log。
Write Ahead Log(WAL),即:预写式日志。是关系数据库系统中用于提供原子性和持久性(ACID属性中的两个)的一系列技术。在使用WAL的系统中,所有的修改在提交之前都要先写入log文件中,log文件中通常包括redo和undo信息,通过日志记录描述好数据的改变后(redo和undo),再写入缓存,等缓存区写满后,最后再往持久层修改数据。
其实不单单数据库会使用,RocketMQ的commitLog也是WAL的一种,通过对文件的顺序追加写入,提高了文件的写入性能。
我们在RocketMQ的broker端文件系统中,能够看到如下的文件
$HOME/store/consumequeue/{topic}/{queueId}/{fileName}
由于我们在broker上设置的每个Topic下都会存在一些MessageQueue,这里的{topic}指代的就是具体的Topic,而{queueId}指代的就是该Topic下某个MessageQueue。
fileName就是MessageQueue中消息在CommitLog中的偏移量,通过这个offset偏移量保证消息读取阶段能够定位到消息的物理位置。
这个offset可以理解为对CommitLog文件中一个消息的引用。
除了offset,在ConsumeQueue中还存储了消息的其他属性如,消息长度、消息tag等。单条数据大小为20字节,单个ConsumeQueue文件能够保存30万条数据,每个文件大约占用5.7MB。
也就是说Topic下每个MessageQueue对应了Broker上多个ConsumeQueue文件,这些ConsumeQueue文件保存了该MessageQueue的所有消息在CommitLog文件中的物理位置,即offset偏移量。
事实上,ConsumeQueue的作用类似索引文件。
它能够区分不同Topic下的不同MessageQueue的消息,同时能够为消费消息起到一定的缓冲作用(当只有ReputMessageService异步服务线程通过doDispatch异步生成了ConsumeQueue队列的元素后,Consumer端才能进行消费)。
这样,只要消息写入成功,并刷盘至CommitLog文件后,消息就不会丢失,即使ConsumeQueue中的数据丢失,也可以通过CommitLog来恢复。
如何保证消息写入CommitLog文件性能接近内存写入性能?
我们都知道的一点是,文件随机写入磁盘的性能是远低于随机写内存的性能。两者性能差距很大。
举个例子:
我们对机械硬盘在随机写入情况下进行性能测试。
测试显示在数据块为512字节时平均写入速度仅为0.083MB/s,
当数据块大小为4KB时,平均写入速度仅为0.576MB/s
当对同样的机械硬盘顺序写情况下进行测试,
测试显示平均写入速度能达到79.0MB/s。
可以看到两者相差两个数量级。
说一个结论,顺序写文件的性能约等于随机写内存的性能。这也是RocketMQ为何选择对commitLog进行顺序写的原因。
提升写入性能的核心方法为:
- 基于操作系统的PageCache
- 顺序写机制
他们两者共同提升了commitLog写入性能。
这里重点说一下PageCache。
RocketMQ对PageCache的使用(Mmap)
PageCache是操作系统对文件的缓存,用于加速对文件的读写。
一般来说,程序对文件进行顺序读写的速度几乎接近于内存的读写访问,这里的主要原因就是在于OS使用PageCache机制对读写访问操作进行了性能优化,将一部分的内存用作PageCache。
对于数据文件的读取而言,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取,即:顺序读入紧随其后的少数几个页面。
这样,只要下次访问的文件已经被加载至PageCache时,读取操作的速度就基本等于访问内存。
而对于数据文件的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将PageCache内的数据刷盘至物理磁盘上。
RocketMQ对消息的读写的大致做法是:
- 首先将数据文件映射到OS的虚拟内存中,通过JDK NIO的MappedByteBuffer实现,即Mmap,我们后面细讲
- 当消息写入时,首先写PageCache,并通过异步刷盘的方式将消息批量持久化(同时也支持同步刷盘)
- 当消息消费者订阅消息时,此时对CommitLog是随机读取。由于PageCache的局部性热点原理,并且整体对消息的读取还是从旧到新的有序读,因此大部分情况下消息还是可以直接从Page Cache中读取,不会产生太多的缺页中断(Page Fault)而从磁盘读取
读写速度快,是PageCache的优点,当然它也存在一定问题。
PageCache的不足
当OS进行脏页回写、内存回收、内存交换(swap)时,会引起较大的消息读写延迟。
对这些情况,RocketMQ采用多种优化技术,如内存预分配,文件预热,mlock系统调用等,保证了在尽可能地发挥PageCache机制优点的同时,尽力减少其缺点带来的消息读写延迟。
上文提到了RocketMQ消息刷盘,这里进行较为详细的讲解。
RocketMQ消息刷盘
消息刷盘主要有同步刷盘和异步刷盘两种方式。
先上图:
同步刷盘
对于同步刷盘模式,当生产者发送消息到broker,broker收到该消息,会对消息进行刷盘操作,只有消息刷盘成功才会返回ACK到生产者,保证消息写入一定能够成功。
同步刷盘保证了消息的可靠性,但对性能有较大影响,因为这个过程是完全同步的,应用场景主要适用于金融、支付、物流等对消息可靠性要求高的领域。
RocketMQ同步刷盘的大致做法如下:
- 基于生产者消费者模型,主线程创建刷盘请求实例GroupCommitRequest
- 将请求实例GroupCommitRequest放入刷盘写队列后唤醒同步刷盘线程GroupCommitService执行刷盘动作(通过CAS变量和CountDownLatch来保证线程间的同步)
- RocketMQ源码中用读写双缓存队列(requestsWrite/requestsRead)来实现读写分离,好处在于内部消费生成的同步刷盘请求可以不用加锁,提高并发度
异步刷盘
默认情况下,broker采用异步刷盘策略。
异步刷盘,顾名思义,当broker收到消息时,并不会直接将消息刷盘,而是先写入PageCache,写入过程是很快的,这里完全是一个内存写操作。
写入成功后,直接返回ACK给生产者。然后在后台通过异步刷盘线程将消息异步写入commitLog,降低了读写延迟,提高了MQ的性能和吞吐量。
不同于同步刷盘,异步过程下,主线程不会阻塞,主线程唤醒刷盘线程后就会继续执行后续操作,提升了吞吐量。
当系统对消息的可靠性要求较低,可以通过异步刷盘策略提升消息吞吐量及读写性能。
原因在于,PageCache本质上还是内存,当出现掉电等情况,os cache中的消息就会丢失。这种情况在极端条件会出现,因此做好异地容灾及跨域同步等高可用策略后,基本上可以降低掉电造成的影响。
Mmap内存映射及RocketMQ中的应用
首先我们来复习一下Mmap内存映射(零拷贝 zero copy)机制。
首先我们复习一下传统的io操作。传统io分为缓冲io和直接io,如下图所示
内核缓冲区为OS的pageCache,为了加快磁盘IO,Linux将磁盘上的数据以Page为单位缓存在系统的内存中,这里的Page是Linux系统定义的一个逻辑概念,一个Page大小一般为4KB。
对于缓冲IO,对操作有三次数据拷贝,写操作则为反向的三次数据拷贝。
读操作:
磁盘->内核缓冲区->用户缓冲区->应用程序内存
写操作:
应用程序内存->用户缓冲区->内核缓冲区->磁盘
对于直接IO,少了用户缓冲区,因此对于读操作会有两次数据拷贝,对于写操作,会有反向的两次数据拷贝。
直接IO的意思就是没有了用户级别的缓冲,操作系统内核态的缓冲还是存在的。
读操作:
磁盘->内核缓冲区->应用程序内存
写操作:
应用程序内存->内核缓冲区->磁盘
如果RocketMQ采用传统的直接IO或者缓冲IO,则文件拷贝次数就会大大增加,降低读写效率, 因此引入了零拷贝策略。
零拷贝在Java中的实现是MappedByteBuffer,它的核心原理是内存映射文件,如图所示:
通过将应用程序的逻辑内存地址直接映射到Linux操作系统的内核缓冲区,应用程序通过读写自己的逻辑内存,达到实际操作操作系统内核缓冲区的效果,减少了用户态与内核态之间的数据拷贝次数。
由于内核态与用户态之间没有数据拷贝,因此叫零拷贝。
这里我们要区分“拷贝”和“映射”两个概念:
拷贝是将数据从一块内存复制到另一块内存中;而映射只是持有了数据的一个引用(即地址),数据本身只有一个副本。
在Linux中,零拷贝通过sendFile实现,在Java中,通过FileChannel.transferTo实现。
那么,RocketMQ具体是如何基于MappedByteBuffer内存映射文件实现高性能文件读写的呢?
在Java中,MappedByteBuffer映射文件要求文件大小小于2GB,RocketMQ中的每个commitlog大小最大为1G,单个ConsumeQueue文件大小月维护5.7MB。是符合MappedByteBuffer文件映射要求的。
当commitlog通过MappedByteBuffer的map()函数映射到内存中后,就可以对其进行读写操作,操作本身完全是基于内存进行的,因此效率很高。消息直接写入到PageCache中,再异步地被异步刷盘线程持久化到磁盘文件中。
当进行读取操作时,文件如果在PageCache中,则直接从内存中读取,而大部分文件是会在内存中命中的,少部分不在PageCache中的文件需要发生一次缺页中断重新映射到内存页中,被读到。
我们在上文中已经知道,一个Page大小一般为4KB,因此一次缺页中断会将一批数据映射到内存中,这也是性能提高的原因之一。
其他零拷贝策略
- 硬件:基于DMA传输数据
- 软件:基于Linux的sendFile
操作系统层面的零拷贝微观细节如下(假设应用为Java进程):
- JVM向OS发出read()系统调用触发上下文切换,从用户态切换到内核态
- 从外部存储(如硬盘)读取文件内容,通过直接内存访问(DMA)存入内核地址空间的缓冲区
- 将数据从内核缓冲区拷贝到用户空间缓冲区,read()系统调用返回,并从内核态切换回用户态
- JVM向OS发出write()系统调用,触发上下文切换,从用户态切换到内核态
- 将数据从用户缓冲区拷贝到内核中与目的地Socket关联的缓冲区
- 数据最终经由Socket通过DMA传送到硬件(如网卡)缓冲区,write()系统调用返回,并从内核态切换回用户态
内存预映射机制
RocketMQ在消息写入过程中,通过调用CommitLog的 putMessage() 方法,
CommitLog会先从MappedFileQueue队列中获取一个 MappedFile,如果没有就新建一个。
这里MappedFile的创建过程是先构建一个AllocateRequest请求,具体做法是:
- 将下一个文件的路径、下下个文件的路径、文件大小为作为参数封装为AllocateRequest对象添加至队列中
- 在Broker启动时,后台创建并运行AllocateMappedFileService服务线程。该线程会不停地run;只要请求队列里存在请求,就会执行MappedFile映射文件的创建和预分配工作。
- 分配的时候有两种策略,一种是使用Mmap的方式来构建MappedFile实例,另一种是从TransientStorePool堆外内存池中获取相应的DirectByteBuffer来构建MappedFile(具体采用哪种策略与刷盘的方式有关)
- 在创建分配完下个MappedFile后,会将下下个MappedFile预先创建并保存至请求队列中等待下次获取时直接返回
这种策略便是RocketMQ预分配MappedFile,也叫 内存预映射机制。 它的思路很巧妙,能够在下次获取时候直接返回MappedFile实例而不用等待MappedFile创建分配所产生的时间延迟。
内存预热
RocketMQ在创建并分配MappedFile的过程中预先写入了一些随机值到Mmap映射出的内存空间里。原因在于:
仅分配内存并进行mlock系统调用后并不会为程序完全锁定这些分配的内存,原因在于其中的分页可能是写时复制的。因此,就有必要对每个内存页面中写入一个假的值。
还有一个问题,当调用Mmap进行内存映射后,OS只是建立了虚拟内存地址至物理地址的映射表,而实际并没有加载任何文件至内存中。
程序要访问数据时,OS会检查该部分的分页是否已经在内存中,如果不在,则发出一次 缺页中断。X86的Linux中一个标准页面大小是4KB,那么1G的CommitLog需要发生 1024KB/4KB=256次 缺页中断,才能使得对应的数据完全加载至物理内存中。
为了避免OS检查分页是否在内存中的过程出现大量缺页中断,RocketMQ在做Mmap内存映射的同时进行了madvise系统调用,目的是使OS做一次内存映射后,使对应的文件数据尽可能多的预加载至内存中,降低缺页中断次数,从而达到内存预热的效果。
RocketMQ通过map+madvise映射后预热机制,将磁盘中的数据尽可能多的加载到PageCache中,保证后续对ConsumeQueue和CommitLog的读取过程中,能够尽可能从内存中读取数据,提升读写性能。
总结
根据上文的论述,结合我们的讨论和思考,我们能够总结RocketMQ存储架构的优缺点:
优点
- ConsumeQueue消息逻辑队列较为轻量级,易于理解
- 对磁盘的访问串行化,能够避免磁盘竟争,不会因为队列增加而导致IOWAIT增高
缺点
- 对于CommitLog而言,写入消息虽然是顺序写,但是读却变成了完全随机读
- Consumer端订阅消费一条消息,需要先读ConsumeQueue,再读CommitLog,这在一定程度上增加了性能开销
参考文献
https://cloud.tencent.com/developer/article/1352677
https://blog.csdn.net/smallcatbaby/article/details/93799959
版权声明:
原创不易,洗文可耻。除非注明,本博文章均为原创,转载请以链接形式标明本文地址。