RocketMQ 的存储结构

最近对 RocketMQ 的存储结构学习了一下,写一篇总结记录一下自己对其的一个研究和理解。
先简单说一下 RocketMQ 的总体架构。

RocketMQ 的总体架构

RocketMQ由四个组件构成,分别是Producer、Consumer、Broker 和 NameServer。

Producer:生产者,负责消息的生产和发送。与 NameServer 集群的一个节点建立长连接,定期从 NameServer 获取 其订阅的 Topic 的路由信息,然后向 Topic 所在的 broker 发送消息。
Consumer:消费者,负责消息的拉取和消费。Consumer 与 NameServer 集群的某个节点建立长连接,然后从 NameServer 上获取可以消费的 Topic 中某个 MessageQueue 所在 Broker 的路由信息,然后与其建立长连接,从而不断的拉取消息进行消费(同一个ConsumerGroup下的所有Consumer消费的内容合起来才是所订阅的Topic内容的整体,从而可以达到负载均衡的目的)。
NameServer:整个消息队列的状态服务器,集群的各个组件通过它来了解全局的信息,各个角色的机器会定期向 NameServer 上报自己的状态,如果超时不上报,NameServer 会认为某个机器出故障不可用,其他组件会把这个机器从可用列表中移除。NamerServer 可以部署多个,相互之间独立,其他角色同时向多个机器上报状态信息,从而达到热备份的目的。
Broker是RocketMQ的核心,它负责接收来自Producer发过来的消息、处理Consumer的消费消息的请求、消息的持久化存储、消息的HA机制以及消息在服务端的过滤。
RocketMQ 的存储结构(CommitLog、ComsumeQueue、Offset)


RocketMQ的存储与查询是由ConsumeQueue和CommitLog配合完成的,消息存储的物理文件是CommitLog,ConsummeQueue是消息的逻辑队列逻辑队列里存储了每条消息指向物理存储的地址(每个Topic下的每个MessageQueue都有一个对应的ConsumeQueue文件),单个CommitLog文件的大小为1G,消息写入CommitLog的时候采用的是尾部追加的方式进行写入,ConsumeQueue的数据信息是消息写入CommitLog后进行构建的。
CommitLog的存储的消息单元的内容
下面从源码中截取一段存储内容的代码来进行分析。
// Initialization of storage space
this.resetByteBuffer(msgStoreItemMemory, msgLen);
// 1 TOTALSIZE
this.msgStoreItemMemory.putInt(msgLen);
// 2 MAGICCODE
this.msgStoreItemMemory.putInt(CommitLog.MESSAGE_MAGIC_CODE);
// 3 BODYCRC
this.msgStoreItemMemory.putInt(msgInner.getBodyCRC());
// 4 QUEUEID
this.msgStoreItemMemory.putInt(msgInner.getQueueId());
// 5 FLAG
this.msgStoreItemMemory.putInt(msgInner.getFlag());
// 6 QUEUEOFFSET
this.msgStoreItemMemory.putLong(queueOffset);
// 7 PHYSICALOFFSET
this.msgStoreItemMemory.putLong(fileFromOffset + byteBuffer.position());
// 8 SYSFLAG
this.msgStoreItemMemory.putInt(msgInner.getSysFlag());
// 9 BORNTIMESTAMP
this.msgStoreItemMemory.putLong(msgInner.getBornTimestamp());
// 10 BORNHOST
this.resetByteBuffer(hostHolder, 8);
this.msgStoreItemMemory.put(msgInner.getBornHostBytes(hostHolder));
// 11 STORETIMESTAMP
this.msgStoreItemMemory.putLong(msgInner.getStoreTimestamp());
// 12 STOREHOSTADDRESS
this.resetByteBuffer(hostHolder, 8);
this.msgStoreItemMemory.put(msgInner.getStoreHostBytes(hostHolder));
//this.msgBatchMemory.put(msgInner.getStoreHostBytes());
// 13 RECONSUMETIMES
this.msgStoreItemMemory.putInt(msgInner.getReconsumeTimes());
// 14 Prepared Transaction Offset
this.msgStoreItemMemory.putLong(msgInner.getPreparedTransactionOffset());
// 15 BODY
this.msgStoreItemMemory.putInt(bodyLength);
if (bodyLength > 0)
this.msgStoreItemMemory.put(msgInner.getBody());
// 16 TOPIC
this.msgStoreItemMemory.put((byte) topicLength);
this.msgStoreItemMemory.put(topicData);
// 17 PROPERTIES
this.msgStoreItemMemory.putShort((short) propertiesLength);
if (propertiesLength > 0)
this.msgStoreItemMemory.put(propertiesData);


从中我们可以看到,每条消息中存储了消体内容、topic名称内容、queueId、消息大小、消息的存储时间消息被某个订阅组重新消费了多少次、消息产生端的地址、消息的存储时间等内容(从中我们可以看到每条消息占的内存空间是不一样的)。

ConsumeQueue 文件存储的单元记录
跟上面一样,从源码中截取关键代码进行分析。
this.byteBufferIndex.putLong(offset); this.byteBufferIndex.putInt(size); this.byteBufferIndex.putLong(tagsCode);

从上面的代码中我们可以看出ConsumeQueue的存储单元是定长的结构每一条记录占20个字节,记录的内容分别为消息的offset、消息长度、消息的 tagcode(消息支持按照指定的tag进行过滤)。

由上图我们也可以看出我们必须先从ConsumeQueue中去获取消息存储的物理地址,然后再从CommitLog中将数据取出。

