【详解文件 IO 系列】讲讲 MQ 消息中间件 (Kafka,RocketMQ 等)与 MMAP、PageCache 的故事
网络 io 相关视频讲解:网路io底层epoll
网络编程相关视频讲解:详解网络编程相关的细节处理
Linux 服务器开发高级架构学习视频:C/C++Linux服务器开发/Linux后端开发架构师
一般的 IO 调用
首先来看一下一般的 IO 调用。在传统的文件 IO 操作中,我们都是调用操作系统提供的底层标准 IO 系统调用函数 read()、write() ,此时调用此函数的进程(在 JAVA 中即 java 进程)由当前的用户态切换到内核态,然后 OS 的内核代码负责将相应的文件数据读取到内核的 IO 缓冲区,然后再把数据从内核 IO 缓冲区拷贝到进程的私有地址空间中去,这样便完成了一次 IO 操作。如下图所示。
注意两点:
OS 的 read 函数会在内核 IO 缓冲区中预读取数据,减少磁盘 IO 操作(Step2)
Java 的 BufferedReader 或 BufferedInputStream 的缓冲区的作用是减少系统调用(Step1)
Java 的 IO 读写大致分为三种:
1、普通 IO(java.io)
例如 FileWriter、FileReader 等,普通 IO 是传统字节传输方式,读写慢阻塞,单向一个 Read 对应一个 Write 。
2、文件通道 FileChannel(java.nio)
全双工通道,采用内存缓冲区 ByteBuffer 且是线程安全的
使用 FileChannel 为什么会比普通 IO 快?一般情况 FileChannel 在一次写入 4kb 的整数倍数时,才能发挥出实际的性能,益于 FileChannel 采用了 ByteBuffer 这样的内存缓冲区。这样可以精准控制写入磁盘的大小,这是普通 IO 无法实现
FileChannel 是直接把 ByteBuffer 的数据直接写入磁盘?ByteBuffer 中的数据和磁盘中的数据还隔了一层,这一层便是 PageCache,是用户内存和磁盘之间的一层缓存。我们都知道磁盘 IO 和内存 IO 的速度可是相差了好几个数量级。我们可以认为 filechannel.write 写入 PageCache 便是完成了落盘操作,但实际上,操作系统最终帮我们完成了 PageCache 到磁盘的最终写入,理解了这个概念,你就应该能够理解 FileChannel 为什么提供了一个 force() 方法,用于通知操作系统进行及时的刷盘,同理使用 FileChannel 时同样经历磁盘->PageCache->用户内存三个阶段
3、内存映射 MMAP(java.nio)
mmap 把文件映射到用户空间里的虚拟内存,省去了从内核缓冲区复制到用户空间的过程,文件中的位置在虚拟内存中有了对应的地址,可以像操作内存一样操作这个文件,相当于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作,只有真正使用这些数据时,也就是图像准备渲染在屏幕上时,虚拟内存管理系统 VMS
文章福利 Linux 后端开发网络底层原理知识学习提升 点击 学习资料 获取,完善技术栈,内容知识点包括 Linux,Nginx,ZeroMQ,MySQL,Redis,线程池,MongoDB,ZK,Linux 内核,CDN,P2P,epoll,Docker,TCP/IP,协程,DPDK 等等。
MMAP 并非是文件 IO 的银弹,它只有在一次写入很小量数据的场景下才能表现出比 FileChannel 稍微优异的性能。紧接着我还要告诉你一些令你沮丧的事,至少在 JAVA 中使用 MappedByteBuffer 是一件非常麻烦并且痛苦的事,主要表现为三点:
MMAP 使用时必须实现指定好内存映射的大小,并且一次 map 的大小限制在 1.5G 左右,重复 map 又会带来虚拟内存的回收、重新分配的问题,对于文件不确定大小的情形实在是太不友好了。
MMAP 使用的是虚拟内存,和 PageCache 一样是由操作系统来控制刷盘的,虽然可以通过 force() 来手动控制,但这个时间把握不好,在小内存场景下会很令人头疼。
MMAP 的回收问题,当 MappedByteBuffer 不再需要时,可以手动释放占用的虚拟内存,但…方式非常的诡异
OS 的 PageCache 机制
PageCache 是 OS 对文件的缓存,用于加速对文件的读写。一般来说,程序对文件进行顺序读写的速度几乎接近于内存的读写访问,这里的主要原因就是在于 OS 使用 PageCache 机制对读写访问操作进行了性能优化,将一部分的内存用作 PageCache1、对于数据文件的读取
如果一次读取文件时出现未命中(cache miss)PageCache 的情况,OS 从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取(ps:顺序读入紧随其后的少数几个页面)。这样,只要下次访问的文件已经被加载至 PageCache 时,读取操作的速度基本等于访问内存 1、对于数据文件的写入
OS 会先写入至 Cache 内,随后通过异步的方式由 pdflush 内核线程将 Cache 内的数据刷盘至物理磁盘上对于文件的顺序读写操作来说,读和写的区域都在 OS 的 PageCache 内,此时读写性能接近于内存。RocketMQ 的大致做法是,将数据文件映射到 OS 的虚拟内存中(通过 JDK NIO 的 MappedByteBuffer),写消息的时候首先写入 PageCache,并通过异步刷盘的方式将消息批量的做持久化(同时也支持同步刷盘);订阅消费消息时(对 CommitLog 操作是随机读取),由于 PageCache 的局部性热点原理且整体情况下还是从旧到新的有序读,因此大部分情况下消息还是可以直接从 Page Cache(cache hit)中读取,不会产生太多的缺页(Page Fault)中断而从磁盘读取:
PageCache 机制也不是完全无缺点的,当遇到 OS 进行脏页回写,内存回收,内存 swap 等情况时,就会引起较大的消息读写延迟。
对于这些情况,RocketMQ 采用了多种优化技术,比如内存预分配,文件预热,mlock 系统调用等,来保证在最大可能地发挥 PageCache 机制优点的同时,尽可能地减少其缺点带来的消息读写延迟
RocketMQ 存储优化技术
对于 RocketMQ 来说,它是把内存映射文件串联起来,组成了链表;因为内存映射文件本身大小有限制,只能是 2G(默认 1G);所以需要把多个内存映射文件串联成一个链表;这里介绍 RocketMQ 存储层采用的几项优化技术方案在一定程度上可以减少 PageCache 的缺点带来的影响,主要包括内存预分配,文件预热和 mlock 系统调用
1、预分配 MappedFile
在消息写入过程中(调用 CommitLog 的 putMessage()方法),CommitLog 会先从 MappedFileQueue 队列中获取一个 MappedFile,如果没有就新建一个;这里,MappedFile 的创建过程是将构建好的一个 AllocateRequest 请求(具体做法是,将下一个文件的路径、下下个文件的路径、文件大小为参数封装为 AllocateRequest 对象)添加至队列中,后台运行的 AllocateMappedFileService 服务线程(在 Broker 启动时,该线程就会创建并运行),会不停地 run,只要请求队列里存在请求,就会去执行 MappedFile 映射文件的创建和预分配工作,分配的时候有两种策略,一种是使用 Mmap 的方式来构建 MappedFile 实例,另外一种是从 TransientStorePool 堆外内存池中获取相应的 DirectByteBuffer 来构建 MappedFile(ps:具体采用哪种策略,也与刷盘的方式有关)。并且,在创建分配完下个 MappedFile 后,还会将下下个 MappedFile 预先创建并保存至请求队列中等待下次获取时直接返回。RocketMQ 中预分配 MappedFile 的设计非常巧妙,下次获取时候直接返回就可以不用等待 MappedFile 创建分配所产生的时间延迟
2 文件预热 && mlock 系统调用(TransientStorePool)
mlock 系统调用
其可以将进程使用的部分或者全部的地址空间锁定在物理内存中,防止其被交换到 swap 空间。对于 RocketMQ 这种的高吞吐量的分布式消息队列来说,追求的是消息读写低延迟,那么肯定希望尽可能地多使用物理内存,提高数据读写访问的操作效率。
文件预热
预热的目的主要有两点:
第一点,由于仅分配内存并进行 mlock 系统调用后并不会为程序完全锁定这些内存,因为其中的分页可能是写时复制的。因此,就有必要对每个内存页面中写入一个假的值。其中,RocketMQ 是在创建并分配 MappedFile 的过程中,预先写入一些随机值至 Mmap 映射出的内存空间里。
第二,调用 Mmap 进行内存映射后,OS 只是建立虚拟内存地址至物理地址的映射表,而实际并没有加载任何文件至内存中。程序要访问数据时 OS 会检查该部分的分页是否已经在内存中,如果不在,则发出一次缺页中断。这里,可以想象下 1G 的 CommitLog 需要发生多少次缺页中断,才能使得对应的数据才能完全加载至物理内存中(ps:X86 的 Linux 中一个标准页面大小是 4KB)?
RocketMQ 的做法是:
在做 Mmap 内存映射的同时进行 madvise 系统调用,目的是使 OS 做一次内存映射后对应的文件数据尽可能多的预加载至内存中,从而达到内存预热的效果。
评论