RocketMQ底层存储文件有commitLog,consumerqueue,indexfile,checkpoint文件。其中使用到JDK新的IO方式NIO包对磁盘文件进行操作。MappedFile类就是使用NIO API对磁盘文件操作的包装类,mq使用这个类对文件进行写入、读取、刷盘等操作。在编程方式上对磁盘文件抽象出统一操作的工具类是很好的设计,提高开发效率。所以每一个log或queue,indexfile文件被创建时相同的都会在内存创建一个对应的文件内存映射管理对象mapfile。
学习mapfile是如何对磁盘文件进行管理的,是阅读mq对commitlog、queue文件高效存储设计机制的核心基础。mapfile类使用了nio api进行读写,所以需要一定的nio bytebuf类的操作基础知识。
MappedFile的类变量以及实例变量的作用
mq需要知道占用了多少内存以及当前有多少个映射文件,方便管理。所以MappedFile定义了以下类变量记录数据
// 操作系统数据页 4K,unix系列通常是这个大小
public static final int OS_PAGE_SIZE = 1024 * 4;
// mq总共分配的映射文件内存大小
private static final AtomicLong TOTAL_MAPPED_VIRTUAL_MEMORY = new AtomicLong(0);
// mq总共创建的内存文件映射数量
private static final AtomicInteger TOTAL_MAPPED_FILES = new AtomicInteger(0);
然后是每个mapfile所代表的映射文件的实例变量。mapfile分为同步刷盘和异步刷盘两种机制,为了实现异步刷盘特地开了3个指针,这样才能搞清楚:未提交数据、已提交未刷盘数据、已刷盘数据。然后由2个异步线程定时对数据进行提交和刷盘。
// 当前数据的写入位置指针,下次写数据从此开始写入
protected final AtomicInteger wrotePosition = new AtomicInteger(0);
// 当前数据的提交指针,指针之前的数据已提交到fileChannel,commitPos~writePos之间的数据是还未提交到fileChannel的
protected final AtomicInteger committedPosition = new AtomicInteger(0);
// 当前数据的刷盘指针,指针之前的数据已落盘,commitPos~flushedPos之间的数据是还未落盘的
private final AtomicInteger flushedPosition = new AtomicInteger(0);
// 文件大小,单位字节
protected int fileSize;
// 磁盘文件的内存文件通道对象
protected FileChannel fileChannel;
// 异步刷盘时数据先写入writeBuf,由CommitRealTime线程定时200ms提交到fileChannel内存,再由FlushRealTime线程定时500ms刷fileChannel落盘
protected ByteBuffer writeBuffer = null;
// 堆外内存池,服务于异步刷盘机制,为了减少内存申请和销毁的时间,提前向OS申请并锁定一块对外内存池,writeBuf就从这里获取
protected TransientStorePool transientStorePool = null;
// 文件名,通常会以此文件开始写入的字节命名
private String fileName;
// 文件起始的写入字节数,初始化时是Long.parseLong(fileName)
private long fileFromOffset;
// 磁盘文件对象
private File file;
// 磁盘文件的内存映射对象,同步刷盘时直接将数据写入到mapedBuf
private MappedByteBuffer mappedByteBuffer;
// 此文件最近一次追加数据的时间戳
private volatile long storeTimestamp = 0;
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;
}
// 默认的构造方法
private void init(final String fileName, final int fileSize) throws IOException {
this.fileName = fileName;
this.fileSize = fileSize;
this.file = new File(fileName);
// fileFromOffset是代表文件的起始字节数
this.fileFromOffset = Long.parseLong(this.file.getName());
boolean ok = false;
// 检查文件所在的目录
ensureDirOK(this.file.getParent());
try {
// NIO API创建文件通道对象对文件操作
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
// mmap函数,对写入数据和读取数据实现OS到用户态的零拷贝
this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
// 全局记录用了多少文件映射内存
TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);
// 全局记录开了多少文件通道对象
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 {
if (!ok && this.fileChannel != null) {
this.fileChannel.close();
}
}
}
MappedFile如何写入数据的
mapfile往文件追加数据的逻辑是在appendMessage函数,根据消息可以追加单条或者批量消息。所有文件数据写入入口都是下面的函数,CommitLog、ConsumerQueue,那么不同文件由于磁盘的文件数据组织方式不一样如何实现定制化的序列化存储?依靠AppendMessageCallback实现,不同的文件写入数据传入各自实现,在统筹逻辑中回调方法。
// 单条消息
public AppendMessageResult appendMessage(final MessageExtBrokerInner msg, final AppendMessageCallback cb) {
return appendMessagesInner(msg, cb);
}
// MessageExtBatch批量消息
public AppendMessageResult appendMessages(final MessageExtBatch messageExtBatch, final AppendMessageCallback cb) {
return appendMessagesInner(messageExtBatch, cb);
}
// 写入数据的统筹逻辑
public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb) {
assert messageExt != null;
assert cb != null;
// writePos指针从这里开始写入
int currentPos = this.wrotePosition.get();
// 一般来说在写入过程中会控制文件剩余空间不够本次写入数据,会自动创建新文件继续写入,所以这里的写入位置应该永远小于文件末尾位置才对
if (currentPos < this.fileSize) {
// 根据刷盘策略不同,选择写到writeBuf还是mapBuf
ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
// 调整buf的写入指针
byteBuffer.position(currentPos);
AppendMessageResult result;
// 回调具体文件对象的写入消息实现函数
if (messageExt instanceof MessageExtBrokerInner) {
result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBrokerInner) messageExt);
} else if (messageExt instanceof MessageExtBatch) {
result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBatch) messageExt);
} else {
return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
}
// 累加写指针位置,写指针也代表了当前写了多少数据字节
this.wrotePosition.addAndGet(result.getWroteBytes());
// 更新文件的最近一次写入数据时间
this.storeTimestamp = result.getStoreTimestamp();
return result;
}
log.error("MappedFile.appendMessage return null, wrotePosition: {} fileSize: {}", currentPos, this.fileSize);
return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
}
fileOffset有什么用
fileOffset是一个mapfile文件的磁盘上起始的写入位置,因为每个commitlog文件都是固定大小1G,所以根据fileOffset + mappedFileSize计算出新文件的磁盘起始写入位置,mappedFileSize是文件的配置文件大小。
public long getFileFromOffset() {
return this.fileFromOffset;
}
刷盘原理
根据文件数据的写入方式不同会决定最终写入的目的地不同,所以要调用不同的对象刷盘。
/**
* @return 当前已经刷盘的位置指针,指针之前的数据已落盘
*/
public int flush(
// 至少需要刷盘的页数
final int flushLeastPages) {
// 这里回去计算当前内存累计的脏页是否到了最低脏页刷盘的阈值
if (this.isAbleToFlush(flushLeastPages)) {
// 检查当前mapfile没有被销毁,destroy函数会将refCount置为0,hold同时会将refCount++代表此时有至少一个线程在反问mapfile
if (this.hold()) {
int value = getReadPosition();
try {
// 异步刷盘的数据会从writeBuf写到fileChannel,直接将fileChannel数据刷盘
if (writeBuffer != null || this.fileChannel.position() != 0) {
this.fileChannel.force(false);
} else {
// 同步刷盘的数据每一次都会直接写入mapBuf
this.mappedByteBuffer.force();
}
} catch (Throwable e) {
log.error("Error occurred when force data to disk.", e);
}
// 更新当前刷盘指针为当前写入指针的位置
this.flushedPosition.set(value);
// 将refCount--,当refCount=0时会释放mapBuf的内存
this.release();
} else {
log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
this.flushedPosition.set(getReadPosition());
}
}
return this.getFlushedPosition();
}
提交原理
提交脏页,对于CommitLog文件通常是由CommitRealTime线程定时200ms将writeBuf数据提交到fileChannel,还要满足最低4个脏页才会提交。提交脏页的最低阈值可以配置,和刷盘脏页阈值一样。
public int commit(final int commitLeastPages) {
if (writeBuffer == null) {
// 同步写入不需要提交,异步写入会定时调用commit函数
return this.wrotePosition.get();
}
// 提交指针到写入指针 这段区间的数据是没有提交到fileChannel的,检查>=脏页提交的最低阈值就提交
if (this.isAbleToCommit(commitLeastPages)) {
if (this.hold()) {
// 提交脏页
commit0(commitLeastPages);
this.release();
} else {
log.warn("in commit, hold failed, commit offset = " + this.committedPosition.get());
}
}
// 到这里所有脏页都提交到fileChannel,且一个mapfile所有数据都提交完毕了,就可以归还writeBuf了
if (writeBuffer != null && this.transientStorePool != null && this.fileSize == this.committedPosition.get()) {
this.transientStorePool.returnBuffer(writeBuffer);
this.writeBuffer = null;
}
return this.committedPosition.get();
}
protected void commit0(final int commitLeastPages) {
int writePos = this.wrotePosition.get();
int lastCommittedPosition = this.committedPosition.get();
// 这个判断有点多余,在commit函数已经判断过了
if (writePos - lastCommittedPosition > commitLeastPages) {
try {
// nio操作
// slice创建一个与原内存空间共享的映射,但是mark、position、limit、capacity指针是新的初始化状态
// 对buf数据操作会互相影响到,但是指针不会互相影响
ByteBuffer byteBuffer = writeBuffer.slice();
// 将提交指针到写入指针 这段区间数据写到fileChannel
byteBuffer.position(lastCommittedPosition);
byteBuffer.limit(writePos);
this.fileChannel.position(lastCommittedPosition);
this.fileChannel.write(byteBuffer);
// 更新提交指针到当前写入位置
this.committedPosition.set(writePos);
} catch (Throwable e) {
log.error("Error occurred when commit data to FileChannel.", e);
}
}
}
文件预热和提前创建
RocketMQ使用的是mmap函数将磁盘文件化作4kb的内存页映射到内存区域中,从而使得用户态程序和操作系统共同映射同一块虚拟内存地址。程序可以对内存页进行读写,背后由操作系统负责将缺失的内存页从磁盘读到内存。
所以一开始的mmap函数映射的磁盘文件在内存中是没有数据页的,如果突发的对它高频随机读取数据页,就会发生很多缺页中断很多用户态->内核态转换,以及磁盘IO。这个场景在MQ中很容易出现,当一个CommitLog写满之后,创建一个新的,然后大量的msg还等着写入到这个新commitLog文件中,必然发生大量缺页中断读取磁盘。会造成MQ的写入性能抖动,在客户端这里也会报大量的写入异常。
RocketMQ也想到了这个场景,所以有文件预热和提前创建的机制避免。
提前创建文件是怎么做的
提前创建文件是指在一个mapfile写满了之后,创建下一个mapfile文件顺带创建下下个mapfile文件存起来。因为commitlog文件名都是按照log中起始磁盘偏移量命名,且文件固定为1G。在创建当前mapfile文件时就可以算出下一个mapfile文件的名字。
如果开启了文件预热,那么创建文件是异步阻塞式的,异步是指创建的动作交给AllocateMapFile线程去做,当前线程阻塞等待它创建好文件并唤醒自己。
/**
* 获取最新的mapfile文件,commitlog调用getLastMappedFile基本上startOffset都传0
* mapfile为空则会初始化,mapfile满了同样会创建新文件
* 根据配置,还可能对mapfile进行内存页预热。也就是说如果开了预热,那每次mapfile满了都会创建新的并且预热
*/
public MappedFile getLastMappedFile(final long startOffset) {
return getLastMappedFile(startOffset, true);
}
/**
* 获取最新的mapfile文件,根据需要创建新的文件
*/
public MappedFile getLastMappedFile(final long startOffset, boolean needCreate) {
long createOffset = -1;
// 从MappedFileQueue尾部拿当前最新的
MappedFile mappedFileLast = getLastMappedFile();
// 第一次创建
if (mappedFileLast == null) {
createOffset = startOffset - (startOffset % this.mappedFileSize);
}
// 写满了,算出下一个mapfile的起始偏移量
if (mappedFileLast != null && mappedFileLast.isFull()) {
createOffset = mappedFileLast.getFileFromOffset() + this.mappedFileSize;
}
// 创建新mapfile
if (createOffset != -1 && needCreate) {
String nextFilePath = this.storePath + File.separator + UtilAll.offset2FileName(createOffset);
String nextNextFilePath = this.storePath + File.separator
+ UtilAll.offset2FileName(createOffset + this.mappedFileSize);
MappedFile mappedFile = null;
// 如果开启了mapfile预热功能,则交给专门线程去创建mapfile并且预热内存页,预热需要一定时间,默认等待5秒
// 并且mapfile还会提前创建下一个mapfile文件并预热,这样在下次要拿mapfile文件时,直接返回已预热好的mapfile就行
if (this.allocateMappedFileService != null) {
mappedFile = this.allocateMappedFileService.putRequestAndReturnMappedFile(nextFilePath,
nextNextFilePath, this.mappedFileSize);
} else {
// 如果没有开启mapfile预热,则直接创建mapfile返回
try {
mappedFile = new MappedFile(nextFilePath, this.mappedFileSize);
} catch (IOException e) {
log.error("create mappedFile exception", e);
}
}
if (mappedFile != null) {
if (this.mappedFiles.isEmpty()) {
mappedFile.setFirstCreateInQueue(true);
}
// 新mapfile加到mapfilequeue尾部
this.mappedFiles.add(mappedFile);
}
return mappedFile;
}
return mappedFileLast;
}
当前线程向AllocateMapFile线程的requestQueue提交创建文件的请求,异步线程创建文件并进行内存页的预热后将mapfile对象放到请求对象中并唤醒阻塞的请求线程。
从这段代码可以看到会提前创建下一个文件,并把下一个文件名为key放到requestTable中。在下一次要用到新文件时,直接根据文件名从requestTable创建好的并且预热完成的mapfile对象返回。大大提高了CommitLog写入的速度。
public MappedFile putRequestAndReturnMappedFile(
String nextFilePath,// 当前要创建的文件
String nextNextFilePath, // 下一个文件
int fileSize) {
// 开了内存池,就要看剩下内存块能够几个文件用的
int canSubmitRequests = 2;
if (this.messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
if (this.messageStore.getMessageStoreConfig().isFastFailIfNoBufferInStorePool() &&
BrokerRole.SLAVE != this.messageStore.getMessageStoreConfig().getBrokerRole()) { //if broker is slave, don't fast fail even no buffer in pool
canSubmitRequests = this.messageStore.getTransientStorePool().availableBufferNums() - this.requestQueue.size();
}
}
// 封装一个请求对象,放到requestTable,创建好的mapfile会放到请求对象中,在下次要拿文件时直接从requestTable根据filePath即可获取
AllocateRequest nextReq = new AllocateRequest(nextFilePath, fileSize);
boolean nextPutOK = this.requestTable.putIfAbsent(nextFilePath, nextReq) == null;
if (nextPutOK) {
if (canSubmitRequests <= 0) {
log.warn("[NOTIFYME]TransientStorePool is not enough, so create mapped file error, " +
"RequestQueueSize : {}, StorePoolSize: {}", this.requestQueue.size(), this.messageStore.getTransientStorePool().availableBufferNums());
this.requestTable.remove(nextFilePath);
return null;
}
// 提交给AllocateMapFile线程去创建文件并预热
boolean offerOK = this.requestQueue.offer(nextReq);
if (!offerOK) {
log.warn("never expected here, add a request to preallocate queue failed");
}
canSubmitRequests--;
}
// 这里也是相同逻辑,不过是提前创建下一个mapfile文件放在requestTable中,下次来到直接可以拿了
AllocateRequest nextNextReq = new AllocateRequest(nextNextFilePath, fileSize);
boolean nextNextPutOK = this.requestTable.putIfAbsent(nextNextFilePath, nextNextReq) == null;
if (nextNextPutOK) {
if (canSubmitRequests <= 0) {
log.warn("[NOTIFYME]TransientStorePool is not enough, so skip preallocate mapped file, " +
"RequestQueueSize : {}, StorePoolSize: {}", this.requestQueue.size(), this.messageStore.getTransientStorePool().availableBufferNums());
this.requestTable.remove(nextNextFilePath);
} else {
boolean offerOK = this.requestQueue.offer(nextNextReq);
if (!offerOK) {
log.warn("never expected here, add a request to preallocate queue failed");
}
}
}
if (hasException) {
log.warn(this.getServiceName() + " service has exception. so return null");
return null;
}
// 虽然是异步,但这里是阻塞等待AllocateMapFile线程创建好文件并返回
AllocateRequest result = this.requestTable.get(nextFilePath);
try {
if (result != null) {
// 请求线程挂起等待5秒
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);
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;
}
MappedFile文件内存页预热
AllocateMapFile线程在循环中不断调用mmapOperation函数,从requestQueue阻塞队列获取外部提交的创建文件请求。
private boolean mmapOperation() {
boolean isSuccess = false;
AllocateRequest req = null;
try {
req = this.requestQueue.take();
AllocateRequest expectedRequest = this.requestTable.get(req.getFilePath());
if (null == expectedRequest) {
log.warn("this mmap request expired, maybe cause timeout " + req.getFilePath() + " "
+ req.getFileSize());
return true;
}
if (expectedRequest != req) {
log.warn("never expected here, maybe cause timeout " + req.getFilePath() + " "
+ req.getFileSize() + ", req:" + req + ", expectedRequest:" + expectedRequest);
return true;
}
// 请求的mapfile还没创建,这里应该不会被创建吧,只有这个线程在创建
if (req.getMappedFile() == null) {
long beginTime = System.currentTimeMillis();
// 创建mapfile对象的代码,省略 ....
// 对mapfile做内存页预热,这里判断只对commitlog文件预热
if (mappedFile.getFileSize() >= this.messageStore.getMessageStoreConfig().getMappedFileSizeCommitLog() &&
this.messageStore.getMessageStoreConfig().isWarmMapedFileEnable()) {
mappedFile.warmMappedFile(this.messageStore.getMessageStoreConfig().getFlushDiskType(),
this.messageStore.getMessageStoreConfig().getFlushLeastPagesWhenWarmMapedFile());
}
req.setMappedFile(mappedFile);
this.hasException = false;
isSuccess = true;
}
} catch (InterruptedException e) {
log.warn(this.getServiceName() + " interrupted, possibly by shutdown.");
this.hasException = true;
return false;
} catch (IOException e) {
// 重试逻辑
} finally {
// 唤醒等待的请求线程,如果这里创建mapfile不成功会怎样?请求线程会等待5秒超时后自动醒来
if (req != null && isSuccess)
req.getCountDownLatch().countDown();
}
return true;
}
mapfile的内存数据页的提前加载逻辑。但是这里为什么要将数据页写入0的预占值并刷盘?mmap函数只是将磁盘文件映射到程序的虚拟内存地址中,这些虚拟内存数据页没有被分配真正的物理内存页,这里提前写入0的假值让OS分配物理页,起到磁盘文件到内存页的提前加载功能。后面读到这些内存页时不会发生缺页中断。
public void warmMappedFile(
// 刷盘策略
FlushDiskType type,
// 刷盘的脏页阈值
int pages) {
long beginTime = System.currentTimeMillis();
ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
int flush = 0;
long time = System.currentTimeMillis();
// 对mapfile刚内存映射的内存页进行预热,以每页4k,每4096脏页进行刷盘 pages默认4096
for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
// mmap函数只是建立磁盘文件到虚拟内存页,这里给每页预先写入0,让操作系统为这个虚拟内存页分配实际的物理内存页
byteBuffer.put(i, (byte) 0);
// force flush when flush disk type is sync
if (type == FlushDiskType.SYNC_FLUSH) {
if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
flush = i;
mappedByteBuffer.force();
}
}
// 主动放弃CPU,让线程回到runnable状态,避免其他线程的饥饿
if (j % 1000 == 0) {
log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);
time = System.currentTimeMillis();
try {
// sleep 0不会主动睡眠,是让出CPU,线程立即可重新竞争CPU
Thread.sleep(0);
} catch (InterruptedException e) {
log.error("Interrupted", e);
}
}
}
// force flush when prepare load finished
if (type == FlushDiskType.SYNC_FLUSH) {
log.info("mapped file warm-up done, force to disk, mappedFile={}, costTime={}",
this.getFileName(), System.currentTimeMillis() - beginTime);
mappedByteBuffer.force();
}
log.info("mapped file warm-up done. mappedFile={}, costTime={}", this.getFileName(),
System.currentTimeMillis() - beginTime);
// 锁定mapfile当前的内存映射区,避免在内存不足时,被OS交换到磁盘中
this.mlock();
}