RocketMQ架構原理解析(三):訊息索引

昔久發表於2021-12-10

一、概述

“索引”一種資料結構,幫助我們快速定位、查詢資料

前文我們梳理了訊息在Commit Log檔案的儲存過程,討論了訊息的落盤策略,然而僅僅通過Commit Log儲存訊息是遠遠不夠的,例如當我們需要消費某個topic的訊息時,通過對Commit Log整體遍歷尋找訊息的方式無疑非常的低效。所以本章將引出2個很重要的概念:消費佇列IndexFile

二、消費佇列

2.1 概念

什麼是消費佇列呢?其實在上一章的訊息協議格式中,就有訊息佇列的體現,簡單回顧一下協議的前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的消費佇列

2.2 資料結構

當然消費佇列不會將訊息體進行冗餘儲存,資料結構如下:

ConsumeQueue資料結構

即在消費佇列的檔案中,需要儲存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過期時,消費佇列檔案也跟著及時失效

2.3 消費佇列之commit log視角

CommitLog視角中的ConsumeQueue檔案

某個commit log會儲存多個topic訊息,而每個topic有可能會將訊息劃分至多個佇列中;如上圖所示,commit log按順序依次儲存訊息,而某個topic的訊息在commit log中大概率也是不連續的,而consume queue的作用便是將某個topic下同一個佇列的訊息依次標識,便於消費時順序消費

2.4 消費佇列之topic視角

topic視角中的ConsumeQueue檔案

上圖描述了Topic A的消費佇列分配情況,所有的訊息相對均勻的分散在3個broker中,每個broker的訊息又分為佇列0及佇列1,所以一共有6個消費佇列,所以Topic A的consumer端也是建議開闢6個程式去消費資料

這裡簡單提一下消費端,我們知道一個消費佇列同時只能被一個消費例項消費,所以消費例項的數量建議值為 <= consumeQueue 數量,理想情況是消費例項的個數完全等於consumeQueue個數,這樣吞吐能達到最佳,以下:

  1. consumerNum < consumeQueue 消費例項小於消費佇列個數。例如某個topic的消費佇列一共有6個,但是隻有3個消費例項,RocketMQ會盡量均衡每個消費例項分配到的消費佇列,所以每個消費例項實際會消費2個佇列的內容。這種情況可能增加消費例項可以提高整體吞吐
  2. consumerNum > consumeQueue 消費例項大於消費佇列個數。比如某個topic的消費佇列有6個,但是有8個消費例項註冊,因為一個消費例項只能對應一個消費佇列,所以勢必導致有2個消費例項處於空閒狀態,不會拿到任何資料
  3. consumerNum == consumeQueue 消費例項等於消費佇列個數。這比較理想的狀態,不會有過載或飢餓產生

基於同一個消費佇列只能被一個消費例項消費的特性,我們可以將某類訊息均傳送給一個佇列,這樣消費的時候能夠嚴格保序。例如我們希望訂單的流程是保序的,可以通過orderId % consumeQueue來決定當前訂單的消費傳送給哪個佇列,從而達到保序的目的

2.5 寫入流程

消費佇列的寫入跟commit log的寫入是同步進行的嗎?答案是否定的,RocketMQ會啟動一個獨立的執行緒來非同步構建消費佇列(構建索引檔案也是這個執行緒)

ConsumeQueue檔案構建

簡單描述下流程:構建索引的執行緒為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端能正常消費到這條訊息嗎?

丟擲這個問題,大家可以思考一下,在訊息產生、消費的章節再回答

至此,消費佇列在broker端的儲存生命週期便結束了,不過在後文的訊息生產、消費環節我們還會反覆提及。雖然消費佇列在檔案操作時並不複雜,也沒有像commit log那樣複雜的刷盤策略,但我們需要深度理解topic、commit log、消費佇列的概念及其從屬關係,這樣在後文介紹訊息生產、消費時才能遊刃有餘

三、IndexFile

ReputMessageService執行緒除了構建消費佇列的索引外,還同時根據訊息key構建了索引

IndexFile檔案構建

3.1 IndexFile簡介

除了正常的生產、消費訊息外,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

3.2 IndexFile結構

我們具體看一下此檔案的結構,與消費佇列檔案相同,IndexFile是定長

IndexFile檔案結構

由三部分組成:

  1. 檔案頭,佔用 40 byte
  2. slot,hash槽兒,佔用500w*4= 20000000 byte
  3. 索引內容 佔用2000w*20= 400000000 byte

所以檔案總大小為: 40+5000000*4+20000000*20=420000040byte ≈ 400M

3.3 儲存原理

簡單剖析一下各部分的欄位

檔案頭 共 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)當前槽兒內,上一條索引的位置

儲存方式如下

IndexFile檔案儲存原理

當一條新的訊息索引進來時,首先定位當前訊息命中的slot,該slot儲存著最近一條訊息的儲存位置。將訊息的索引資料append至檔案尾部的同時,將最新索引資料的next指向上一條索引位置,這樣便形成了一條當前slot按照時間存入的倒序的連結串列

3.4 訊息查詢

根據前文的鋪墊,同一個槽兒內的資料,已經被一個隱式的鏈兒串連在了一起,當我們根據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查詢時,預期應該返回空資料,但實際卻返回了一條資料

msgKey查詢bug

其主要是因為AaBB擁有相同的HashCode2080

此bug以及解決辦法已經在GitHub上做了提交:

3.5 message id

訊息id,在RocketMQ中又定義為msg unique id,組成形式是“ip+物理偏移量”(ip非定長欄位,會因ipv4與ipv6的不同而有所區別),其中ip及物理偏移量在訊息的協議格式中均有體現;當我們拿到訊息所屬的broker地址,以及該訊息的物理儲存偏移量時,也就唯一定位了該條訊息,所以使用“ip+物理偏移量”的方式作為訊息id

在某些場景下,msg unique id也會儲存在indexFile中,不是本文的重點,我們後文還會提及

四、索引查詢(page cache)

查詢這塊,我們將放在訊息傳送、消費的章節來闡述,此處僅僅討論索引結構設計中page cache所承擔的角色

其實在整個流程中,RocketMQ是極度依賴page cache的,尤其是消費佇列,資料查詢是通過如下流程來查詢訊息的:

1、broker接受請求 -> 2、查詢ConsumeQueue檔案(20byte) -> 3、拿到訊息的physicOffset -> 4、查詢commitLog檔案(msg size)

我們發現第二步及第四步都只是查詢很小的資料量,如果沒有page cache擋在磁碟前,整體的效能必將是斷崖式下降。我有朋友做過禁掉page cache後,RocketMQ前後的效能的對比相差好幾個量級,不禁感慨,page cache真是讓我們又愛又恨,嘆嘆

相關文章