前言:RocketMQ的消息持久化是基于文件系统,而从效率来看文件系统>kv存储>关系型数据库。那么,到底是如何存储的,相信对源码进行解析,将会是我们大大提高对消息存储的认识。
(一)存储层的整体结构
首先看下结构图
对于我们业务层来说,都是通过DefaultMessageStore类做为操作入口。RocketMQ下主要有6类文件,分别是三类大文件:Index文件,consumequeue文件,commitlog文件。三类小文件:checkpoint文件,config目录下的配置文件.和abort。
而对于三类大文件,使用的就是nio的MappedByteBuffer类来提高读写性能。这个类是文件内存映射的相关类,支持随机读和顺序写。在RocketMQ中,被封装成了MappedFile类。
RocketMQ对于每类大文件,在存储时候分割成了多个固定大小的文件,每个文件名为前面所有的文件大小加1(也就是偏移量)。从而实现对整个大文件的串联拼接。接下来就需要看看MappedFIle这个封装类了。
(二)MappedFile类
对于 commitlog、 consumequeue、 index 三类大文件进行磁盘读写操作,均是通过 MapedFile 类来完成。这个类相当于MappedByteBuffer的包装类。
(1)主要成员变量
//默认页大小为4k
public static final int OS_PAGE_SIZE = 1024 * 4;
protected static final Logger log = LoggerFactory.getLogger(LoggerName.STORE_LOGGER_NAME);
//JVM中映射的虚拟内存总大小
private static final AtomicLong TOTAL_MAPPED_VIRTUAL_MEMORY = new AtomicLong(0);
//JVM中mmap的数量
private static final AtomicInteger TOTAL_MAPPED_FILES = new AtomicInteger(0);
//当前写文件的位置
protected final AtomicInteger wrotePosition = new AtomicInteger(0);
//ADD BY ChenYang
protected final AtomicInteger committedPosition = new AtomicInteger(0);
private final AtomicInteger flushedPosition = new AtomicInteger(0);
//映射文件的大小
protected int fileSize;
//映射的fileChannel对象
protected FileChannel fileChannel;
/**
* Message will put to here first, and then reput to FileChannel if writeBuffer is not null.
*/
protected ByteBuffer writeBuffer = null;
protected TransientStorePool transientStorePool = null;
//映射的文件名
private String fileName;
//映射的起始偏移量
private long fileFromOffset;
//映射的文件
private File file;
//映射的内存对象
private MappedByteBuffer mappedByteBuffer;
//最后一条消息保存时间
private volatile long storeTimestamp = 0;
//是不是刚刚创建的
private boolean firstCreateInQueue = false;
(2)文件顺序写操作
这里提供了两种顺序写的方法。
第一个是提供给commitlog使用的,传入消息内容,然后CommitLog按照规定的格式构造二进制信息并顺序写,方法是appendMessage(final Object msg, final AppendMessageCallback cb);这里的msg是MessageExtBrokerInner或者MessageExtBatch,具体的写操作是在回调类的doAppend方法进行。最后再调用appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb);
第二个是appendMessage(final byte[] data)方法
public boolean appendMessage(final byte[] data) {
//找出当前要的写入位置
int currentPos = this.wrotePosition.get();
//如果当前位置加上要写入的数据大小小于等于文件大小,则说明剩余空间足够写入。
if ((currentPos + data.length) <= this.fileSize) {
try {
//则由内存对象 mappedByteBuffer 创建一个指向同一块内存的
//ByteBuffer 对象,并将内存对象的写入指针指向写入位置;然后将该二进制信
//息写入该内存对象,同时将 wrotePostion 值增加消息的大小;
this.fileChannel.position(currentPos);
this.fileChannel.write(ByteBuffer.wrap(data));
} catch (Throwable e) {
log.error("Error occurred when append message to mappedFile.", e);
}
this.wrotePosition.addAndGet(data.length);
return true;
}
(3)消息刷盘操作
主要的方法是commit(final int flushLeastPages);
public int commit(final int commitLeastPages) {
if (writeBuffer == null) {
//no need to commit data to file channel, so just regard wrotePosition as committedPosition.
return this.wrotePosition.get();
}
if (this.isAbleToCommit(commitLeastPages)) {
if (this.hold()) {
commit0(commitLeastPages);
this.release();
} else {
log.warn("in commit, hold failed, commit offset = " + this.committedPosition.get());
}
}
// All dirty data has been committed to FileChannel.
if (writeBuffer != null && this.transientStorePool != null && this.fileSize == this.committedPosition.get()) {
this.transientStorePool.returnBuffer(writeBuffer);
this.writeBuffer = null;
}
return this.committedPosition.get();
}
解析:
(1)首先判断文件是否已经写满类,即wrotePosition等于fileSize,若写慢则进行刷盘操作
(2)检测内存中尚未刷盘的消息页数是否大于最小刷盘页数,不够页数也暂时不刷盘。
(3)MappedFile的父类是ReferenceResource,该父类作用是记录MappedFile中的引用次数,为正表示资源可用,刷盘前加一,然后将wrotePosotion的值赋给committedPosition,再减一。
(4)随机读操作
public SelectMappedBufferResult selectMappedBuffer(int pos) {
int readPosition = getReadPosition();
if (pos < readPosition && pos >= 0) {
if (this.hold()) {
ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
byteBuffer.position(pos);
int size = readPosition - pos;
ByteBuffer byteBufferNew = byteBuffer.slice();
byteBufferNew.limit(size);
return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this);
}
}
return null;
}
随机读的操作有两个方法,上面显示第一种方法,读取从指定位置开始的所有消息,海有一种是读取指定位置开始的指定消息大小的消息内容。这两个方法均是调用 ByteBuffer 的 slice 和 limit 方法获取消息内容,然后初始化 SelectMapedBufferResult 对象并返回;该对象的 startOffset 变量是读取消息的开始位置加上该文件的起始偏移量;
(二)MappedFileQueue
我们在应用层中访问commitlog和consumequeue是通过MappFileQueue来操作MappedFile类的,从而间接操作磁盘上面的文件,MappFileQueue是多个MappedFile组成的队列。接下来解读一下其重要的方法
(1)获取在指定时间点后更新的文件
public MappedFile getMappedFileByTime(final long timestamp) {
Object[] mfs = this.copyMappedFiles(0);
if (null == mfs)
return null;
for (int i = 0; i < mfs.length; i++) {
MappedFile mappedFile = (MappedFile) mfs[i];
if (mappedFile.getLastModifiedTimestamp() >= timestamp) {
return mappedFile;
}
}
return (MappedFile) mfs[mfs.length - 1];
解析:遍历MappedFile,若遇到的文件更新时间大于指定时间,则返回该对象,若找不到则返回最后一个MappedFile对象
(2)清理指定偏移量所在文件之后的文件
public void truncateDirtyFiles(long offset) {
List<MappedFile> willRemoveFiles = new ArrayList<MappedFile>();
for (MappedFile file : this.mappedFiles) {
long fileTailOffset = file.getFileFromOffset() + this.mappedFileSize;
if (fileTailOffset > offset) {
if (offset >= file.getFileFromOffset()) {
file.setWrotePosition((int) (offset % this.mappedFileSize));
file.setCommittedPosition((int) (offset % this.mappedFileSize));
file.setFlushedPosition((int) (offset % this.mappedFileSize));
} else {
file.destroy(1000);
willRemoveFiles.add(file);
}
}
}
this.deleteExpiredFile(willRemoveFiles);
}
解析:遍历整个列表,每个MappedFile对象对应着一个固定大小的文件,当当文件的起始偏移量fileFromOffset<=offset<=fileFromOffset+fileSize,则表示指定的位置偏移量 offset 落在的该文件上,则将对应的 MapedFile 对象的 wrotepostion 和commitPosition 设置为 offset%fileSize,若文件的起始偏移量fileFromOffset>offset,即是命中的文件之后的文件,则将这些文件删除并且从 MappFileQueue 的 MapedFile 列表中清除掉。
(3)获取或创建最后一个文件
public MappedFile