文章目录
  1. 1. RocketMQ消息持久化(消息不丢失)原理
    1. 1.1. 如何保证消息写入CommitLog文件性能接近内存写入性能?
    2. 1.2. RocketMQ对PageCache的使用(Mmap)
    3. 1.3. RocketMQ消息刷盘
      1. 1.3.1. 同步刷盘
      2. 1.3.2. 异步刷盘
  2. 2. Mmap内存映射及RocketMQ中的应用
    1. 2.1. 其他零拷贝策略
    2. 2.2. 内存预映射机制
    3. 2.3. 内存预热
    4. 2.4. 总结
  3. 3. 参考文献

大家好,跟我学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对消息的读写的大致做法是:

  1. 首先将数据文件映射到OS的虚拟内存中,通过JDK NIO的MappedByteBuffer实现,即Mmap,我们后面细讲
  2. 当消息写入时,首先写PageCache,并通过异步刷盘的方式将消息批量持久化(同时也支持同步刷盘)
  3. 当消息消费者订阅消息时,此时对CommitLog是随机读取。由于PageCache的局部性热点原理,并且整体对消息的读取还是从旧到新的有序读,因此大部分情况下消息还是可以直接从Page Cache中读取,不会产生太多的缺页中断(Page Fault)而从磁盘读取

读写速度快,是PageCache的优点,当然它也存在一定问题。

PageCache的不足

当OS进行脏页回写、内存回收、内存交换(swap)时,会引起较大的消息读写延迟。

对这些情况,RocketMQ采用多种优化技术,如内存预分配,文件预热,mlock系统调用等,保证了在尽可能地发挥PageCache机制优点的同时,尽力减少其缺点带来的消息读写延迟。

上文提到了RocketMQ消息刷盘,这里进行较为详细的讲解。

RocketMQ消息刷盘

消息刷盘主要有同步刷盘和异步刷盘两种方式。

先上图:

刷盘模式

同步刷盘

对于同步刷盘模式,当生产者发送消息到broker,broker收到该消息,会对消息进行刷盘操作,只有消息刷盘成功才会返回ACK到生产者,保证消息写入一定能够成功。

同步刷盘保证了消息的可靠性,但对性能有较大影响,因为这个过程是完全同步的,应用场景主要适用于金融、支付、物流等对消息可靠性要求高的领域。

RocketMQ同步刷盘的大致做法如下:

  1. 基于生产者消费者模型,主线程创建刷盘请求实例GroupCommitRequest
  2. 将请求实例GroupCommitRequest放入刷盘写队列后唤醒同步刷盘线程GroupCommitService执行刷盘动作(通过CAS变量和CountDownLatch来保证线程间的同步)
  3. RocketMQ源码中用读写双缓存队列(requestsWrite/requestsRead)来实现读写分离,好处在于内部消费生成的同步刷盘请求可以不用加锁,提高并发度

异步刷盘

默认情况下,broker采用异步刷盘策略。

异步刷盘,顾名思义,当broker收到消息时,并不会直接将消息刷盘,而是先写入PageCache,写入过程是很快的,这里完全是一个内存写操作。

写入成功后,直接返回ACK给生产者。然后在后台通过异步刷盘线程将消息异步写入commitLog,降低了读写延迟,提高了MQ的性能和吞吐量。

不同于同步刷盘,异步过程下,主线程不会阻塞,主线程唤醒刷盘线程后就会继续执行后续操作,提升了吞吐量。

当系统对消息的可靠性要求较低,可以通过异步刷盘策略提升消息吞吐量及读写性能。

原因在于,PageCache本质上还是内存,当出现掉电等情况,os cache中的消息就会丢失。这种情况在极端条件会出现,因此做好异地容灾及跨域同步等高可用策略后,基本上可以降低掉电造成的影响。

Mmap内存映射及RocketMQ中的应用

首先我们来复习一下Mmap内存映射(零拷贝 zero copy)机制。

首先我们复习一下传统的io操作。传统io分为缓冲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



版权声明:

原创不易,洗文可耻。除非注明,本博文章均为原创,转载请以链接形式标明本文地址。

文章目录
  1. 1. RocketMQ消息持久化(消息不丢失)原理
    1. 1.1. 如何保证消息写入CommitLog文件性能接近内存写入性能?
    2. 1.2. RocketMQ对PageCache的使用(Mmap)
    3. 1.3. RocketMQ消息刷盘
      1. 1.3.1. 同步刷盘
      2. 1.3.2. 异步刷盘
  2. 2. Mmap内存映射及RocketMQ中的应用
    1. 2.1. 其他零拷贝策略
    2. 2.2. 内存预映射机制
    3. 2.3. 内存预热
    4. 2.4. 总结
  3. 3. 参考文献
Fork me on GitHub