說在前面
RocketMQ在底層儲存上借鑑了Kafka,但是也有它獨到的設計,本文主要關注深刻影響著RocketMQ效能的底層檔案儲存結構,中間會穿插一點點Kafka的東西以作為對比。
例子
Commit Log,一個檔案集合,每個檔案1G大小,儲存滿後存下一個,為了討論方便可以把它當成一個檔案,所有訊息內容全部持久化到這個檔案中;Consume Queue:一個Topic可以有多個,每一個檔案代表一個邏輯佇列,這裡存放訊息在Commit Log的偏移值以及大小和Tag屬性。
為了簡述方便,來個例子
假如叢集有一個Broker,Topic為binlog的佇列(Consume Queue)數量為4,如下圖所示,按順序傳送這5條內容各不相同訊息。

先簡單關注下Commit Log和Consume Queue。

RMQ的訊息整體是有序的,所以這5條訊息按順序將內容持久化在Commit Log中。Consume Queue則用於將訊息均衡地排列在不同的邏輯佇列,叢集模式下多個消費者就可以並行消費Consume Queue的訊息。
Page Cache
瞭解了每個檔案都在什麼位置存放什麼內容,那接下來就正式開始討論這種儲存方案為什麼在效能帶來的提升。
通常檔案讀寫比較慢,如果對檔案進行順序讀寫,速度幾乎是接近於記憶體的隨機讀寫,為什麼會這麼快,原因就是Page Cache。

先來個直觀的感受,整個OS有3.7G的實體記憶體,用掉了2.7G,應當還剩下1G空閒的記憶體,但OS給出的卻是175M。當然這個數學題肯定不能這麼算。
OS發現系統的實體記憶體有大量剩餘時,為了提高IO的效能,就會使用多餘的記憶體當做檔案快取,也就是圖上的buff / cache,廣義我們說的Page Cache就是這些記憶體的子集。
OS在讀磁碟時會將當前區域的內容全部讀到Cache中,以便下次讀時能命中Cache,寫磁碟時直接寫到Cache中就寫返回,由OS的pdflush以某些策略將Cache的資料Flush回磁碟。
但是系統上檔案非常多,即使是多餘的Page Cache也是非常寶貴的資源,OS不可能將Page Cache隨機分配給任何檔案,Linux底層就提供了mmap將一個程式指定的檔案對映進虛擬記憶體(Virtual Memory),對檔案的讀寫就變成了對記憶體的讀寫,能充分利用Page Cache。不過,檔案IO僅僅用到了Page Cache還是不夠的,如果對檔案進行隨機讀寫,會使虛擬記憶體產生很多缺頁(Page Fault)中斷。

每個使用者空間的程式都有自己的虛擬記憶體,每個程式都認為自己所有的實體記憶體,但虛擬記憶體只是邏輯上的記憶體,要想訪問記憶體的資料,還得通過記憶體管理單元(MMU)查詢頁表,將虛擬記憶體對映成實體記憶體。如果對映的檔案非常大,程式訪問區域性對映不到實體記憶體的虛擬記憶體時,產生缺頁中斷,OS需要讀寫磁碟檔案的真實資料再載入到記憶體。如同我們的應用程式沒有Cache住某塊資料,直接訪問資料庫要資料再把結果寫到Cache一樣,這個過程相對而言是非常慢的。
但是順序IO時,讀和寫的區域都是被OS智慧Cache過的熱點區域,不會產生大量缺頁中斷,檔案的IO幾乎等同於記憶體的IO,效能當然就上去了。
說了這麼多Page Cache的優點,也得稍微提一下它的缺點,核心把可用的記憶體分配給Page Cache後,free的記憶體相對就會變少,如果程式有新的記憶體分配需求或者缺頁中斷,恰好free的記憶體不夠,核心還需要花費一點時間將熱度低的Page Cache的記憶體回收掉,對效能非常苛刻的系統會產生毛刺。
關於mmap,多說一句,關於這個函式複雜的用法這裡不描述,但是有一點要記住,呼叫了mmap並且傳入了一個檔案的fd,是在程式地址空間(虛擬記憶體)分配了一段連續的地址用以對映檔案,核心是不會分配真實的實體記憶體來將檔案裝入記憶體。最終還是要通過缺頁中斷來分配記憶體,但是這樣明顯是會影響效能,所以最好要呼叫madvise傳入WILLNEED的策略用以Page cache的預熱,防止這塊記憶體又變冷被回收。
刷盤
刷盤一般分成:同步刷盤和非同步刷盤

同步刷盤
在訊息真正落盤後,才返回成功給Producer,只要磁碟沒有損壞,訊息就不會丟。

