Page Cache
我们经常听到别人说RocketMQ读写都很快?当别人问你RocketMQ为什么这么快时,你能回答上来多少?我们今天就来聊聊这个问题
在回答这个问题之前我们先回顾一下计算机内存管理方面的知识
物理内存:内存条上的内存空间
虚拟内存:计算机内存管理的一种技术,它使得应用程序认为它拥有连续的可用内存。而实际上他是被分隔成多个物理内存碎片,这些数据也有可能存在磁盘上,在需要时进行数据交换
缺页中断:当程序试图访问虚拟内存中的页,但是这个页未被加载到物理内存时,就会发出中断,将相关的页从虚拟内存文件载入到物理内存
至于为什么要搞个虚拟内存,你可以参考一下其他文章哈
操作系统会通过MMU将虚拟内存转为物理内存。那么问题来了,当虚拟内存页的个数>物理内存页的个数,那岂不是有些虚拟内存页永远没有对应的物理内存空间?
其实不是的,当操作系统没找到要用的页时,会将最少访问的页失效,将其写入磁盘(其实就是swap分区),再加载需要访问的页,并修改表中的映射。
这就是我们用free命令查看内存使用情况的时候,显示Swap分区使用情况的原因
看上图buff/cache显示的内容,你知道这个列代表什么含义吗?
Buffer是对磁盘数据的缓存,而Cache是对文件数据的缓存(也就是page cache),他们既会用到读请求中,也会用到写请求中
这个page cache有什么用呢?我们知道对内存和磁盘进行读写的速度差了好几个数量级,为了避免每次读写文件时,都需要对磁盘进行操作,Linux使用page cache来对文件中的数据进行缓存。
每次读文件时,如果读取的数据在页缓存中已经存在,直接返回,否则将文件中的数据拷贝到页缓存(同时会对相邻的页进行预读取),然后再将页缓存中的数据拷贝给用户
每次写文件时,如果写入的数据所在的页缓存已经存在,则直接把新数据写入到页缓存中即可,否则将文件中的数据拷贝到页缓存,并把新数据写入到页缓存,内核在一定时机把页缓存刷新到文件中
由于page cache的存在,当对文件进行顺序读写时性能很高
零拷贝
当我们通过普通方式进行文件读取时,会有4次上下文切换,4次数据拷贝(至于为什么要进行上下文切换等各种细节,可以参考其他文章)。从图中可以看到CPU复制2和CPU复制3完全没必要啊,能不能省略呢?当然可以了,省略后就是大名鼎鼎的零拷贝了
零拷贝最常见的实现方式目前有2种,mmap和sendfile。两者有啥区别呢?看图
通过mmap系统调用可以将用户空间的虚拟内存地址和文件进行映射,对映射后的虚拟内存的地址进行读写操作就如同对文件进行读写操作一样。因为读写文件都要经过页缓存,所以mmap映射的其实是文件的pagecache。
注意:当映射完成并不会将文件加载到page cache中(即内存中),当对文件进行读写时,才会将文件加载到page cache中。
当使用mmap的方式进行读写时会发生,4次上下文切换,3次数据拷贝,可以看到比普通方式读写少了一次数据拷贝
如下就是用java来实现mmap的一个demo
public class MmapDemo {
// 映射100mb的文件
private static final long _100MB = 100 * 1024 * 1024;
// 操作系统每页大小,默认4k
private static final long _4kb = 4 * 1024;
public static void main(String[] args) throws Exception {
File file = new File(args[0]);
FileChannel fileChannel = new RandomAccessFile(file, "rw").getChannel();
MappedByteBuffer byteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, _100MB);
if (args.length > 1) {
for (int i = 0; i < _100MB; i += _4kb) {
byteBuffer.put(i, (byte) 0);
}
}
System.out.println("over");
TimeUnit.SECONDS.sleep(10000);
}
}
当执行这个java程序时,传入一个参数cache的大小基本不变
当传入2个参数时,cache会逐渐增大100m左右
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 0 152244 136464 1431968 0 0 0 16 0 1 1 1 99 0 0
0 0 0 150868 136464 1431976 0 0 0 15592 1896 3757 1 1 98 0 0
0 0 0 150796 136464 1431980 0 0 0 18 1900 3788 1 1 98 0 0
0 0 0 150676 136464 1431992 0 0 1 18 1902 3812 1 1 98 0 0
0 0 0 150700 136464 1431996 0 0 0 18 1864 3748 1 1 98 0 0
当创建完一个文件后,通过mmap完成映射,当发生实际读写的时候,才会将文件加载到pagecache,这样还是会有磁盘的io,降低存储的性能?有没有什么好方式能提高这部分性能呢?
当然是提前把文件加载到pagecache,如何加载呢?可以每隔4kb对文件对文件写入0,这种做法叫做文件预热。在RocketMQ每次创建完文件后都会对文件进行预热,后续对文件进行读写时效率就会很高
而sendfile在mmap的基础上又进行了优化,只需要2次上下文切换即可。
在rocketmq中使用mmap这种方式来实现零拷贝,因为通过mmap进行映射的空间有限,所以commitLog的大小为1G
源码解析
在RocketMQ中,每个CommitLog,ConsumeQueue,IndexFile文件都对应一个MappedFile对象,MappedFileQueue用来管理MappedFile,而AllocateMappedFileService用来创建MappedFile
写入消息的方式有两种方式,一种是写入MappedByteBuffer(即映射出来的内存),一种是写入ByteBuffer(堆外内存)
当刷盘方式为同步,或者异步(没有开启transientStorePool这种场景),会将消息写入MappedByteBuffer
@Test
public void writeCaseOne() throws Exception {
File file = new File("/Users/peng/software/rocketmq/test/case1.txt");
FileChannel fileChannel = new RandomAccessFile(file, "rw").getChannel();
MappedByteBuffer byteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 2048);
byteBuffer.put("hello mmap\n".getBytes());
// 将 pagecache 中的内容强制刷到磁盘
byteBuffer.force();
}
当刷盘方式为异步(开启transientStorePool这种场景),会将消息写入ByteBuffer
后面刷盘的一节会详细介绍哈
@Test
public void writeCaseTwo() throws Exception {
File file = new File("/Users/peng/software/rocketmq/test/case2.txt");
FileChannel fileChannel = new RandomAccessFile(file, "rw").getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocate(20);
byteBuffer.put("hello mmap\n".getBytes());
byteBuffer.flip();
while (byteBuffer.hasRemaining()) {
fileChannel.write(byteBuffer);
}
// 将 pagecache 中的内容强制刷到磁盘
fileChannel.force(false);
}
消息存储的调用链路如下
org.apache.rocketmq.broker.processor.SendMessageProcessor#processRequest
org.apache.rocketmq.store.DefaultMessageStore#asyncPutMessage
org.apache.rocketmq.store.CommitLog#asyncPutMessage
我们直接来分析CommitLog#asyncPutMessage这个方法
本节主要看2个部分的内容,创建MappedFile和往MappedFile追加消息
从MappedFileQueue中获取最后一次写入的MappedFile,如果没有则进行创建,先来看创建的操作
protected MappedFile tryCreateMappedFile(long createOffset) {
// 同时创建2个文件
String nextFilePath = this.storePath + File.separator + UtilAll.offset2FileName(createOffset);
String nextNextFilePath = this.storePath + File.separator + UtilAll.offset2FileName(createOffset
+ this.mappedFileSize);
return doCreateMappedFile(nextFilePath, nextNextFilePath);
}
当进行创建的时候,RocketMQ会同时创建2个文件(下一个要创建的文件和下下个要创建的文件)
首先将创建请求封装成AllocateRequest,并放到阻塞队列中
public MappedFile putRequestAndReturnMappedFile(String nextFilePath, String nextNextFilePath, int fileSize) {
int canSubmitRequests = 2;
// 根据资源做一些校验操作
// 将创建下一个文件的请求和创建下下个文件的请求放在requestTable中
// 获取创建下一个文件的请求,并阻塞等待
AllocateRequest result = this.requestTable.get(nextFilePath);
try {
if (result != null) {
// 等待创建
boolean waitOK = result.getCountDownLatch().await(waitTimeOut, TimeUnit.MILLISECONDS);
if (!waitOK) {
log.warn("create mmap timeout " + result.getFilePath() + " " + result.getFileSize());
return null;
} else {
this.requestTable.remove(nextFilePath);
// 返回创建的 mappedFile
return result.getMappedFile();
}
} else {
log.error("find preallocate mmap failed, this never happen");
}
} catch (InterruptedException e) {
log.warn(this.getServiceName() + " service has exception. ", e);
}
return null;
}
AllocateMappedFileService会不断从阻塞队列中获取请求,然后进行创建,创建完成后会唤醒阻塞的线程
// AllocateMappedFileService
public void run() {
log.info(this.getServiceName() + " service started");
while (!this.isStopped() && this.mmapOperation()) {
}
log.info(this.getServiceName() + " service end");
}
// AllocateMappedFileService#mmapOperation
private boolean mmapOperation() {
boolean isSuccess = false;
AllocateRequest req = null;
try {
req = this.requestQueue.take();
if (req.getMappedFile() == null) {
long beginTime = System.currentTimeMillis();
MappedFile mappedFile;
// 开启 transientStorePool
if (messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
try {
mappedFile = ServiceLoader.load(MappedFile.class).iterator().next();
mappedFile.init(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
} catch (RuntimeException e) {
log.warn("Use default implementation.");
// 上面没有 spi 的实现类,所以会走如下方法
mappedFile = new MappedFile(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
}
} else {
mappedFile = new MappedFile(req.getFilePath(), req.getFileSize());
}
// pre write mappedFile
if (mappedFile.getFileSize() >= this.messageStore.getMessageStoreConfig()
.getMappedFileSizeCommitLog()
&&
this.messageStore.getMessageStoreConfig().isWarmMapedFileEnable()) {
// 进行预热
mappedFile.warmMappedFile(this.messageStore.getMessageStoreConfig().getFlushDiskType(),
this.messageStore.getMessageStoreConfig().getFlushLeastPagesWhenWarmMapedFile());
}
// 将创建完成的 mappedFile 放到 AllocateRequest
req.setMappedFile(mappedFile);
this.hasException = false;
isSuccess = true;
}
} catch (InterruptedException e) {
} finally {
if (req != null && isSuccess)
// 唤醒等待获取 mappedFile 的线程
req.getCountDownLatch().countDown();
}
return true;
}
根据是否开启transientStorePool会调用MappedFile的不同构造函数,开启transientStorePool时会多初始化writeBuffer和transientStorePool
// MappedFile
public void init(final String fileName, final int fileSize,
final TransientStorePool transientStorePool) throws IOException {
init(fileName, fileSize);
this.writeBuffer = transientStorePool.borrowBuffer();
this.transientStorePool = transientStorePool;
}
为了避免频繁创建ByteBuffer,TransientStorePool在启动的时候会提前创建好几个ByteBuffer,并锁定在内存中,供后续使用,和线程池一个意思
// TransientStorePool
public void init() {
for (int i = 0; i < poolSize; i++) {
// 分配堆外内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(fileSize);
final long address = ((DirectBuffer) byteBuffer).address();
Pointer pointer = new Pointer(address);
// 内存锁定
LibC.INSTANCE.mlock(pointer, new NativeLong(fileSize));
availableBuffers.offer(byteBuffer);
}
}
// TransientStorePool
public void returnBuffer(ByteBuffer byteBuffer) {
byteBuffer.position(0);
byteBuffer.limit(fileSize);
this.availableBuffers.offerFirst(byteBuffer);
}
public ByteBuffer borrowBuffer() {
ByteBuffer buffer = availableBuffers.pollFirst();
if (availableBuffers.size() < poolSize * 0.4) {
log.warn("TransientStorePool only remain {} sheets.", availableBuffers.size());
}
return buffer;
}
当真正追加消息到MappedFile时
同步复制和异步复制(不开启transientStorePool)会将消息写入MappedByteBuffer
而异步复制(开启transientStorePool)会将消息写入WriteBuffer
至此将消息写入内存的流程跑完了,下面我们来看刷盘的实现
参考博客
[1]https://www.cnkirito.moe/learn-mmap/