訊息中介軟體—RocketMQ訊息儲存(二)
文章摘要:上篇中主要介紹了RocketMQ儲存部分的整體架構設計,本篇將深入分析RocketMQ儲存部分的細節內容
在本篇文章中,小編將繼續深入分析與介紹RocketMQ訊息儲存部分中的關鍵技術—Mmap與PageCache、幾種RocketMQ儲存優化技術(包括預先建立分配MappedFile、檔案預熱和mlock系統呼叫)、RocketMQ內部封裝類—CommitLog/MappedFile/MappedFileQueue/ConsumeQueue的簡析。然後,再簡要介紹下RocketMQ訊息刷盤兩種主要方式。在讀完本篇幅後,希望讀者能夠對RocketMQ訊息儲存部分有一個更為深刻和全面的認識。
一、RocketMQ儲存整體設計架構回顧
RocketMQ之所以能單機支援上萬的持久化佇列與其獨特的儲存結構是密不可分的,這裡再來看下其檔案儲存的整體設計架構。(ps:之前看了@艾瑞克的《RocketMQ高效能之底層儲存設計》覺得其表達方式和思路相當清晰,因此修改了下(一)篇中的“RocketMQ訊息儲存整體架構”)

RokcetMQ檔案儲存設計架構_v2.jpg
上面圖中假設Consumer端預設設定的是同一個ConsumerGroup,因此Consumer端執行緒採用的是負載訂閱的方式進行消費。從架構圖中可以總結出如下幾個關鍵點:
(1) 訊息生產與訊息消費相互分離 ,Producer端傳送訊息最終寫入的是CommitLog(訊息儲存的日誌資料檔案),Consumer端先從ConsumeQueue(訊息邏輯佇列)讀取持久化訊息的起始物理位置偏移量offset、大小size和訊息Tag的HashCode值,隨後再從CommitLog中進行讀取待拉取消費訊息的真正實體內容部分;
(2) RocketMQ的CommitLog檔案採用混合型儲存 (所有的Topic下的訊息佇列共用同一個CommitLog的日誌資料檔案),並通過建立類似索引檔案—ConsumeQueue的方式來區分不同Topic下面的不同MessageQueue的訊息,同時為消費訊息起到一定的緩衝作用(只有ReputMessageService非同步服務執行緒通過doDispatch非同步生成了ConsumeQueue佇列的元素後,Consumer端才能進行消費)。這樣,只要訊息寫入並刷盤至CommitLog檔案後,訊息就不會丟失,即使ConsumeQueue中的資料丟失,也可以通過CommitLog來恢復。
(3) RocketMQ每次讀寫檔案的時候真的是完全順序讀寫麼? 這裡,傳送訊息時,生產者端的訊息確實是 順序寫入CommitLog ;訂閱訊息時,消費者端也是 順序讀取ConsumeQueue ,然而根據其中的起始物理位置偏移量offset讀取訊息真實內容卻是 隨機讀取CommitLog 。 在RocketMQ叢集整體的吞吐量、併發量非常高的情況下,隨機讀取檔案帶來的效能開銷影響還是比較大的,那麼這裡如何去優化和避免這個問題呢?後面的章節將會逐步來解答這個問題。
這裡,同樣也可以總結下RocketMQ儲存架構的優缺點:
(1) 優點:
a、ConsumeQueue訊息邏輯佇列較為輕量級;
b、對磁碟的訪問序列化,避免磁碟竟爭,不會因為佇列增加導致IOWAIT增高;
(2) 缺點:
a、對於CommitLog來說寫入訊息雖然是順序寫,但是讀卻變成了完全的隨機讀;
b、Consumer端訂閱消費一條訊息,需要先讀ConsumeQueue,再讀Commit Log,一定程度上增加了開銷;
二、RocketMQ儲存關鍵技術—再談Mmap與PageCache
上篇中已經對Mmap記憶體對映技術(具體為JDK NIO的MappedByteBuffer)和PageCache概念進行了一定的深入分析。本節在回顧這兩種技術的同時,從其他的維度來闡述上篇未涉及的細節點。
1.1、Mmap記憶體對映技術—MappedByteBuffer
(1)Mmap記憶體對映技術的特點
Mmap記憶體對映和普通標準IO操作的本質區別在於它並不需要將檔案中的資料先拷貝至OS的核心IO緩衝區,而是可以直接將使用者程序私有地址空間中的一塊區域與檔案物件建立對映關係,這樣程式就好像可以直接從記憶體中完成對檔案讀/寫操作一樣。只有當缺頁中斷髮生時,直接將檔案從磁碟拷貝至使用者態的程序空間內,只進行了一次資料拷貝。對於容量較大的檔案來說(檔案大小一般需要限制在1.5~2G以下),採用Mmap的方式其讀/寫的效率和效能都非常高。

