最近對 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後,就會繼續執行。
- 同步刷盤