文件持续写入场景-性能优化-零拷贝(mmap)高性能文件写入MappedByteBuffer之系列(二)
hello 大家好,我是爱抄中间件代码的路人丙,今天想跟大家分享一下,在公司文件读写业务场景下引入mmap进行文件写入的调研、优化心得体会以及实践。(这篇文章最开始是ppt,是笔者在部门内部的第一次技术分享的内容!)
前言:该篇文章是该系列的第二篇!
笔者之前就看过一部分RocketMQ的源码,所以了解到RocketMQ在文件读写特别是写入使用了mmap,恰好机缘巧合之下,负责公司的业务部分涉及到了文件写入的场景且存在性能问题,于是笔者就想到了RocketMQ的0拷贝文件写入,然后笔者就去扒了RocketMQ对应0拷贝的代码,然后参考其源码对业务代码进行升级改造,最终的结果就是原业务对应接口单次RT的时间缩短了3~15倍(具体的优化效率取决于文件写入的大小,笔者的测试文件大小仅在200M-1.5G范围),最后的产出就是极大的提升了用户对对应功能的使用体验。
注意:
以下涉及0拷贝的内容,均以mmap代替,即描述到mmap时,即指“零拷贝”
看完本系列文章你也将是高性能文件写入的高手:(此篇文章为系列第二篇)
1、mmap是什么
2、Java中的mmap之MappedByteBuffer 安全使用(参考 RocketMQ源码 MappedFile类)
3、代码实操:传统文件写入 vs mmap文件写入的性能对比
4、mmap的优缺点,以及适用场景(参考 RocketMQ源码对mmap的使用,避免写出内存泄漏的代码)
5、高性能文件断点续传的代码
目录
3 那么Java中应该如何使用mmap进行文件读写?(大名鼎鼎的开源消息组件:RocketMQ是如何利用mmap为其具体业务场景赋能)
笔者因为之前看过RocketMQ4.6部分源码,知道RocketMQ中有零拷贝这个概念,所以就去看了RocketMQ对应零拷贝相关的源码了
RocketMQ使用mmap零拷贝的主要业务场景在持久化落盘: 分别是CommitLog以及IndexLog的读写落盘
以下截图源码来自:RocketMQ 4.6分支
RocketMQ对于mmap的使用主要封装在MappedFile类中:
看源码,建议有目标的阅读,这样效率更高(避免其他干扰),所以笔者此次阅读源码的目标是:RocketMQ如何进行mmap使用以及对于mmap生命周期管理的源码实现(说白了就是,mmap怎么初始化、怎么写、怎么销毁)
3.1 RockeMQ MappedFile初始化init()
mmap初始化:MappedFile类构造函数会进行初始化init(),即进行mmap系统调用
以下是其核心代码:
private void init(final String fileName, final int fileSize) throws IOException {
// 文件名(全路径) MessageStoreConfig类的storePathCommitLog属性,可以通过setStorePathCommitLog 去修改文件存储的路径
this.fileName = fileName;
// 文件大小 MessageStoreConfig类的mappedFileSizeCommitLog属性(默认1G) 所以mmap申请的1g的内存映射
// fileSize 可以看成1g
this.fileSize = fileSize;
this.file = new File(fileName);
// fileFromOffset文件起始地址 大家都知道RockeMQ在设计的时候文件名都是偏移地址
this.fileFromOffset = Long.parseLong(this.file.getName());
boolean ok = false;
// 检查文件存储路径的父目录是否创建 ,没创建会创建
ensureDirOK(this.file.getParent());
try {
// 熟悉的代码,熟悉的配方
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
// 可以看到这里 直接申请了 1个g fileSize的大小已经在前面说明过了,如果你要改,可以自己改
//重要: mmap申请
this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
// 看到这里可以初步推测TOTAL_MAPPED_VIRTUAL_MEMORY属性:记录mmap映射大小
TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);
// 计数做了一个+1 初步推测应该就是计数一下,但是不知道有什么用
TOTAL_MAPPED_FILES.incrementAndGet();
ok = true;
} catch (FileNotFoundException e) {
log.error("Failed to create file " + this.fileName, e);
throw e;
} catch (IOException e) {
log.error("Failed to map file " + this.fileName, e);
throw e;
} finally {
// 如果这个方法出异常了,那么就会把fileChannel关掉
// 不知道大家有不有跟我一样的疑问:这个mappedByteBuffer不用管么?
if (!ok && this.fileChannel != null) {
this.fileChannel.close();
}
}
// 看到这里,我们大概知道RocketMQ是怎么去初始化MappedByteBuffer以及怎么去结合自己的业务场景设计
// 所以如果你没用过MappedByteBuffer,那么看到这里应该也会照猫画虎了,比如这个init()方法代码就可以抄哇(我真是人如其名!)
// ok ,到这里,我们知道怎么初始化的了。但是还不知道RocketMQ怎么用这个MappedByteBuffer的,怎么办,ctr + 鼠标左键 锁定mappedByteBuffer属性
}
public static void ensureDirOK(final String dirName) {
if (dirName != null) {
File f = new File(dirName);
if (!f.exists()) {
boolean result = f.mkdirs();
log.info(dirName + " mkdir " + (result ? "OK" : "Failed"));
}
}
}
上面代码注释中,“重要”关键词
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
// 可以看到这里 直接申请了 1个g fileSize的大小已经在前面说明过了,如果你要改,可以自己改
//重要: mmap申请
this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
我们看一下JDK1.8中对mmap申请方法的说明:
笔者用翻译工具翻译了一下,大概意思如下:(以下翻译来自JDK1.8源码注释(windows10有道词典10.2.6翻译))
“将该通道文件的一个区域直接映射到内存中。
文件的一个区域可以通过以下三种方式之一映射到内存中:
只读:任何修改结果缓冲区的尝试都会导致抛出java.nio.ReadOnlyBufferException异常。(MapMode.READ_ONLY)
读/写:对结果缓冲区所做的更改最终将传播到文件中;它们可能对映射同一文件的其他程序可见,也可能不可见。(MapMode.READ_WRITE)
Private:对结果缓冲区所做的更改不会传播到文件中,并且对映射同一文件的其他程序不可见;相反,它们将导致创建缓冲区修改部分的私有副本。(MapMode.PRIVATE)
对于只读映射,该通道必须已打开以供读取;对于读/写映射或私有映射,该通道必须同时为读和写打开。
此方法返回的映射字节缓冲区的位置为0,限制和容量为size;它的标记将是未定义的。缓冲区及其所表示的映射将保持有效,直到缓冲区本身被垃圾收集。
映射一旦建立,就不依赖于用于创建它的文件通道。特别是,关闭通道对映射的有效性没有影响。
内存映射文件的许多细节本质上依赖于底层操作系统,因此不明确。当请求的区域未完全包含在此通道的文件中时,此方法的行为未指定。这个程序或其他程序对基础文件的内容或大小所做的更改是否传播到缓冲区是未指定的。将对缓冲区的更改传播到文件的速率未指定。
对于大多数操作系统,将文件映射到内存比通过通常的读写方法读取或写入几十kb的数据要昂贵得多。从性能的角度来看,通常只值得将相对较大的文件映射到内存中。
参数:
mode - FileChannel中定义的常量READ_ONLY、READ_WRITE或PRIVATE之一。MapMode类,根据文件是要被映射为只读、读/写还是私有(写时复制),分别position -映射区域在文件中的起始位置;必须是非负的size -要映射的区域的大小;必须非负且不大于整数。MAX_VALUE
返回:
映射的字节缓冲区
抛出:
NonReadableChannelException -如果模式为READ_ONLY,但该通道未打开以进行读取
NonWritableChannelException -如果模式是READ_WRITE或PRIVATE,但该通道没有为读写打开
IllegalArgumentException -如果参数的前提条件不成立
IOException -如果发生其他I/O错误
参见:
FileChannel。MapMode, MappedByteBuffer
”
注意:对于大多数操作系统,将文件映射到内存比通过通常的读写方法读取或写入几十kb的数据要昂贵得多。从性能的角度来看,通常只值得将相对较大的文件映射到内存中。
3.2 mmap映射写入
接下来,我们看看RocketMQ如何写入的:
简单来说,写入就一行代码:
byteBuffer.put(this.msgStoreItemMemory.array(), 0, maxBlank);
3.3 mmap映射销毁
以下截图是映射销毁的核心逻辑:
public static void clean(final ByteBuffer buffer) {
if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)
return;
// 不满足以上条件
invoke(invoke(viewed(buffer), "cleaner"), "clean");
}
至此,我们知道了RocketMQ源码中是如何管理mmap文件读写的生命周期的,相信小伙伴们看到这里应该知道如何在Java语言体系中使用零拷贝mmap了。
至此我们已经知道如何在Java中使用高性能的mmap了以及安全的管理MappedByteBuffer生命周期了,接下来,我们就看一下引入mmap的效果以及存在的问题。
参考RocketMQ对于mmap生命周期的源码实现,我们接入自己的业务场景:文件断点续传场景
4 代码实操:针对断点续传场景进行 传统文件写入 vs mmap文件写入的性能对比
注意:本次测试环境仅统计文件写入的时间,不考虑网络IO以及带宽的影响
4.1 传统的文件写入方式
此处笔者省略了具体代码实现…
4.2 mmap MappedByteBuffer文件写入方式
4.2.1 检查文件
// 缓存MappedFile实列,其实就是缓存MappedByteBuffer的引用
final Map<