Java NIO記憶體對映模型圖.jpg
(2)JDK NIO的MappedByteBuffer簡要分析
從JDK的原始碼來看,MappedByteBuffer繼承自ByteBuffer,其內部維護了一個邏輯地址變數—address。在建立對映關係時,MappedByteBuffer利用了JDK NIO的FileChannel類提供的map()方法把檔案物件對映到虛擬記憶體。仔細看原始碼中map()方法的實現,可以發現最終其通過呼叫native方法map0()完成檔案物件的對映工作,同時使用Util.newMappedByteBuffer()方法初始化MappedByteBuffer例項,但最終返回的是DirectByteBuffer的例項。在Java程式中使用MappedByteBuffer的get()方法來獲取記憶體資料是最終通過DirectByteBuffer.get()方法實現(底層通過unsafe.getByte()方法,以“地址 + 偏移量”的方式獲取指定對映至記憶體中的資料)。
(3)使用Mmap的限制
a.Mmap對映的記憶體空間釋放的問題;由於對映的記憶體空間本身就不屬於JVM的堆記憶體區(Java Heap),因此其不受JVM GC的控制,解除安裝這部分記憶體空間需要通過系統呼叫 unmap()方法來實現。然而unmap()方法是FileChannelImpl類裡實現的私有方法,無法直接顯示呼叫。 RocketMQ中的做法是 ,通過Java反射的方式呼叫“sun.misc”包下的Cleaner類的clean()方法來釋放對映佔用的記憶體空間;
b.MappedByteBuffer記憶體對映大小限制;因為其佔用的是虛擬記憶體(非JVM的堆記憶體),大小不受JVM的-Xmx引數限制,但其大小也受到OS虛擬記憶體大小的限制。一般來說,一次只能對映1.5~2G 的檔案至使用者態的虛擬記憶體空間,這也是為何RocketMQ預設設定單個CommitLog日誌資料檔案為1G的原因了;
c.使用MappedByteBuffe的其他問題;會存在記憶體佔用率較高和檔案關閉不確定性的問題;
2.2、OS的PageCache機制
PageCache是OS對檔案的快取,用於加速對檔案的讀寫。一般來說,程式對檔案進行順序讀寫的速度幾乎接近於記憶體的讀寫訪問,這裡的主要原因就是在於OS使用PageCache機制對讀寫訪問操作進行了效能優化,將一部分的記憶體用作PageCache。
(1) 對於資料檔案的讀取 ,如果一次讀取檔案時出現未命中PageCache的情況,OS從物理磁碟上訪問讀取檔案的同時,會順序對其他相鄰塊的資料檔案進行預讀取(ps:順序讀入緊隨其後的少數幾個頁面)。這樣,只要下次訪問的檔案已經被載入至PageCache時,讀取操作的速度基本等於訪問記憶體。
(2) 對於資料檔案的寫入 ,OS會先寫入至Cache內,隨後通過非同步的方式由pdflush核心執行緒將Cache內的資料刷盤至物理磁碟上。
對於檔案的順序讀寫操作來說,讀和寫的區域都在OS的PageCache內,此時讀寫效能接近於記憶體。 RocketMQ的大致做法是 ,將資料檔案對映到OS的虛擬記憶體中(通過JDK NIO的MappedByteBuffer),寫訊息的時候首先寫入PageCache,並通過非同步刷盤的方式將訊息批量的做持久化(同時也支援同步刷盤);訂閱消費訊息時(對CommitLog操作是隨機讀取),由於PageCache的區域性性熱點原理且整體情況下還是從舊到新的有序讀,因此大部分情況下訊息還是可以直接從Page Cache中讀取,不會產生太多的缺頁(Page Fault)中斷而從磁碟讀取。

