整体架构
存储文件介绍
CommitLog文件
CommitLog
,消息存储文件,所有主题的消息都存储在 CommitLog
文件中。
我们的业务系统向 RocketMQ
发送一条消息,不管在中间经历了多么复杂的流程,最终这条消息会被持久化到CommitLog
文件。
我们知道,一台Broker 服务器
只有一个CommitLog
文件 (组),RocketMQ
会将所有主题的消息存储在同一个文件中,这个文件中就存储着一条条 Message,每条 Message 都会按照顺序写入。
CommitLog 文件存储的逻辑视图如下图所示,每条消息的前面4个字节存储该条消息的总长度。整个 CommitLog 文件默认大小为 1G。可通过在 broker 置文件中设置 mapedFileSizeCommitLog 属性来改变默认大小
CommitLog
文件存储消息的几乎所有信息,存储位置如下图
直观来看,commitLog文件使用上图目录结构,存储位置:${ROCKET_HOME}/store/commitlog/${fileName}
其中filename是文件的物理偏移量。例如,第一个文件第一条消息的物理偏移量是0,文件名是0000000000000000000,由于每个文件大小固定是1GB,所以第二个文件是00000000001073741824
存储的数据结构如下图:
需要注意的是,图中列出了主要的存储信息,省去了部分不那么重要的信息,例如固定值魔数。
根据偏移与消息长度查找消息:首先根据偏移找到所在的文件 ,然后用 offset 与文件长度取余得到在文件的偏移 ,从 偏移量读取size长度的内容返回即可。
如果只根据消息偏移查找消息, 则首先找到文件内的偏移量 ,然后尝试读取前4个字节获取消息的实际长度,最后读取指定字节即可。
public SelectMappedBufferResult getMessage(final long offset, final int size) {
int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog();
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, offset == 0);
if (mappedFile != null) {
int pos = (int) (offset % mappedFileSize);
return mappedFile.selectMappedBuffer(pos, size);
}
return null;
}
ConsumeQueue文件
RocketMQ 基于主题订阅模式实现消息消费,消费者关心的是一个主题下的所有消息,但由于同一主题的消息不连续地存储在commitlog文件中,试想一下如果消息消费者直接从消息存储文件(commitlog)中去遍历查找订阅主题下的消息,效率将极其低下
RocketMQ为了适应消息消费的检索需求,设计了消息消费队列文件(Consumequeue),该文件可以看成是 Commitlog关于消息消费的“索引”文件, consumequeue 的第一级目录为消息主题,第二级目录为主题的消息队列,如图所示
为了加快检索速度,并且减少空间使用,ConsumeQueue 不会存储所有消息正文,只会存储如下内容:
单个ConsumeQueue文件默认包含30万个条目,每个条目20byte,单个文件的长度为30W × 20byte,约5.7M。ConsumeQueue每一个文件的名称是以第一个消息条数20byte字节的大小为命名的。
单个 ConsumeQueue 文件是一个 ConsumeQueue 条目的数组,其下标为 ConsumeQueue 的逻辑偏移量,消息消费进度存储的偏移量即逻辑偏移量。 ConsumeQueue 即为 CommitLog 文件的索引文件, 其构建机制是当消息到达 CommitLog 文件后, 由专门的线程产生消息转发任务,从而构建消息消费队列文件与下文提到的索引文件。
根据消息逻辑偏移量、 时间戳查找消息的实现
public SelectMappedBufferResult getIndexBuffer(final long startIndex) {
/*startIndex 消息索引*/
int mappedFileSize = this.mappedFileSize;
// 根据消息索引 * 20 得到在 ConsumeQueue 中的物理偏移
long offset = startIndex * CQ_STORE_UNIT_SIZE;
if (offset >= this.getMinLogicOffset()) {
// 找到物理索引所在的文件
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset);
if (mappedFile != null) {
// 物理索引与文件大小取余,得到数据存储的位置,然后通过MappedByteBuffer的到内存映射Buffer
SelectMappedBufferResult result = mappedFile.selectMappedBuffer((int) (offset % mappedFileSize));
return result;
}
}
return null;
}
根据 startIndex 获取准备消费的条目。首先 startIndex * 20 得到在 ConsumeQueue 中的
物理偏移量offset 。
如果该 offset 小于 minLogicOffset,则返回 null,说明该消息已被删除;如果大于 minLogicOffset,则根据偏移量定位到具体的物理文件,然后通过 offset 与物理文大小取模获取在该文件的偏移量,最终的到从 startIndex 开始,到该 ConsumeQueue 有效结尾的所有数据对应的 MappedByteBuffer。
除了根据消息偏移量查找消息的功能外,RocketMQ 还提供了根据时间戳查找消息的功能,具体实现逻辑如下:
- 首先根据时间戳定位到 ConsumeQueue 物理文件,就是从第一个文件开始找到第一个文件更新时间大于该时间戳的文件。
- 然后对 ConsumeQueue 中的所有项,使用二分查找,查询每条记录对应的 CommitLog 的最后更新时间和要查询的时间戳
- 最终找到与时间戳对应的 ConsumeQueue 偏移,或者离时间戳最近的消息的 ConsumeQueue 偏移
Index索引文件
消息消费队列是 RocketMQ 专门为消息订阅构建的索引文件,提高根据主题与消息队列检索消息的速度,另外 RocketMQ 引入了 Hash 索引机制为消息建立索引,利用这个索引可以完成使用key查询完成消费。
HashMap 的设计包含两个基本点: Hash槽 与 Hash 冲突的链表结构。
从图中可以看出,indexFile 总共包含 IndexHeader、 Hash 槽、 Hash 条目(数据)。
IndexHeader IndexHeader头部,包含 40 个字节,记录该 IndexFile 的统计信息,其结构如下。
- beginTimestamp: 该索引文件中包含消息的最小存储时间。
- endTimestamp: 该索引文件中包含消息的最大存储时间。
- beginPhyOffset: 该索引文件中包含消息的最小物理偏移量(CommitLog 文件偏移量)。
- endPhyOffset:该索引文件中包含消息的最大物理偏移量(CommitLog 文件偏移量)。
- hashSlotCount: hashSlot个数,并不是 hash 槽使用的个数,在这里意义不大。
- indexCount: Index条目列表当前已使用的个数,Index条目在Index条目列表中按顺序存储。
Hash槽 Hash槽,一个 IndexFile 默认包含500万个 Hash 槽,每个 Hash 槽存储的是落在该 Hash 槽的 hashcode 最新的 Index 的索引。
Hash 条目 Index条目列表,默认一个索引文件包含 2000 万个条目,每一个 Index 条目结构如下。
- hashcode: key 的 hashcode。
- phyOffset: 消息对应的物理偏移量。
- timeDif:该消息存储时间与第一条消息的时间戳的差值,小于 0 该消息无效。
- preIndexNo:该条目的前一条记录的 Index 索引,当出现 hash 冲突时,构建的链表结构。
Index文件的写入步骤如下:
- 如果当前已使用条目大于等于允许最大条目数时,则返回 false,表示当前索引文件已写满。如果当前索引文件未写满则根据 key 算出 key 的 hashcode,然后 keyHash 对 hash 槽数量取余定位到 hashcode 对应的 hash 槽下标, hashcode对应的hash槽的物理地址 = IndexHeader 头部(40字节) + 下标 * 每个 hash 槽的大小(4字节)。
- 读取 hash 槽中存储的数据,如果 hash 槽存储的数据小于 0 或大于当前索引文件中存储的最大条目,则将该槽的值设置为 0。
- 将条目信息存储在 IndexFile 中。
-
- 计算新添加条目的起始物理偏移量,等于头部字节长度 + hash 槽数量单个 hash 槽大小(4个字节) + 当前 Index 条目个数单个 Index 条目大小(20个字节)。
- 依次将 hashcode、消息物理偏移量、时间差timeDif、原来 Hash 槽的值存入该索引条目中。
- 将新添加的索引条目索引存入 hash 槽中,覆盖原来的值。
- 更新文件索引头信息。
public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
// 判断是否写满了
if (this.indexHeader.getIndexCount() < this.indexNum) {
// 计算 hash 槽的位置
int keyHash = indexKeyHashMethod(key);
int slotPos = keyHash % this.hashSlotNum;
int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
try {
// 获取原来槽内的值
int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
// <= 0 或者大于当前存储数,则认为其无效
if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()) {
slotValue = invalidIndex;
}
// 计算时间差值
long timeDiff = storeTimestamp - this.indexHeader.getBeginTimestamp();
timeDiff = timeDiff / 1000;
if (this.indexHeader.getBeginTimestamp() <= 0) {
timeDiff = 0;
} else if (timeDiff > Integer.MAX_VALUE) {
timeDiff = Integer.MAX_VALUE;
} else if (timeDiff < 0) {
timeDiff = 0;
}
// 计算索引条目的位置
int absIndexPos =
IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
+ this.indexHeader.getIndexCount() * indexSize;
// 写入索引条目
this.mappedByteBuffer.putInt(absIndexPos, keyHash);
this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);
// 更新槽的值
this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());
// 更新头部数据
if (this.indexHeader.getIndexCount() <= 1) {
this.indexHeader.setBeginPhyOffset(phyOffset);
this.indexHeader.setBeginTimestamp(storeTimestamp);
}
this.indexHeader.incHashSlotCount();
this.indexHeader.incIndexCount();
this.indexHeader.setEndPhyOffset(phyOffset);
this.indexHeader.setEndTimestamp(storeTimestamp);
return true;
} catch (Exception e) {
log.error("putKey exception, Key: " + key + " KeyHashCode: " + key.hashCode(), e);
}
} else {
log.warn("Over index file capacity: index count = " + this.indexHeader.getIndexCount()
+ "; index max num = " + this.indexNum);
}
return false;
}
至此,索引文件的写入套路就已经介绍完了,它通过 hash 槽存储了 hash 冲突链表的头指针,然后每个索引项都保存了前一个索引项的指针,借此,在文件存储中实现了链表的数据结构。
当根据 key 查找消息时,不光可以设置要查找 key 还可以设置最大查找数量,开始时间戳,结束时间戳,操作步骤如下:
- 根据 key 计算 hashcode,然后 keyHash 对 hash 槽数量取余定位到 hashcode 对应的 hash 槽下标。
- 如果对应的 Hash 槽中存储的数据小于 1 或大于当前索引条目个数则表示该 HashCode 没有对应的条目,直接返回。
- 由于会存在 hash 冲突,根据 slotValue 定位该 hash 槽最新的一个 Item 条目,将存储的物理偏移加入到 phyOffsets 中 ,然后继续验证Item条目中存储的上一个 Index 下标,如果大于等于 1 并且小于最大条目数,则继续查找,否则结束查找。
- 根据 Index 下标定位到条目的起始物理偏移量,然后依次读取 hashcode、 物理偏移量、时间差、上一个条目的Index下标,循环步骤4。
- 如果存储的时间差小于 0,则直接结束;如果 hashcode 匹配并且消息存储时间介于待查找时间start、 end之间则将消息物理偏移量加入到phyOffsets
- 验证条目的前一个 Index 索引,如果索引大于等于 1 并且小于Index条目数,则继续查找,否则结束整个查找。
具体的实现代码如下:
public void selectPhyOffset(final List<Long> phyOffsets, final String key, final int maxNum,
final long begin, final long end) {
if (this.mappedFile.hold()) {
// 计算 hash 槽的位置
int keyHash = indexKeyHashMethod(key);
int slotPos = keyHash % this.hashSlotNum;
int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
try {
// 获取 hash 槽内存的索引位置
int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
// 验证合法性
if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()
|| this.indexHeader.getIndexCount() <= 1) {
} else {
// 遍历索引链表
for (int nextIndexToRead = slotValue; ; ) {
// 数量够了就退出
if (phyOffsets.size() >= maxNum) {
break;
}
// 找到本条索引的位置
int absIndexPos =
IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
+ nextIndexToRead * indexSize;
// 读取索引条目的内容
int keyHashRead = this.mappedByteBuffer.getInt(absIndexPos);
long phyOffsetRead = this.mappedByteBuffer.getLong(absIndexPos + 4);
long timeDiff = (long) this.mappedByteBuffer.getInt(absIndexPos + 4 + 8);
int prevIndexRead = this.mappedByteBuffer.getInt(absIndexPos + 4 + 8 + 4);
// 验证时间合法性
if (timeDiff < 0) {
break;
}
timeDiff *= 1000L;
long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff;
boolean timeMatched = (timeRead >= begin) && (timeRead <= end);
// 验证整体合法性
if (keyHash == keyHashRead && timeMatched) {
phyOffsets.add(phyOffsetRead);
}
// 验证链表中下一个节点的合法性,如何合法则继续循环,否则退出
if (prevIndexRead <= invalidIndex
|| prevIndexRead > this.indexHeader.getIndexCount()
|| prevIndexRead == nextIndexToRead || timeRead < begin) {
break;
}
nextIndexToRead = prevIndexRead;
}
}
} catch (Exception e) {
log.error("selectPhyOffset exception ", e);
}
}
}
CheckPoint文件
checkpoint 的作用是记录 CommitLog、ConsumeQueue、Index文件的刷盘时间点,文件固定长度为 4k,其中只用该文件的前面 24 个字节,其存储格式如下图所示。
- physicMsgTimestamp: CommitLog文件刷盘时间点。
- logicsMsgTimestamp: 消息消费队列文件刷盘时间点。
- indexMsgTimestamp: 索引文件刷盘时间点。
文件存储模型层次结构
对于commitlog、consumequeue、index三类大文件进行磁盘读写操作,均是通过MapedFile类来完成
RocketMQ文件存储模型层次结构如上图所示,根据类别和作用从概念模型上大致可以划分为5层,下面将从各个层次分别进行分析和阐述:
(1)RocketMQ业务处理器层:Broker端对消息进行读取和写入的业务逻辑入口,这一层主要包含了业务逻辑相关处理操作(根据解析RemotingCommand中的RequestCode来区分具体的业务操作类型,进而执行不同的业务处理流程),比如前置的检查和校验步骤、构造MessageExtBrokerInner对象、decode反序列化、构造Response返回对象等;
(2)RocketMQ数据存储组件层;该层主要是RocketMQ的存储核心类—DefaultMessageStore,其为RocketMQ消息数据文件的访问入口,通过该类的“putMessage()”和“getMessage()”方法完成对CommitLog消息存储的日志数据文件进行读写操作(具体的读写访问操作还是依赖下一层中CommitLog对象模型提供的方法);另外,在该组件初始化时候,还会启动很多存储相关的后台服务线程,包括AllocateMappedFileService(MappedFile预分配服务线程)、ReputMessageService(回放存储消息服务线程)、HAService(Broker主从同步高可用服务线程)、StoreStatsService(消息存储统计服务线程)、IndexService(索引文件服务线程)等;
(3)RocketMQ存储逻辑对象层:该层主要包含了RocketMQ数据文件存储直接相关的三个模型类IndexFile、ConsumerQueue和CommitLog。IndexFile为索引数据文件提供访问服务,ConsumerQueue为逻辑消息队列提供访问服务,CommitLog则为消息存储的日志数据文件提供访问服务。这三个模型类也是构成了RocketMQ存储层的整体结构(对于这三个模型类的深入分析将放在后续篇幅中);
(4)封装的文件内存映射层:RocketMQ主要采用JDK NIO中的MappedByteBuffer和FileChannel两种方式完成数据文件的读写。其中,采用MappedByteBuffer这种内存映射磁盘文件的方式完成对大文件的读写,在RocketMQ中将该类封装成MappedFile类。这里限制的问题在上面已经讲过;对于每类大文件(IndexFile/ConsumerQueue/CommitLog),在存储时分隔成多个固定大小的文件(单个IndexFile文件大小约为400M、单个ConsumerQueue文件大小约5.72M、单个CommitLog文件大小为1G),其中每个分隔文件的文件名为前面所有文件的字节大小数+1,即为文件的起始偏移量,从而实现了整个大文件的串联。这里,每一种类的单个文件均由MappedFile类提供读写操作服务(其中,MappedFile类提供了顺序写/随机读、内存数据刷盘、内存清理等和文件相关的服务);
(5)磁盘存储层:主要指的是部署RocketMQ服务器所用的磁盘。这里,需要考虑不同磁盘类型(如SSD或者普通的HDD)特性以及磁盘的性能参数(如IOPS、吞吐量和访问时延等指标)对顺序写/随机读操作带来的影响
设计思路
(1)消息生产与消息消费相互分离,Producer端发送消息最终写入的是CommitLog(消息存储的日志数据文件),Consumer端先从ConsumeQueue(消息逻辑队列)读取持久化消息的起始物理位置偏移量offset、大小size和消息Tag的HashCode值,随后再从CommitLog中进行读取待拉取消费消息的真正实体内容部分;
(2)RocketMQ的CommitLog文件采用混合型存储(所有的Topic下的消息队列共用同一个CommitLog的日志数据文件),并通过建立类似索引文件—ConsumeQueue的方式来区分不同Topic下面的不同MessageQueue的消息,同时为消费消息起到一定的缓冲作用(只有ReputMessageService异步服务线程通过doDispatch异步生成了ConsumeQueue队列的元素后,Consumer端才能进行消费)。这样,只要消息写入并刷盘至CommitLog文件后,消息就不会丢失,即使ConsumeQueue中的数据丢失,也可以通过CommitLog来恢复。
(3)RocketMQ每次读写文件的时候真的是完全顺序读写么?这里,发送消息时,生产者端的消息确实是顺序写入CommitLog;订阅消息时,消费者端也是顺序读取ConsumeQueue,然而根据其中的起始物理位置偏移量offset读取消息真实内容却是随机读取CommitLog。
在RocketMQ集群整体的吞吐量、并发量非常高的情况下,随机读取文件带来的性能开销影响还是比较大的,那么这里如何去优化和避免这个问题呢?
这里,同样也可以总结下RocketMQ存储架构的优缺点:
(1)优点:
a、ConsumeQueue消息逻辑队列较为轻量级;
b、对磁盘的访问串行化,避免磁盘竟争,不会因为队列增加导致IOWAIT增高;
(2)缺点:
a、对于CommitLog来说写入消息虽然是顺序写,但是读却变成了完全的随机读;
b、Consumer端订阅消费一条消息,需要先读ConsumeQueue,再读Commit Log,一定程度上增加了开销;
发送存储流程
SendMessageProcessor#processRequest
SendMessageProcessor#asyncProcessRequest
public CompletableFuture<RemotingCommand> asyncProcessRequest(ChannelHandlerContext ctx,
RemotingCommand request) throws RemotingCommandException {
final SendMessageContext mqtraceContext;
switch (request.getCode()) {
case RequestCode.CONSUMER_SEND_MSG_BACK:
return this.asyncConsumerSendMsgBack(ctx, request);
default:
SendMessageRequestHeader requestHeader = parseRequestHeader(request);
if (requestHeader == null) {
return CompletableFuture.completedFuture(null);
}
mqtraceContext = buildMsgContext(ctx, requestHeader);
this.executeSendMessageHookBefore(ctx, request, mqtraceContext);
if (requestHeader.isBatch()) {
return this.asyncSendBatchMessage(ctx, request, mqtraceContext, requestHeader);
} else {
return this.asyncSendMessage(ctx, request, mqtraceContext, requestHeader);
}
}
}
SendMessageProcessor#asyncSendMessage
DefaultMessageStore#putMessage
public PutMessageResult putMessage(MessageExtBrokerInner msg) {
try {
return asyncPutMessage(msg).get();
} catch (InterruptedException | ExecutionException e) {
return new PutMessageResult(PutMessageStatus.UNKNOWN_ERROR, null);
}
}
DefaultMessageStore#asyncPutMessage
public CompletableFuture<PutMessageResult> asyncPutMessage(MessageExtBrokerInner msg) {
PutMessageStatus checkStoreStatus = this.checkStoreStatus();
if (checkStoreStatus != PutMessageStatus.PUT_OK) {
return CompletableFuture.completedFuture(new PutMessageResult(checkStoreStatus, null));
}
PutMessageStatus msgCheckStatus = this.checkMessage(msg);
if (msgCheckStatus == PutMessageStatus.MESSAGE_ILLEGAL) {
return CompletableFuture.completedFuture(new PutMessageResult(msgCheckStatus, null));
}
long beginTime = this.getSystemClock().now();
CompletableFuture<PutMessageResult> putResultFuture = this.commitLog.asyncPutMessage(msg);
putResultFuture.thenAccept((result) -> {
long elapsedTime = this.getSystemClock().now() - beginTime;
if (elapsedTime > 500) {
log.warn("putMessage not in lock elapsed time(ms)={}, bodyLength={}", elapsedTime, msg.getBody().length);
}
this.storeStatsService.setPutMessageEntireTimeMax(elapsedTime);
if (null == result || !result.isOk()) {
this.storeStatsService.getPutMessageFailedTimes().add(1);
}
});
return putResultFuture;
}
CommitLog#asyncPutMessage
MappedFile#appendMessage
Mmap内存映射技术
(1)Mmap内存映射技术的特点
Mmap内存映射和普通标准IO操作的本质区别在于它并不需要将文件中的数据先拷贝至OS的内核IO缓冲区,而是可以直接将用户进程私有地址空间中的一块区域与文件对象建立映射关系,这样程序就好像可以直接从内存中完成对文件读/写操作一样。只有当缺页中断发生时,直接将文件从磁盘拷贝至用户态的进程空间内,只进行了一次数据拷贝。对于容量较大的文件来说(文件大小一般需要限制在1.5~2G以下),采用Mmap的方式其读/写的效率和性能都非常高。
在RocketMQ中,ConsumeQueue逻辑消费队列存储的数据较少,并且是顺序读取,在page cache机制的预读取作用下,Consume Queue的读性能会比较高近乎内存,即使在有消息堆积情况下也不会影响性能。而对于CommitLog消息存储的日志数据文件来说,读取消息内容时候会产生较多的随机访问读取,严重影响性能。如果选择合适的系统IO调度算法,比如设置调度算法为“Noop”(此时块存储采用SSD的话),随机读的性能也会有所提升。
另外,RocketMQ主要通过MappedByteBuffer对文件进行读写操作。其中,利用了NIO中的FileChannel模型直接将磁盘上的物理文件直接映射到用户态的内存地址中(这种Mmap的方式减少了传统IO将磁盘文件数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间来回进行拷贝的性能开销),将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率(这里需要注意的是,采用MappedByteBuffer这种内存映射的方式有几个限制,其中之一是一次只能映射1.5~2G 的文件至用户态的虚拟内存,这也是为何RocketMQ默认设置单个CommitLog日志数据文件为1G的原因了)。
PageCache
PageCache是OS对文件的缓存,用于加速对文件的读写。一般来说,程序对文件进行顺序读写的速度几乎接近于内存的读写访问,这里的主要原因就是在于OS使用PageCache机制对读写访问操作进行了性能优化,将一部分的内存用作PageCache。
(1)对于数据文件的读取,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取(ps:顺序读入紧随其后的少数几个页面)。这样,只要下次访问的文件已经被加载至PageCache时,读取操作的速度基本等于访问内存。
(2)对于数据文件的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上。
对于文件的顺序读写操作来说,读和写的区域都在OS的PageCache内,此时读写性能接近于内存。RocketMQ的大致做法是,将数据文件映射到OS的虚拟内存中(通过JDK NIO的MappedByteBuffer),写消息的时候首先写入PageCache,并通过异步刷盘的方式将消息批量的做持久化(同时也支持同步刷盘);订阅消费消息时(对CommitLog操作是随机读取),由于PageCache的局部性热点原理且整体情况下还是从旧到新的有序读,因此大部分情况下消息还是可以直接从Page Cache中读取,不会产生太多的缺页(Page Fault)中断而从磁盘读取。
PageCache机制也不是完全无缺点的,当遇到OS进行脏页回写,内存回收,内存swap等情况时,就会引起较大的消息读写延迟。
对于这些情况,RocketMQ采用了多种优化技术,比如内存预分配,文件预热,mlock系统调用等,来保证在最大可能地发挥PageCache机制优点的同时,尽可能地减少其缺点带来的消息读写延迟。
预分配MappedFile
在消息写入过程中(调用CommitLog的putMessage()方法),CommitLog会先从MappedFileQueue队列中获取一个 MappedFile,如果没有就新建一个。
这里,MappedFile的创建过程是将构建好的一个AllocateRequest请求(具体做法是,将下一个文件的路径、下下个文件的路径、文件大小为参数封装为AllocateRequest对象)添加至队列中,后台运行的AllocateMappedFileService服务线程(在Broker启动时,该线程就会创建并运行),会不停地run,只要请求队列里存在请求,就会去执行MappedFile映射文件的创建和预分配工作,分配的时候有两种策略,一种是使用Mmap的方式来构建MappedFile实例,另外一种是从TransientStorePool堆外内存池中获取相应的DirectByteBuffer来构建MappedFile(ps:具体采用哪种策略,也与刷盘的方式有关)。
并且,在创建分配完下个MappedFile后,还会将下下个MappedFile预先创建并保存至请求队列中等待下次获取时直接返回。RocketMQ中预分配MappedFile的设计非常巧妙,下次获取时候直接返回就可以不用等待MappedFile创建分配所产生的时间延迟。
消息刷盘
(1)同步刷盘:如上图所示,只有在消息真正持久化至磁盘后,RocketMQ的Broker端才会真正地返回给Producer端一个成功的ACK响应。同步刷盘对MQ消息可靠性来说是一种不错的保障,但是性能上会有较大影响,一般适用于金融业务应用领域。RocketMQ同步刷盘的大致做法是,基于生产者消费者模型,主线程创建刷盘请求实例—GroupCommitRequest并在放入刷盘写队列后唤醒同步刷盘线程—GroupCommitService,来执行刷盘动作(其中用了CAS变量和CountDownLatch来保证线程间的同步)。这里,RocketMQ源码中用读写双缓存队列(requestsWrite/requestsRead)来实现读写分离,其带来的好处在于内部消费生成的同步刷盘请求可以不用加锁,提高并发度。
(2)异步刷盘(默认):能够充分利用OS的PageCache的优势,只要消息写入PageCache即可将成功的ACK返回给Producer端。消息刷盘采用后台异步线程提交的方式进行,降低了读写延迟,提高了MQ的性能和吞吐量。异步和同步刷盘的区别在于,异步刷盘时,主线程并不会阻塞,在将刷盘线程wakeup后,就会继续执行。