从源码角度分析读取数据的整个过程
简写源码中数据读取的整个过程。
ConsumeQueue 文件存储的单元记录
跟上面一样,从源码中截取关键代码进行分析。
1.PullMessageProcessor.java
private RemotingCommand processRequest ( final Channel channel, RemotingCommand request,
boolean brokerAllowSuspend)

//在processRequest这个方法中调用了从MessageStore获取Message的方法
final GetMessageResult getMessageResult =
this.brokerController.getMessageStore().getMessage(requestHeader.getConsumerGroup(),
requestHeader.getTopic(),
requestHeader.getQueueId(), requestHeader.getQueueOffset(),
requestHeader.getMaxMsgNums(), messageFilter);

2.DefaultMessageStore.java

public GetMessageResult getMessage ( final String group, final String topic, final int queueId,
final long offset,
final int maxMsgNums,
final MessageFilter messageFilter)

// 在getMessage这个方法中分别调取了获得ConsumeQueue的方法、取ConsumeQueue指定位点的消息的物理地址信息以及CommitLog取获得消息内容的方法
// 根据topic信息和队列Id获得对应的ConsumeQueue信息
ConsumeQueue consumeQueue = findConsumeQueue(topic, queueId);

// 查询指定位点的消息的物理地址信息
SelectMappedBufferResult bufferConsumeQueue = consumeQueue.getIndexBuffer(offset);

// 其中获取ConsumeQueue指定位点信息的时候用的是MappedFile类方式取获取MappedBuffer信息(后面会讲解Mmap)
public SelectMappedBufferResult getIndexBuffer ( final long startIndex){
int mappedFileSize = this.mappedFileSize;
long offset = startIndex * CQ_STORE_UNIT_SIZE;
if (offset >= this.getMinLogicOffset()) {
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset);
if (mappedFile != null) {
SelectMappedBufferResult result = mappedFile.selectMappedBuffer((int) (offset % mappedFileSize));
return result;
}
}
return null;
}

//CommitLog查询消息中存储的内容
SelectMappedBufferResult selectResult = this.commitLog.getMessage(offsetPy, sizePy);

//获取消息内容的时候也是通过MappedFile类来获取对应的MappedBuffer信息
public SelectMappedBufferResult getMessage ( final long offset, final int size){
int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMapedFileSizeCommitLog();
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, offset == 0);
if (mappedFile != null) {
int pos = (int) (offset % mappedFileSize);
return mappedFile.selectMappedBuffer(pos, size);
}
return null;
}

从上面我们可以看出无论是ConsumeQueue还是CommitLog,他们都有自己的MappedFileQueue。
下面先说一下MappedFile类是干嘛用的
这个类提供了对单个文件的读写操作服务,其支持对文件指定区域的数据进行读写的功能。
MappedFile文件是在CommitLog写入消息的时候创建的。
当然ConsumeQueue的MappedFile文件也是在ConsumeQueue数据信息写入的时候创建的。

Mmap与PageCache
Mmap与普通标准IO操作的区别

标准IO数据读写过程

传统IO会先将数据拷贝至内核态缓冲区,后将其拷贝至用户态的缓冲区,然后用户态的应用程序才可以对其进行操作。
Mmap内存映射

Mmap不需要将文件中的数据先拷贝进OS的内核IO缓冲区,它可以直接将用户进程地址空间的一块区域与文件对象建立映射关系(Mmap的内存空间是一块虚拟内存,不属于JVM的内存空间,不受JVM管控,但是受到OS虚拟内存大小的限制,一次只能映射1.5G~2G的文件至用户态的虚拟内存空间,这也是RocketMQ单个CommitLog文件为1G的原因)。在读取文件的时候,会先去PageCache中去查询数据(下面会说一下我对PageCache的理解)。
关于PageCache的理解

PageCache 是操作系统对于文件的缓存,用于加快文件的读写速度(将一部分内存用于PageCache)。

有关文件读取的相关内容

文件读取时,先去PageCache中查看是否有我们需要的文件,如果未命中,则会从物理磁盘读取文件,在读取的同时,会将其相邻的数据文件进行读取(顺序读入),这样做的好处是,如果下次要读取的文件已经被加载到PageCache的话,读取速度会很快,基本等同于从内存读取数据。


有关文件写入的相关内容

数据会先被写到PageCache中,然后由PageCache对数据进行刷盘至物理磁盘,写数据的性能基本等同于写入磁盘的性能。


PageCache 在RocketMQ中的应用

在上面的Mmap中已经说过,会将数据文件映射到操作系统的虚拟内存中,读数据的时候先从PageCache中去寻找,因为PageCache的局部热点原理和读取顺序的有序性(Consumer消费的时候同一ConsumeQueue的顺序也是由旧到新),所以大多数情况下可以从Page中直接获取数据,不会产生太多的缺页情况。写数据的时候首先将数据写入PageCache,并通过异步刷盘的方式将消息批量刷盘(同步刷盘也可以支持)。
RocketMQ 的预热
在Broker启动的时候会进行Mmap的映射操作,将数据文件映射到虚拟内存中,另外,在进行内存映射到同时,也会预加载一些内容到内存中。

同步刷盘和异步刷盘


同步刷盘
当消息持久化完成后,Broker才会返回给Producer一个ACK响应,可以保证消息的可靠性,但是性能较低。
异步刷盘
只要消息写入PageCache即可将成功的ACK返回给Producer端。消息刷盘采用后台异步线程提交的方式进行,降低了读写延迟,提高了RocketMQ的性能和吞吐量。
异步和同步刷盘的区别在于,异步刷盘时,主线程并不会阻塞,在将刷盘线程wakeup后,就会继续执行。

原文地址:https://www.cnblogs.com/shoshana-kong/p/14742493.html