RokcetMQ檔案儲存PageCache機制.jpg
PageCache機制也不是完全無缺點的,當遇到OS進行髒頁回寫,記憶體回收,記憶體swap等情況時,就會引起較大的訊息讀寫延遲。
對於這些情況,RocketMQ採用了多種優化技術,比如記憶體預分配,檔案預熱,mlock系統呼叫等,來保證在最大可能地發揮PageCache機制優點的同時,儘可能地減少其缺點帶來的訊息讀寫延遲。
三、RocketMQ儲存優化技術
這一節將主要介紹RocketMQ儲存層採用的幾項優化技術方案在一定程度上可以減少PageCache的缺點帶來的影響,主要包括記憶體預分配,檔案預熱和mlock系統呼叫。
3.1 預先分配MappedFile
在訊息寫入過程中(呼叫CommitLog的putMessage()方法),CommitLog會先從MappedFileQueue佇列中獲取一個 MappedFile,如果沒有就新建一個。
這裡,MappedFile的建立過程是將構建好的一個AllocateRequest請求(具體做法是,將下一個檔案的路徑、下下個檔案的路徑、檔案大小為引數封裝為AllocateRequest物件)新增至佇列中,後臺執行的AllocateMappedFileService服務執行緒(在Broker啟動時,該執行緒就會建立並執行),會不停地run,只要請求佇列裡存在請求,就會去執行MappedFile對映檔案的建立和預分配工作,分配的時候有兩種策略,一種是使用Mmap的方式來構建MappedFile例項,另外一種是從TransientStorePool堆外記憶體池中獲取相應的DirectByteBuffer來構建MappedFile(ps:具體採用哪種策略,也與刷盤的方式有關)。並且,在建立分配完下個MappedFile後,還會將下下個MappedFile預先建立並儲存至請求佇列中等待下次獲取時直接返回。 RocketMQ中預分配MappedFile的設計非常巧妙,下次獲取時候直接返回就可以不用等待MappedFile建立分配所產生的時間延遲。