一般只用於金融場景,這種方式不是本文討論的重點,因為沒有利用Page Cache的特點,RMQ採用GroupCommit的方式對同步刷盤進行了優化。
非同步刷盤
讀寫檔案充分利用了Page Cache,即寫入Page Cache就返回成功給Producer,RMQ中有兩種方式進行非同步刷盤,整體原理是一樣的。

刷盤由程式和OS共同控制
先談談OS,當程式順序寫檔案時,首先寫到Cache中,這部分被修改過,但卻沒有被刷進磁碟,產生了不一致,這些不一致的記憶體叫做髒頁(Dirty Page)。

髒頁設定太小,Flush磁碟的次數就會增加,效能會下降;髒頁設定太大,效能會提高,但萬一OS當機,髒頁來不及刷盤,訊息就丟了。

上圖為centos系統的預設配置,dirty_ratio為阻塞式flush的閾值,而dirty_background_ratio是非阻塞式的flush。要想獲得比較好的效能,推薦將這兩個值再調大一些,然後效能測試下。

RMQ想要效能高,那傳送訊息時,訊息要寫進Page Cache而不是直接寫磁碟,接收訊息時,訊息要從Page Cache直接獲取而不是缺頁從磁碟讀取。
好了,原理回顧完,從訊息傳送和訊息接收來看RMQ中被mmap後的Commit Log和Consume Queue的IO情況。
RMQ傳送邏輯
傳送時,Producer不直接與Consume Queue打交道。上文提到過,RMQ所有的訊息都會存放在Commit Log中,為了使訊息儲存不發生混亂,對Commit Log進行寫之前就會上鎖。

訊息持久被鎖序列化後,對Commit Log就是順序寫,也就是常說的Append操作。配合上Page Cache,RMQ在寫Commit Log時效率會非常高。
Commit Log持久後,會將裡面的資料Dispatch到對應的Consume Queue上。

每一個Consume Queue代表一個邏輯佇列,是由ReputMessageService在單個Thread Loop中Append,顯然也是順序寫。
消費邏輯底層
消費時,Consumer不直接與Commit Log打交道,而是從Consume Queue中去拉取資料

拉取的順序從舊到新,在檔案表示每一個Consume Queue都是順序讀,充分利用了Page Cache。
光拉取Consume Queue是沒有資料的,裡面只有一個對Commit Log的引用,所以再次拉取Commit Log。

Commit Log會進行隨機讀

但整個RMQ只有一個Commit Log,雖然是隨機讀,但整體還是有序地讀,只要那整塊區域還在Page Cache的範圍內,還是可以充分利用Page Cache。

在一臺真實的MQ上檢視網路和磁碟,即使訊息端一直從MQ讀取訊息,也幾乎看不到程式從磁碟拉資料,資料直接從Page Cache經由Socket傳送給了Consumer。
對比Kafka
文章開頭就說到,RMQ是借鑑了Kafka的想法,同時也打破了Kafka在底層儲存的設計。

Kafka中關於訊息的儲存只有一種檔案,叫做Partition(不考慮細化的Segment),它履行了RMQ中Commit Log和Consume Queue公共的職責,即它在邏輯上進行拆分存,以提高消費並行度,又在內部儲存了真實的訊息內容。

這樣看上去非常完美,不管對於Producer還是Consumer,單個Partition檔案在正常的傳送和消費邏輯中都是順序IO,充分利用Page Cache帶來的巨大效能提升,但是,萬一Topic很多,每個Topic又分了N個Partition,這時對於OS來說,這麼多檔案的順序讀寫在併發時變成了隨機讀寫。

這時,不知道為什麼,我突然想起了「打地鼠」這款遊戲。對於每一個洞,我打的地鼠總是有順序的,但是,萬一有10000個洞,只有你一個人去打,無數只地鼠有先有後的出入於每個洞,這時還不是隨機去打,同學們腦補下這場景。
當然,思路很好的同學馬上發現RMQ在佇列非常多的情況下Consume Queue不也是和Kafka類似,雖然每一個檔案是順序IO,但整體是隨機IO。不要忘記了,RMQ的Consume Queue是不會儲存訊息的內容,任何一個訊息也就佔用20 Byte,所以檔案可以控制得非常小,絕大部分的訪問還是Page Cache的訪問,而不是磁碟訪問。正式部署也可以將Commit Log和Consume Queue放在不同的物理SSD,避免多類檔案進行IO競爭。
說在後面
更多精彩的文章,請關注我的微信公眾號: 艾瑞克的技術江湖
