RocketMQ消息存储(四) - CommitLog
之前几篇文章都对RocketMQ消息存储的底层基本类做详解。 从本篇开始,就来到了消息存储的上层层面的存储文件对象了。
我们知道RocketMQ 主要的消息存储文件有三种: 1. commitlog文件 2. consumeQueue文件 3.indexFile文件。
1.概述
本篇主要讲解的是CommitLog类 ,其对应的存储文件为: ../store/commitlog
。
在RocketMQ中,commitlog文件是极其究极重要的文件,其它的 consumequeue文件 和 indexfile文件 中的数据内容都是通过commitlog文件来分发写入的, 并且 这两个文件中的数据内容必须要跟 commitlog 中的数据内容保持一致。
为了更好的形象的去理解 commitlog 与 consumequeue,indexfile的关系, 可以把他们看成 总表与子表的关系。
- 总表:commitlog (包含所有producer发送的消息)
- 子表:consumequeue (对commitlog的消息 进行 分类:按照 topic 和 queueId 分类)
- 子表:indexfile (对commitlog的消息 进行分类:按照消息的 keys 进行分类)
当要查询时:
1. 先去子表(consumequeue/indexfile)中按照分类找到对应的消息物理偏移量。
2. 再拿着消息物理偏移量 去 commitlog中去找到真实的数据。
这种查询方式 有些类似于 Mysql 中的回表查( 先去非聚簇索引中查找到目标索引,再根据查到的目标索引去聚簇索引中查到真实数据)。
本篇文章主要讲解以下几种方法:
- 消息数据的写入
- 刷盘策略
- 启动生命周期
2.重要属性
在讲解方法前,先来简单看下CommitLog中的几个重要属性:
- MappedFileQueue : 用于管理
../store/commitlog
目录下的文件, 该目录下的文件都被抽象成了MappedFile对象。 - FlushCommitLogService: 异步刷盘服务 (刷盘时使用)
- AppendMessageCallback: 使用该对象 将消息 写入到 MappedFile文件中
- topicQueueTable: 管理 key:主题-队列 value: 偏移量 的映射表
- putMessageLock: 写入消息到文件中时 使用的锁
- beginTimeInLock: 记录写入消息时加锁的开始时间
- MESSAGE_MAGIC_CODE: 正常消息魔法值
- BLANK_MAGIC_CODE: 文件尾消息魔法值
这里先做个大概的介绍, 在脑子里有个印象,其具体的作用会在后面详解方法中会再进行详细说明。
3.文件写入
在讲解该文件写入方法之前,先详细说明一下 需要使用到的几个重要属性:MESSAGE_MAGIC_CODE ,BLANK_MAGIC_CODE, putMessageLock。
3.1 消息协议
RocketMQ中针对消息有一套复杂的消息协议编码:
在向 文件中写入一条条消息时:
- 正常能够写入的消息其MAGICCODE 为 MESSAGE_MAGIC_CODE
- 当文件快要写满时,此时向文件中写入一条消息,会判断该消息的长度 不足以写到该文件剩余的空间中,因此会直接接着在文件尾写入 8byte的数据为: 文件还剩多少空间(4byte) + BLANK_MAGIC_CODE(4byte)
BLANK_MAGIC_CODE 这个有什么用呢?
在之后读取文件的一条条消息时,会先读消息的MAGICCODE ,若MAGICCODE 为 BLANK_MAGIC_CODE ,则说明此时读到了该文件的末尾了,需要读下一个文件了。 如下图所示:
3.2 写消息锁
RocketMQ 中的消息写入是顺序写的, 因此为了保证顺序性,会在写消息时 进行 加锁 putMessageLock.
而 此 putMessageLock 有两种加锁方式:
- 基于AQS的ReetrantLock (默认)
- 基于CAS的自旋锁
这两种锁的区别 主要根据 并发场景下来进行选择, 在高并发的场景下选择 方式1 能够 保持系统的稳定性能。在并发度很低的场景下 选择方式2会更加提升性能, 因为持有方式1的锁过程对于系统来说是个重量级的操作。
在官方文档中建议用户 在同步刷盘时采用独占锁, 异步刷盘时采用自旋锁.
3.3 消息写入流程
消息写入的方法入口为 asyncPutMessage(final MessageExtBrokerInner msg)
- 第一次消息整理。 (设置存储时间、CRC值)
- 加锁
- 获取当前正在顺序写的MappedFile 或 创建新的MappedFile。(这一步在后面会详细讲解)
- 向上一步获取的MappedFile文件中 写入消息,这是消息写入的核心入口。 (这一步在后面也会详细讲解)
- 解锁
- 通知刷盘服务,进行刷盘操作 (这一步在后面详细讲解)
- HA同步
- 返回写入消息的结果
3.4 MappedFile异步创建
在消息写入流程的第3条,会获取当前正在顺序写的MappedFile
mappedFile = this.mappedFileQueue.getLastMappedFile(0);
这个方法接口属于 MappedFileQueue 中的,之前讲过。 但是并没有针对于 其中通过 allocateMappedFileService 服务异步创建MappedFile的方法做详细的讲解。
if (this.allocateMappedFileService != null) {
mappedFile = this.allocateMappedFileService.putRequestAndReturnMappedFile(nextFilePath,
nextNextFilePath, this.mappedFileSize);
}
所谓异步创建文件,就是通过 allocateMappedFileService 服务中的线程来为我们创建新的MappedFile,如下图:
putMsg线程 会先将创建文件的操作 委托给 allocateMappedFileService线程来做,自己进入阻塞态,当allocateMappedFileService线程将MappedFile文件创建完毕后,会唤醒putMsg线程继续后面的文件写入操作。
而在 allocateMappedFileService线程中有两种 创建 MappedFile 的方式 ,这取决于一个配置变量 transientStorePoolEnable
,如果该配置项为 true,说明 启用堆外缓冲池, 自然false 就是不使用堆外缓冲池了。 下面会针对是否开启堆外缓冲池的两种创建MappedFile的方式做详细讲解
if (messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
// 方式一: 启用堆外缓冲池
try {
// SPI 反射创建 MappedFile
mappedFile = ServiceLoader.load(MappedFile.class).iterator().next();
mappedFile.init(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
} catch (RuntimeException e) {
//...
}
} else {
// 方式二: 不启用堆外缓冲池 普通方式创建
mappedFile = new MappedFile(req.getFilePath(), req.getFileSize());
}
3.4.1 不开启堆外缓冲池
此方式就是 普通创建 MappedFile ,底层使用的是 MappedByteBuffer 来控制文件的写入操作。
- 第一次启动的时候,allocate线程会先后创建2个文件, 第一个文件创建完毕后,便会返回putMsg线程并唤醒它,然后allocate下次你哼进而继续异步创建下一个文件
- 接下来请求allocate线程都会将已经创建好的文件直接返回给putMsg线程,然后继续异步创建下一个文件,这样便真正实现了异步创建文件的效果。
注意:此方式仅仅是创建申请了 1G大小的 MappedByteBuffer 内存映射缓冲区, 还没有对缓冲区做 预热操作(耗时的操作)。
这个预热操作,会在下一节详细讲解。
3.4.2 开启堆外缓冲池
堆外缓冲池,所谓池,就是可重复利用的堆外缓冲区。
在Broker启动阶段, 会根据配置决定是否创建 堆外缓冲池
if (messageStoreConfig.isTransientStorePoolEnable()) {
this.transientStorePool.init();
}
若开启堆外缓冲池,则会拉长Broker的启动时长。代码注释中也标明了 该方法是重量级初始化操作。
/**
* It's a heavy init method.
*/
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);
}
}
实际上 主要耗时的操作并不是 开辟内存的代码 base = unsafe.allocateMemory(size);
,而是在开辟内存后的预热操作 unsafe.setMemory(base, size, (byte) 0);
,它会为内存区域附上0值。
至此 就生成了 一个堆外缓冲池,默认里面有5个 1G 大小的 DirectByteBuffer缓冲区。
在创建 MappedFile时, 通过调用其 init 方法 ,让MappedFile从池中获取一个 直接内存缓冲区DirectByteBuffer, 在之后的文件写入过程中就会使用该缓冲区。
public void init(final String fileName, final int fileSize,
final TransientStorePool transientStorePool) throws IOException {
init(fileName, fileSize);
// 从池中 获取 堆外内存缓冲区 DirectByteBuffer
this.writeBuffer = transientStorePool.borrowBuffer();
this.transientStorePool = transientStorePool;
}
小总结: 开启堆外缓冲池的优点和缺点:
- 优点: 缓冲区可重复利用, 且缓冲区都是预热过的
- 缺点: 创建堆外缓冲池 很 耗时。
3.1.3 文件预热
什么是文件预热? 为什么要做文件预热?
答: 可结合 《RocketMQ消息存储(一) - mmap内存映射原理》 来体会。
因为 MappeFile底层是 通过 MappedByteBuffer 与 文件建立了 mmap内存映射关系。
而此时 物理内存 上并没有 与 文件有实质上的 地址映射,如果就这样的方式 read() 或write() 就会产生缺页中断异常,会影响 read() 和 write()的效率。
因此就需要通过 提前写入 一些 默认值(0) 来让物理内存与 文件建立好关系, 避免在 read() 和 write() 的过程中产生中段异常。
说白了就是为后面的 读写 提升效率。
在 allocateMappedFileService线程 创建完成 MappedFile 文件后,需要对该MappeFile文件做预热操作。
是否需要文件预热通过配置 warmMappedFileEnable
来开启和关闭 (默认是开启状态)。
allocateMappedFileService服务线程中,文件预热的代码入口:
/**
* 参数1: type 刷盘的方式(FlushDiskType.ASYNC_FLUSH)
* 参数2: pages 一页的大小 4K
*/
mappedFile.warmMappedFile(this.messageStore.getMessageStoreConfig().getFlushDiskType(),
this.messageStore.getMessageStoreConfig().getFlushLeastPagesWhenWarmMapedFile());
此时就会调用 MappedFile 的 warmMappedFile()
文件预热方法。
for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
// 每隔1页 往里面写一个0
byteBuffer.put(i, (byte) 0);
}
mappedByteBuffer.force();
往每一页(4K) 写入一个0,通过该方式 完成 文件的预热。
同时 会锁住 该文件在物理内存的地址空间,防止SWAP交换影响性能。
this.mlock();
public void mlock() {
final long beginTime = System.currentTimeMillis();
final long address = ((DirectBuffer) (this.mappedByteBuffer)).address();
Pointer pointer = new Pointer(address);
{
int ret = LibC.INSTANCE.mlock(pointer, new NativeLong(this.fileSize));
log.info("mlock {} {} {} ret = {} time consuming = {}", address, this.fileName, this.fileSize, ret, System.currentTimeMillis() - beginTime);
}
{
int ret = LibC.INSTANCE.madvise(pointer, new NativeLong(this.fileSize), LibC.MADV_WILLNEED);
log.info("madvise {} {} {} ret = {} time consuming = {}", address, this.fileName, this.fileSize, ret, System.currentTimeMillis() - beginTime);
}
}