預分配MappedFile的主要過程.jpg
3.2 檔案預熱&&mlock系統呼叫
(1)mlock系統呼叫:其可以將程序使用的部分或者全部的地址空間鎖定在實體記憶體中,防止其被交換到swap空間。對於RocketMQ這種的高吞吐量的分散式訊息佇列來說,追求的是訊息讀寫低延遲,那麼肯定希望儘可能地多使用實體記憶體,提高資料讀寫訪問的操作效率。
(2)檔案預熱:預熱的目的主要有兩點;第一點,由於僅分配記憶體並進行mlock系統呼叫後並不會為程式完全鎖定這些記憶體,因為其中的分頁可能是寫時複製的。因此,就有必要對每個記憶體頁面中寫入一個假的值。其中,RocketMQ是在建立並分配MappedFile的過程中,預先寫入一些隨機值至Mmap映射出的記憶體空間裡。第二,呼叫Mmap進行記憶體對映後,OS只是建立虛擬記憶體地址至實體地址的對映表,而實際並沒有載入任何檔案至記憶體中。程式要訪問資料時OS會檢查該部分的分頁是否已經在記憶體中,如果不在,則發出一次缺頁中斷。這裡,可以想象下1G的CommitLog需要發生多少次缺頁中斷,才能使得對應的資料才能完全載入至實體記憶體中(ps:X86的Linux中一個標準頁面大小是4KB)? RocketMQ的做法是 ,在做Mmap記憶體對映的同時進行madvise系統呼叫,目的是使OS做一次記憶體對映後對應的檔案資料儘可能多的預載入至記憶體中,從而達到記憶體預熱的效果。
四、RocketMQ儲存相關的模型與封裝類簡析
(1)CommitLog:訊息主體以及元資料的儲存主體,儲存Producer端寫入的訊息主體內容。單個檔案大小預設1G ,檔名長度為20位,左邊補零,剩餘為起始偏移量,比如00000000000000000000代表了第一個檔案,起始偏移量為0,檔案大小為1G=1073741824;當第一個檔案寫滿了,第二個檔案為00000000001073741824,起始偏移量為1073741824,以此類推。訊息主要是順序寫入日誌檔案,當檔案滿了,寫入下一個檔案;
(2) ConsumeQueue:訊息消費的邏輯佇列,其中包含了這個MessageQueue在CommitLog中的起始物理位置偏移量offset,訊息實體內容的大小和Message Tag的雜湊值。從實際物理儲存來說,ConsumeQueue對應每個Topic和QueuId下面的檔案。單個檔案大小約5.72M,每個檔案由30W條資料組成,每個檔案預設大小為600萬個位元組,當一個ConsumeQueue型別的檔案寫滿了,則寫入下一個檔案;
(3)IndexFile:用於為生成的索引檔案提供訪問服務,通過訊息Key值查詢訊息真正的實體內容。在實際的物理儲存上,檔名則是以建立時的時間戳命名的,固定的單個IndexFile檔案大小約為400M,一個IndexFile可以儲存 2000W個索引;
(4)MapedFileQueue:對連續物理儲存的抽象封裝類,原始碼中可以通過訊息儲存的物理偏移量位置快速定位該offset所在MappedFile(具體物理儲存位置的抽象)、建立、刪除MappedFile等操作;
(5)MappedFile:檔案儲存的直接記憶體對映業務抽象封裝類,原始碼中通過操作該類,可以把訊息位元組寫入PageCache快取區(commit),或者原子性地將訊息持久化的刷盤(flush);
五、RocketMQ訊息刷盤的主要過程
在RocketMQ中訊息刷盤主要可以分為同步刷盤和非同步刷盤兩種。

RocketMQ同步&&非同步刷盤兩種方式.jpg
(1)同步刷盤:如上圖所示,只有在訊息真正持久化至磁碟後,RocketMQ的Broker端才會真正地返回給Producer端一個成功的ACK響應。同步刷盤對MQ訊息可靠性來說是一種不錯的保障,但是效能上會有較大影響,一般適用於金融業務應用領域。RocketMQ同步刷盤的大致做法是,基於生產者消費者模型,主執行緒建立刷盤請求例項—GroupCommitRequest並在放入刷盤寫佇列後喚醒同步刷盤執行緒—GroupCommitService,來執行刷盤動作(其中用了CAS變數和CountDownLatch來保證執行緒間的同步)。這裡,RocketMQ原始碼中用讀寫雙快取佇列(requestsWrite/requestsRead)來實現讀寫分離,其帶來的好處在於內部消費生成的同步刷盤請求可以不用加鎖,提高併發度。
(2)非同步刷盤:能夠充分利用OS的PageCache的優勢,只要訊息寫入PageCache即可將成功的ACK返回給Producer端。訊息刷盤採用後臺非同步執行緒提交的方式進行,降低了讀寫延遲,提高了MQ的效能和吞吐量。非同步和同步刷盤的區別在於,非同步刷盤時,主執行緒並不會阻塞,在將刷盤執行緒wakeup後,就會繼續執行。
六、結語
在參考了@艾瑞克的那篇RocketMQ儲存相關技術博文後,讓我理解了公眾號的文章與其他技術細節文章應該是有所區別的,公眾號文章還是力求精簡(ps:貼大量程式碼尤其需要慎重),篇幅太長會影響閱讀體驗,更多的內容應該以各種設計圖和少量的文字為說明。同時,由於RocketMQ本身較為複雜,光看技術文章只能理解和領會一個大概,更多地還是需要自己多擼原始碼、Debug以及多實踐才能對其有一個較為深入的理解。
由於目前微信對本公眾號依然沒有放開評論功能,需要討論的同學可以直接在公號內給我留言,我會依次回覆內容。如果喜歡本文,請收藏後點個贊並轉發朋友圈哦。