RocketMQ高效能之底層儲存設計
說在前面
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的記憶體回收掉,對效能非常苛刻的系統會產生毛刺。
刷盤
刷盤一般分成:同步刷盤和非同步刷盤
同步刷盤
在訊息真正落盤後,才返回成功給Producer,只要磁碟沒有損壞,訊息就不會丟。
一般只用於金融場景,這種方式不是本文討論的重點,因為沒有利用Page Cache的特點,RMQ採用GroupCommit的方式對同步刷盤進行了優化。
非同步刷盤
讀寫檔案充分利用了Page Cache,即寫入Page Cache就返回成功給Producer,RMQ中有兩種方式進行非同步刷盤,整體原理是一樣的。
刷盤由程式和OS共同控制
先談談OS,當程式順序寫檔案時,首先寫到Cache中,這部分被修改過,但卻沒有被刷進磁碟,產生了不一致,這些不一致的記憶體叫做髒頁(Dirty Page)。
髒頁設定太小,Flush磁碟的次數就會增加,效能會下降;髒頁設定太大,效能會提高,但萬一OS宕機,髒頁來不及刷盤,訊息就丟了。
一般不是高配玩家,用OS的預設值就好,如上圖。
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競爭。