文章摘要:上篇中主要介紹了RocketMQ存儲部分的整體架構(gòu)設(shè)計,本篇將深入分析RocketMQ存儲部分的細節(jié)內(nèi)容
在本篇文章中,小編將繼續(xù)深入分析與介紹RocketMQ消息存儲部分中的關(guān)鍵技術(shù)—Mmap與PageCache、幾種RocketMQ存儲優(yōu)化技術(shù)(包括預先創(chuàng)建分配MappedFile、文件預熱和mlock系統(tǒng)調(diào)用)、RocketMQ內(nèi)部封裝類—CommitLog/MappedFile/MappedFileQueue/ConsumeQueue的簡析。然后,再簡要介紹下RocketMQ消息刷盤兩種主要方式。在讀完本篇幅后,希望讀者能夠?qū)ocketMQ消息存儲部分有一個更為深刻和全面的認識。
一、RocketMQ存儲整體設(shè)計架構(gòu)回顧
RocketMQ之所以能單機支持上萬的持久化隊列與其獨特的存儲結(jié)構(gòu)是密不可分的,這里再來看下其文件存儲的整體設(shè)計架構(gòu)。(ps:之前看了@艾瑞克的《RocketMQ高性能之底層存儲設(shè)計》覺得其表達方式和思路相當清晰,因此修改了下(一)篇中的“RocketMQ消息存儲整體架構(gòu)”)
上面圖中假設(shè)Consumer端默認設(shè)置的是同一個ConsumerGroup,因此Consumer端線程采用的是負載訂閱的方式進行消費。從架構(gòu)圖中可以總結(jié)出如下幾個關(guān)鍵點:
(1)消息生產(chǎn)與消息消費相互分離,Producer端發(fā)送消息最終寫入的是CommitLog(消息存儲的日志數(shù)據(jù)文件),Consumer端先從ConsumeQueue(消息邏輯隊列)讀取持久化消息的起始物理位置偏移量offset、大小size和消息Tag的HashCode值,隨后再從CommitLog中進行讀取待拉取消費消息的真正實體內(nèi)容部分;
(2)RocketMQ的CommitLog文件采用混合型存儲(所有的Topic下的消息隊列共用同一個CommitLog的日志數(shù)據(jù)文件),并通過建立類似索引文件—ConsumeQueue的方式來區(qū)分不同Topic下面的不同MessageQueue的消息,同時為消費消息起到一定的緩沖作用(只有ReputMessageService異步服務(wù)線程通過doDispatch異步生成了ConsumeQueue隊列的元素后,Consumer端才能進行消費)。這樣,只要消息寫入并刷盤至CommitLog文件后,消息就不會丟失,即使ConsumeQueue中的數(shù)據(jù)丟失,也可以通過CommitLog來恢復。
(3)RocketMQ每次讀寫文件的時候真的是完全順序讀寫么?這里,發(fā)送消息時,生產(chǎn)者端的消息確實是順序?qū)懭隒ommitLog;訂閱消息時,消費者端也是順序讀取ConsumeQueue,然而根據(jù)其中的起始物理位置偏移量offset讀取消息真實內(nèi)容卻是隨機讀取CommitLog。 在RocketMQ集群整體的吞吐量、并發(fā)量非常高的情況下,隨機讀取文件帶來的性能開銷影響還是比較大的,那么這里如何去優(yōu)化和避免這個問題呢?后面的章節(jié)將會逐步來解答這個問題。
這里,同樣也可以總結(jié)下RocketMQ存儲架構(gòu)的優(yōu)缺點:
(1)優(yōu)點:
a、ConsumeQueue消息邏輯隊列較為輕量級;
b、對磁盤的訪問串行化,避免磁盤竟爭,不會因為隊列增加導致IOWAIT增高;
(2)缺點:
a、對于CommitLog來說寫入消息雖然是順序?qū)懀亲x卻變成了完全的隨機讀;
b、Consumer端訂閱消費一條消息,需要先讀ConsumeQueue,再讀Commit Log,一定程度上增加了開銷;
二、RocketMQ存儲關(guān)鍵技術(shù)—再談Mmap與PageCache
上篇中已經(jīng)對Mmap內(nèi)存映射技術(shù)(具體為JDK NIO的MappedByteBuffer)和PageCache概念進行了一定的深入分析。本節(jié)在回顧這兩種技術(shù)的同時,從其他的維度來闡述上篇未涉及的細節(jié)點。
1.1、Mmap內(nèi)存映射技術(shù)—MappedByteBuffer
(1)Mmap內(nèi)存映射技術(shù)的特點
Mmap內(nèi)存映射和普通標準IO操作的本質(zhì)區(qū)別在于它并不需要將文件中的數(shù)據(jù)先拷貝至OS的內(nèi)核IO緩沖區(qū),而是可以直接將用戶進程私有地址空間中的一塊區(qū)域與文件對象建立映射關(guān)系,這樣程序就好像可以直接從內(nèi)存中完成對文件讀/寫操作一樣。只有當缺頁中斷發(fā)生時,直接將文件從磁盤拷貝至用戶態(tài)的進程空間內(nèi),只進行了一次數(shù)據(jù)拷貝。對于容量較大的文件來說(文件大小一般需要限制在1.5~2G以下),采用Mmap的方式其讀/寫的效率和性能都非常高。
(2)JDK NIO的MappedByteBuffer簡要分析
從JDK的源碼來看,MappedByteBuffer繼承自ByteBuffer,其內(nèi)部維護了一個邏輯地址變量—address。在建立映射關(guān)系時,MappedByteBuffer利用了JDK NIO的FileChannel類提供的map()方法把文件對象映射到虛擬內(nèi)存。仔細看源碼中map()方法的實現(xiàn),可以發(fā)現(xiàn)最終其通過調(diào)用native方法map0()完成文件對象的映射工作,同時使用Util.newMappedByteBuffer()方法初始化MappedByteBuffer實例,但最終返回的是DirectByteBuffer的實例。在Java程序中使用MappedByteBuffer的get()方法來獲取內(nèi)存數(shù)據(jù)是最終通過DirectByteBuffer.get()方法實現(xiàn)(底層通過unsafe.getByte()方法,以“地址 + 偏移量”的方式獲取指定映射至內(nèi)存中的數(shù)據(jù))。
(3)使用Mmap的限制
a.Mmap映射的內(nèi)存空間釋放的問題;由于映射的內(nèi)存空間本身就不屬于JVM的堆內(nèi)存區(qū)(Java Heap),因此其不受JVM GC的控制,卸載這部分內(nèi)存空間需要通過系統(tǒng)調(diào)用 unmap()方法來實現(xiàn)。然而unmap()方法是FileChannelImpl類里實現(xiàn)的私有方法,無法直接顯示調(diào)用。RocketMQ中的做法是,通過Java反射的方式調(diào)用“sun.misc”包下的Cleaner類的clean()方法來釋放映射占用的內(nèi)存空間;
b.MappedByteBuffer內(nèi)存映射大小限制;因為其占用的是虛擬內(nèi)存(非JVM的堆內(nèi)存),大小不受JVM的-Xmx參數(shù)限制,但其大小也受到OS虛擬內(nèi)存大小的限制。一般來說,一次只能映射1.5~2G 的文件至用戶態(tài)的虛擬內(nèi)存空間,這也是為何RocketMQ默認設(shè)置單個CommitLog日志數(shù)據(jù)文件為1G的原因了;
c.使用MappedByteBuffe的其他問題;會存在內(nèi)存占用率較高和文件關(guān)閉不確定性的問題;
2.2、OS的PageCache機制
PageCache是OS對文件的緩存,用于加速對文件的讀寫。一般來說,程序?qū)ξ募M行順序讀寫的速度幾乎接近于內(nèi)存的讀寫訪問,這里的主要原因就是在于OS使用PageCache機制對讀寫訪問操作進行了性能優(yōu)化,將一部分的內(nèi)存用作PageCache。
(1)對于數(shù)據(jù)文件的讀取,如果一次讀取文件時出現(xiàn)未命中PageCache的情況,OS從物理磁盤上訪問讀取文件的同時,會順序?qū)ζ渌噜弶K的數(shù)據(jù)文件進行預讀取(ps:順序讀入緊隨其后的少數(shù)幾個頁面)。這樣,只要下次訪問的文件已經(jīng)被加載至PageCache時,讀取操作的速度基本等于訪問內(nèi)存。
(2)對于數(shù)據(jù)文件的寫入,OS會先寫入至Cache內(nèi),隨后通過異步的方式由pdflush內(nèi)核線程將Cache內(nèi)的數(shù)據(jù)刷盤至物理磁盤上。
對于文件的順序讀寫操作來說,讀和寫的區(qū)域都在OS的PageCache內(nèi),此時讀寫性能接近于內(nèi)存。RocketMQ的大致做法是,將數(shù)據(jù)文件映射到OS的虛擬內(nèi)存中(通過JDK NIO的MappedByteBuffer),寫消息的時候首先寫入PageCache,并通過異步刷盤的方式將消息批量的做持久化(同時也支持同步刷盤);訂閱消費消息時(對CommitLog操作是隨機讀取),由于PageCache的局部性熱點原理且整體情況下還是從舊到新的有序讀,因此大部分情況下消息還是可以直接從Page Cache中讀取,不會產(chǎn)生太多的缺頁(Page Fault)中斷而從磁盤讀取。
PageCache機制也不是完全無缺點的,當遇到OS進行臟頁回寫,內(nèi)存回收,內(nèi)存swap等情況時,就會引起較大的消息讀寫延遲。
對于這些情況,RocketMQ采用了多種優(yōu)化技術(shù),比如內(nèi)存預分配,文件預熱,mlock系統(tǒng)調(diào)用等,來保證在最大可能地發(fā)揮PageCache機制優(yōu)點的同時,盡可能地減少其缺點帶來的消息讀寫延遲。
三、RocketMQ存儲優(yōu)化技術(shù)
這一節(jié)將主要介紹RocketMQ存儲層采用的幾項優(yōu)化技術(shù)方案在一定程度上可以減少PageCache的缺點帶來的影響,主要包括內(nèi)存預分配,文件預熱和mlock系統(tǒng)調(diào)用。
3.1 預先分配MappedFile
在消息寫入過程中(調(diào)用CommitLog的putMessage()方法),CommitLog會先從MappedFileQueue隊列中獲取一個 MappedFile,如果沒有就新建一個。
這里,MappedFile的創(chuàng)建過程是將構(gòu)建好的一個AllocateRequest請求(具體做法是,將下一個文件的路徑、下下個文件的路徑、文件大小為參數(shù)封裝為AllocateRequest對象)添加至隊列中,后臺運行的AllocateMappedFileService服務(wù)線程(在Broker啟動時,該線程就會創(chuàng)建并運行),會不停地run,只要請求隊列里存在請求,就會去執(zhí)行MappedFile映射文件的創(chuàng)建和預分配工作,分配的時候有兩種策略,一種是使用Mmap的方式來構(gòu)建MappedFile實例,另外一種是從TransientStorePool堆外內(nèi)存池中獲取相應(yīng)的DirectByteBuffer來構(gòu)建MappedFile(ps:具體采用哪種策略,也與刷盤的方式有關(guān))。并且,在創(chuàng)建分配完下個MappedFile后,還會將下下個MappedFile預先創(chuàng)建并保存至請求隊列中等待下次獲取時直接返回。RocketMQ中預分配MappedFile的設(shè)計非常巧妙,下次獲取時候直接返回就可以不用等待MappedFile創(chuàng)建分配所產(chǎn)生的時間延遲。
3.2 文件預熱&&mlock系統(tǒng)調(diào)用
(1)mlock系統(tǒng)調(diào)用:其可以將進程使用的部分或者全部的地址空間鎖定在物理內(nèi)存中,防止其被交換到swap空間。對于RocketMQ這種的高吞吐量的分布式消息隊列來說,追求的是消息讀寫低延遲,那么肯定希望盡可能地多使用物理內(nèi)存,提高數(shù)據(jù)讀寫訪問的操作效率。
(2)文件預熱:預熱的目的主要有兩點;第一點,由于僅分配內(nèi)存并進行mlock系統(tǒng)調(diào)用后并不會為程序完全鎖定這些內(nèi)存,因為其中的分頁可能是寫時復制的。因此,就有必要對每個內(nèi)存頁面中寫入一個假的值。其中,RocketMQ是在創(chuàng)建并分配MappedFile的過程中,預先寫入一些隨機值至Mmap映射出的內(nèi)存空間里。第二,調(diào)用Mmap進行內(nèi)存映射后,OS只是建立虛擬內(nèi)存地址至物理地址的映射表,而實際并沒有加載任何文件至內(nèi)存中。程序要訪問數(shù)據(jù)時OS會檢查該部分的分頁是否已經(jīng)在內(nèi)存中,如果不在,則發(fā)出一次缺頁中斷。這里,可以想象下1G的CommitLog需要發(fā)生多少次缺頁中斷,才能使得對應(yīng)的數(shù)據(jù)才能完全加載至物理內(nèi)存中(ps:X86的Linux中一個標準頁面大小是4KB)?RocketMQ的做法是,在做Mmap內(nèi)存映射的同時進行madvise系統(tǒng)調(diào)用,目的是使OS做一次內(nèi)存映射后對應(yīng)的文件數(shù)據(jù)盡可能多的預加載至內(nèi)存中,從而達到內(nèi)存預熱的效果。
四、RocketMQ存儲相關(guān)的模型與封裝類簡析
(1)CommitLog:消息主體以及元數(shù)據(jù)的存儲主體,存儲Producer端寫入的消息主體內(nèi)容。單個文件大小默認1G ,文件名長度為20位,左邊補零,剩余為起始偏移量,比如00000000000000000000代表了第一個文件,起始偏移量為0,文件大小為1G=1073741824;當?shù)谝粋€文件寫滿了,第二個文件為00000000001073741824,起始偏移量為1073741824,以此類推。消息主要是順序?qū)懭肴罩疚募斘募M了,寫入下一個文件;
(2) ConsumeQueue:消息消費的邏輯隊列,其中包含了這個MessageQueue在CommitLog中的起始物理位置偏移量offset,消息實體內(nèi)容的大小和Message Tag的哈希值。從實際物理存儲來說,ConsumeQueue對應(yīng)每個Topic和QueuId下面的文件。單個文件大小約5.72M,每個文件由30W條數(shù)據(jù)組成,每個文件默認大小為600萬個字節(jié),當一個ConsumeQueue類型的文件寫滿了,則寫入下一個文件;
(3)IndexFile:用于為生成的索引文件提供訪問服務(wù),通過消息Key值查詢消息真正的實體內(nèi)容。在實際的物理存儲上,文件名則是以創(chuàng)建時的時間戳命名的,固定的單個IndexFile文件大小約為400M,一個IndexFile可以保存 2000W個索引;
(4)MapedFileQueue:對連續(xù)物理存儲的抽象封裝類,源碼中可以通過消息存儲的物理偏移量位置快速定位該offset所在MappedFile(具體物理存儲位置的抽象)、創(chuàng)建、刪除MappedFile等操作;
(5)MappedFile:文件存儲的直接內(nèi)存映射業(yè)務(wù)抽象封裝類,源碼中通過操作該類,可以把消息字節(jié)寫入PageCache緩存區(qū)(commit),或者原子性地將消息持久化的刷盤(flush);
五、RocketMQ消息刷盤的主要過程
在RocketMQ中消息刷盤主要可以分為同步刷盤和異步刷盤兩種。
(1)同步刷盤:如上圖所示,只有在消息真正持久化至磁盤后,RocketMQ的Broker端才會真正地返回給Producer端一個成功的ACK響應(yīng)。同步刷盤對MQ消息可靠性來說是一種不錯的保障,但是性能上會有較大影響,一般適用于金融業(yè)務(wù)應(yīng)用領(lǐng)域。RocketMQ同步刷盤的大致做法是,基于生產(chǎn)者消費者模型,主線程創(chuàng)建刷盤請求實例—GroupCommitRequest并在放入刷盤寫隊列后喚醒同步刷盤線程—GroupCommitService,來執(zhí)行刷盤動作(其中用了CAS變量和CountDownLatch來保證線程間的同步)。這里,RocketMQ源碼中用讀寫雙緩存隊列(requestsWrite/requestsRead)來實現(xiàn)讀寫分離,其帶來的好處在于內(nèi)部消費生成的同步刷盤請求可以不用加鎖,提高并發(fā)度。
(2)異步刷盤:能夠充分利用OS的PageCache的優(yōu)勢,只要消息寫入PageCache即可將成功的ACK返回給Producer端。消息刷盤采用后臺異步線程提交的方式進行,降低了讀寫延遲,提高了MQ的性能和吞吐量。異步和同步刷盤的區(qū)別在于,異步刷盤時,主線程并不會阻塞,在將刷盤線程wakeup后,就會繼續(xù)執(zhí)行。
六、結(jié)語
在參考了@艾瑞克的那篇RocketMQ存儲相關(guān)技術(shù)博文后,讓我理解了公眾號的文章與其他技術(shù)細節(jié)文章應(yīng)該是有所區(qū)別的,公眾號文章還是力求精簡(ps:貼大量代碼尤其需要慎重),篇幅太長會影響閱讀體驗,更多的內(nèi)容應(yīng)該以各種設(shè)計圖和少量的文字為說明。同時,由于RocketMQ本身較為復雜,光看技術(shù)文章只能理解和領(lǐng)會一個大概,更多地還是需要自己多擼源碼、Debug以及多實踐才能對其有一個較為深入的理解。
由于目前微信對本公眾號依然沒有放開評論功能,需要討論的同學可以直接在公號內(nèi)給我留言,我會依次回復內(nèi)容。如果喜歡本文,請收藏后點個贊并轉(zhuǎn)發(fā)朋友圈哦。