前文我們梳理了消息在Commit Log文件的存儲過程,討論了消息的落盤策略,然而僅僅通過Commit Log存儲消息是遠遠不夠的,例如當我們需要消費某個topic的消息時,通過對Commit Log整體遍歷尋找消息的方式無疑非常的低效。所以本文將引出2個很重要的概念:消費隊列、IndexFile
消費隊列
什么是消費隊列呢?其實在上一章的消息協議格式中,就有消息隊列的體現,簡單回顧一下協議的前6個字段:
msg total len:消息總長度
msg magic :魔法值,標記當前數據是一條消息
msg CRC :消息內容的CRC值
queue id :隊列id
msg flag :消息標記位
queue offset :隊列的偏移量,從0開始累加
其中第4個字段為消費隊列的id,第6個字段為當前隊列的偏移量;所以在消息產生的時候,消息所屬的隊列就已經確定
那么究竟該如何理解“消費隊列”的概念呢?我們舉例來說:假定某個RocketMQ集群部署了3個broker(brokerA、brokerB、brokerC),主題topicTest的消息分別存儲在這3個broker中,而每個broker又對應一個commit log文件。我們把broker中的主題topicTest中的消息劃分為多個隊列,每個隊列便是這個topic在當前broker的消費隊列
數據結構
當然消費隊列不會將消息體進行冗余存儲,數據結構如下:
即在消費隊列的文件中,需要存儲20個字節的索引內容。RocketMQ中默認指定每個消費隊列的文件存儲30萬條消息的索引,而一個索引占用20個字節,這樣每個文件的大小便是固定值300000*20/1024/1024≈5.72M,而文件命名采用與commit log相似的方式,即總長度20位,高位補0
store/consumequeue/topicXX/0/00000000000000000000 第一個文件
store/consumequeue/topicXX/1/00000000000006000000 第二個文件
store/consumequeue/topicXX/2/00000000000012000000 第三個文件
store/consumequeue/topicXX/3/00000000000018000000 第四個文件
與commitLog只有一個文件不同,consumeQueue是每個topic的每個消費隊列都會生產多個文件
為什么消費隊列文件存儲消息的個數要設置成30萬呢?一個文件還不到6M,為何不能像commit log那樣設置為1G呢?鄙人沒有在源碼及網上找到相關資料,猜測可能是個經驗值。首先該值不宜設置的過大,因為消息總是有失效期的,例如3天失效,如果消費隊列的文件設置過大的話,有可能一個文件中包含了過去一個月的消息,時間跨度過大,這樣不利于及時刪除已經過期的消息;其次該值也不宜過小,太小的話會產生大量的小文件,在管理維護上制造負擔。最理想情況是一個消費隊列文件對應一個commit log,這樣commit log過期時,消費隊列文件也跟著及時失效
消費隊列之commit log視角
某個commit log會存儲多個topic消息,而每個topic有可能會將消息劃分至多個隊列中;如上圖所示,commit log按順序依次存儲消息,而某個topic的消息在commit log中大概率也是不連續的,而consume queue的作用便是將某個topic下同一個隊列的消息依次標識,便于消費時順序消費
消費隊列之topic視角
上圖描述了Topic A的消費隊列分配情況,所有的消息相對均勻的分散在3個broker中,每個broker的消息又分為隊列0及隊列1,所以一共有6個消費隊列,所以Topic A的consumer端也是建議開辟6個進程去消費數據
這里簡單提一下消費端,我們知道一個消費隊列同時只能被一個消費實例消費,所以消費實例的數量建議值為 <= consumeQueue 數量,理想情況是消費實例的個數完全等于consumeQueue個數,這樣吞吐能達到最佳
consumerNum < consumeQueue 消費實例小于消費隊列個數。例如某個topic的消費隊列一共有6個,但是只有3個消費實例,RocketMQ會盡量均衡每個消費實例分配到的消費隊列,所以每個消費實例實際會消費2個隊列的內容。這種情況可能增加消費實例可以提高整體吞吐
consumerNum > consumeQueue 消費實例大于消費隊列個數。比如某個topic的消費隊列有6個,但是有8個消費實例注冊,因為一個消費實例只能對應一個消費隊列,所以勢必導致有2個消費實例處于空閑狀態,不會拿到任何數據
consumerNum == consumeQueue 消費實例等于消費隊列個數。這比較理想的狀態,不會有過載或饑餓產生
基于同一個消費隊列只能被一個消費實例消費的特性,我們可以將某類消息均發送給一個隊列,這樣消費的時候能夠嚴格保序。例如我們希望訂單的流程是保序的,可以通過orderId % consumeQueue來決定當前訂單的消費發送給哪個隊列,從而達到保序的目的
寫入流程
消費隊列的寫入跟commit log的寫入是同步進行的嗎?答案是否定的,RocketMQ會啟動一個獨立的線程來異步構建消費隊列(構建索引文件也是這個線程)
簡單描述下流程:構建索引的線程為ReputMessageService,跟寫入commitLog的線程是異步關系,該線程會不斷地將沒有構建索引的消息從commit log中取出,將物理偏移量、消息長度、tag寫入文件。值得一提的是,消息隊列文件的寫入跟commit log不同,commit log的寫入有很多刷盤策略,而consumeQueue每條消息解析完畢都會刷盤,而且采用的是FileChannel。
借此,我們拋出幾個問題
問題1:為什么消費隊列寫入文件要用FileChannel?批次多,數據量小的場景用Mapp不香嗎?
關于這個問題,我咨詢了RocketMQ開源社區比較有影響力的大佬,給出的答復是:的確是這樣,RocketMQ這樣設計考慮欠佳,寫文件這塊應該向kafka學習,即消息寫入用FileChannel,索引寫入用Mapp
問題2:為什么RocketMQ中很少有用到堆外內存?文件寫入的話,使用堆外內存少一次內存拷貝,不是可以提高性能嗎?
是這樣的,類似這樣的場景首選還是堆外內存;RocketMQ的確還有很多可優化的空間,在將來的某個版本,我們一定可以看到針對此處的優化
問題3:如果消息已經寫入commit log,但還未寫入消費隊列,consumer端能正常消費到這條消息嗎?
拋出這個問題,大家可以思考一下,在消息產生、消費的章節再回答
IndexFile
ReputMessageService線程除了構建消費隊列的索引外,還同時根據消息key構建了索引
除了正常的生產、消費消息外,RocketMQ還提供了根據msg key進行查詢的功能,將消息key相同的消息一并查出;我們當然可以通過掃描全量的commit log將相同msg key類型的消息過濾出來,但性能堪憂,而且涉及大量的IO運算;IndexFile便是為了實現快速查找目標消息而衍生的索引文件
IndexFile的命名規范也有別于消費隊列,IndexFile是按照創建時間來命名的,因為根據消息key進行匹配查詢的時候,都要帶上時間參數,文件名起到了快速定位索引數據位置的作用,下面列舉一組IndexFile的文件名
rocketMQ/store/20211204094647480
rocketMQ/store/20211205094647480
rocketMQ/store/20211206094647480
rocketMQ/store/20211207094647480
IndexFile結構
我們具體看一下此文件的結構,與消費隊列文件相同,IndexFile是定長的
由三部分組成:
文件頭,占用 40 byte
slot,hash槽兒,占用500w4= 20000000 byte
索引內容 占用2000w20= 400000000 byte
所以文件總大小為: 40+50000004+2000000020=420000040byte ≈ 400M
存儲原理
簡單剖析一下各部分的字段
文件頭 共 20 byte
開始時間(8 byte)存儲前索引文件內,所有消息的最小時間
結束時間(8 byte)存儲前索引文件內,所有消息的最大時間,因為根據key查詢的時候 ,時間是必填選項 ,開始與結束時間用來快速定位消息是否命中
最小物理偏移量(8 byte)存儲前索引文件內,所有消息的最小物理偏移量
最大物理偏移量(8 byte)存儲前索引文件內,所有消息的最大物理偏移量;框定最小、最大物理偏移量,是為了給通過物理地址查詢時快速索引
有效hash slot數量(4 byte)因為存儲hash沖突的情況,所以最壞情況是,hash slot只有1個,最理想情況是有500萬個
index索引數量(4 byte)如果當前索引文件已經構建完畢,那么該值是固定值2000萬
slot 4 byte
當前槽兒內的最近一次index的位置(4 byte)
索引內容 20 byte
hash值(4 byte)
消息的物理地址(8 byte)
時間差(4 byte)當前消息與最早消息的時間差
索引(4 byte)當前槽兒內,上一條索引的位置
存儲方式如下
當一條新的消息索引進來時,首先定位當前消息命中的slot,該slot存儲著最近一條消息的存儲位置。將消息的索引數據append至文件尾部的同時,將最新索引數據的next指向上一條索引位置,這樣便形成了一條當前slot按照時間存入的倒序的鏈表
消息查詢
根據前文的鋪墊,同一個槽兒內的數據,已經被一個隱式的鏈兒串連在了一起,當我們根據topic+key進行數據查詢時,直接通過topic + # + key的hash值,定位到某個槽兒,進而依次尋找消息即可;當然同一個槽兒內的數據可能出現hash沖突,我們需要將不符合條件的數據過濾掉
當我閱讀這部分源碼的時候,發現了其內部存在的一個bug,其做消息過濾時,僅僅判斷消息的hash字段是否相等,如果相等的話,繼而認定為要尋找的數據從而返回
class : org.apache.rocketmq.store.index.IndexFile
if (keyHash == keyHashRead && timeMatched) {
phyOffsets.add(phyOffsetRead);
}
進而帶來的一些問題,例如:
新建topic AaTopic,并向topic中發送一條消息,message key為Aa
新建topic BBTopic,并向topic中發送一條消息,message key為BB
當我們通過 topic=AaTopic && key=BB查詢時,預期應該返回空數據,但實際卻返回了一條數據
其主要是因為Aa與BB擁有相同的HashCode2080
message id
消息id,在RocketMQ中又定義為msg unique id,組成形式是“ip+物理偏移量”(ip非定長字段,會因ipv4與ipv6的不同而有所區別),其中ip及物理偏移量在消息的協議格式中均有體現;當我們拿到消息所屬的broker地址,以及該消息的物理存儲偏移量時,也就唯一定位了該條消息,所以使用“ip+物理偏移量”的方式作為消息id
在某些場景下,msg unique id也會存儲在indexFile中,
索引查詢
查詢這塊,我們將放在消息發送、消費的章節來闡述,此處僅僅討論索引結構設計中page cache所承擔的角色
其實在整個流程中,RocketMQ是極度依賴page cache的,尤其是消費隊列,數據查詢是通過如下流程來查詢消息的:
1、broker接受請求 -> 2、查詢ConsumeQueue文件(20byte) -> 3、拿到消息的physicOffset -> 4、查詢commitLog文件(msg size)
我們發現第二步及第四步都只是查詢很小的數據量,如果沒有page cache擋在磁盤前,整體的性能必將是斷崖式下降。我有朋友做過禁掉page cache后,RocketMQ前后的性能的對比相差好幾個量級