文章摘要:上篇中主要介紹了RocketMQ存儲(chǔ)部分的整體架構(gòu)設(shè)計(jì),本篇將深入分析RocketMQ存儲(chǔ)部分的細(xì)節(jié)內(nèi)容
在本篇文章中,小編將繼續(xù)深入分析與介紹RocketMQ消息存儲(chǔ)部分中的關(guān)鍵技術(shù)—Mmap與PageCache、幾種RocketMQ存儲(chǔ)優(yōu)化技術(shù)(包括預(yù)先創(chuàng)建分配MappedFile、文件預(yù)熱和mlock系統(tǒng)調(diào)用)、RocketMQ內(nèi)部封裝類—CommitLog/MappedFile/MappedFileQueue/ConsumeQueue的簡(jiǎn)析。然后,再簡(jiǎn)要介紹下RocketMQ消息刷盤兩種主要方式。在讀完本篇幅后,希望讀者能夠?qū)ocketMQ消息存儲(chǔ)部分有一個(gè)更為深刻和全面的認(rèn)識(shí)。
一、RocketMQ存儲(chǔ)整體設(shè)計(jì)架構(gòu)回顧
RocketMQ之所以能單機(jī)支持上萬(wàn)的持久化隊(duì)列與其獨(dú)特的存儲(chǔ)結(jié)構(gòu)是密不可分的,這里再來(lái)看下其文件存儲(chǔ)的整體設(shè)計(jì)架構(gòu)。(ps:之前看了@艾瑞克的《RocketMQ高性能之底層存儲(chǔ)設(shè)計(jì)》覺(jué)得其表達(dá)方式和思路相當(dāng)清晰,因此修改了下(一)篇中的“RocketMQ消息存儲(chǔ)整體架構(gòu)”)

上面圖中假設(shè)Consumer端默認(rèn)設(shè)置的是同一個(gè)ConsumerGroup,因此Consumer端線程采用的是負(fù)載訂閱的方式進(jìn)行消費(fèi)。從架構(gòu)圖中可以總結(jié)出如下幾個(gè)關(guān)鍵點(diǎn):
(1)消息生產(chǎn)與消息消費(fèi)相互分離,Producer端發(fā)送消息最終寫入的是CommitLog(消息存儲(chǔ)的日志數(shù)據(jù)文件),Consumer端先從ConsumeQueue(消息邏輯隊(duì)列)讀取持久化消息的起始物理位置偏移量offset、大小size和消息Tag的HashCode值,隨后再?gòu)腃ommitLog中進(jìn)行讀取待拉取消費(fèi)消息的真正實(shí)體內(nèi)容部分;
(2)RocketMQ的CommitLog文件采用混合型存儲(chǔ)(所有的Topic下的消息隊(duì)列共用同一個(gè)CommitLog的日志數(shù)據(jù)文件),并通過(guò)建立類似索引文件—ConsumeQueue的方式來(lái)區(qū)分不同Topic下面的不同MessageQueue的消息,同時(shí)為消費(fèi)消息起到一定的緩沖作用(只有ReputMessageService異步服務(wù)線程通過(guò)doDispatch異步生成了ConsumeQueue隊(duì)列的元素后,Consumer端才能進(jìn)行消費(fèi))。這樣,只要消息寫入并刷盤至CommitLog文件后,消息就不會(huì)丟失,即使ConsumeQueue中的數(shù)據(jù)丟失,也可以通過(guò)CommitLog來(lái)恢復(fù)。
(3)RocketMQ每次讀寫文件的時(shí)候真的是完全順序讀寫么?這里,發(fā)送消息時(shí),生產(chǎn)者端的消息確實(shí)是順序?qū)懭隒ommitLog;訂閱消息時(shí),消費(fèi)者端也是順序讀取ConsumeQueue,然而根據(jù)其中的起始物理位置偏移量offset讀取消息真實(shí)內(nèi)容卻是隨機(jī)讀取CommitLog。 在RocketMQ集群整體的吞吐量、并發(fā)量非常高的情況下,隨機(jī)讀取文件帶來(lái)的性能開(kāi)銷影響還是比較大的,那么這里如何去優(yōu)化和避免這個(gè)問(wèn)題呢?后面的章節(jié)將會(huì)逐步來(lái)解答這個(gè)問(wèn)題。
這里,同樣也可以總結(jié)下RocketMQ存儲(chǔ)架構(gòu)的優(yōu)缺點(diǎn):
(1)優(yōu)點(diǎn):
a、ConsumeQueue消息邏輯隊(duì)列較為輕量級(jí);
b、對(duì)磁盤的訪問(wèn)串行化,避免磁盤竟?fàn)?,不?huì)因?yàn)殛?duì)列增加導(dǎo)致IOWAIT增高;
(2)缺點(diǎn):
a、對(duì)于CommitLog來(lái)說(shuō)寫入消息雖然是順序?qū)懀亲x卻變成了完全的隨機(jī)讀;
b、Consumer端訂閱消費(fèi)一條消息,需要先讀ConsumeQueue,再讀Commit Log,一定程度上增加了開(kāi)銷;
二、RocketMQ存儲(chǔ)關(guān)鍵技術(shù)—再談Mmap與PageCache
上篇中已經(jīng)對(duì)Mmap內(nèi)存映射技術(shù)(具體為JDK NIO的MappedByteBuffer)和PageCache概念進(jìn)行了一定的深入分析。本節(jié)在回顧這兩種技術(shù)的同時(shí),從其他的維度來(lái)闡述上篇未涉及的細(xì)節(jié)點(diǎn)。
1.1、Mmap內(nèi)存映射技術(shù)—MappedByteBuffer
(1)Mmap內(nèi)存映射技術(shù)的特點(diǎn)
Mmap內(nèi)存映射和普通標(biāo)準(zhǔn)IO操作的本質(zhì)區(qū)別在于它并不需要將文件中的數(shù)據(jù)先拷貝至OS的內(nèi)核IO緩沖區(qū),而是可以直接將用戶進(jìn)程私有地址空間中的一塊區(qū)域與文件對(duì)象建立映射關(guān)系,這樣程序就好像可以直接從內(nèi)存中完成對(duì)文件讀/寫操作一樣。只有當(dāng)缺頁(yè)中斷發(fā)生時(shí),直接將文件從磁盤拷貝至用戶態(tài)的進(jìn)程空間內(nèi),只進(jìn)行了一次數(shù)據(jù)拷貝。對(duì)于容量較大的文件來(lái)說(shuō)(文件大小一般需要限制在1.5~2G以下),采用Mmap的方式其讀/寫的效率和性能都非常高。

(2)JDK NIO的MappedByteBuffer簡(jiǎn)要分析
從JDK的源碼來(lái)看,MappedByteBuffer繼承自ByteBuffer,其內(nèi)部維護(hù)了一個(gè)邏輯地址變量—address。在建立映射關(guān)系時(shí),MappedByteBuffer利用了JDK NIO的FileChannel類提供的map()方法把文件對(duì)象映射到虛擬內(nèi)存。仔細(xì)看源碼中map()方法的實(shí)現(xiàn),可以發(fā)現(xiàn)最終其通過(guò)調(diào)用native方法map0()完成文件對(duì)象的映射工作,同時(shí)使用Util.newMappedByteBuffer()方法初始化MappedByteBuffer實(shí)例,但最終返回的是DirectByteBuffer的實(shí)例。在Java程序中使用MappedByteBuffer的get()方法來(lái)獲取內(nèi)存數(shù)據(jù)是最終通過(guò)DirectByteBuffer.get()方法實(shí)現(xiàn)(底層通過(guò)unsafe.getByte()方法,以“地址 + 偏移量”的方式獲取指定映射至內(nèi)存中的數(shù)據(jù))。
(3)使用Mmap的限制
a.Mmap映射的內(nèi)存空間釋放的問(wèn)題;由于映射的內(nèi)存空間本身就不屬于JVM的堆內(nèi)存區(qū)(Java Heap),因此其不受JVM GC的控制,卸載這部分內(nèi)存空間需要通過(guò)系統(tǒng)調(diào)用 unmap()方法來(lái)實(shí)現(xiàn)。然而unmap()方法是FileChannelImpl類里實(shí)現(xiàn)的私有方法,無(wú)法直接顯示調(diào)用。RocketMQ中的做法是,通過(guò)Java反射的方式調(diào)用“sun.misc”包下的Cleaner類的clean()方法來(lái)釋放映射占用的內(nèi)存空間;
b.MappedByteBuffer內(nèi)存映射大小限制;因?yàn)槠湔加玫氖翘摂M內(nèi)存(非JVM的堆內(nèi)存),大小不受JVM的-Xmx參數(shù)限制,但其大小也受到OS虛擬內(nèi)存大小的限制。一般來(lái)說(shuō),一次只能映射1.5~2G 的文件至用戶態(tài)的虛擬內(nèi)存空間,這也是為何RocketMQ默認(rèn)設(shè)置單個(gè)CommitLog日志數(shù)據(jù)文件為1G的原因了;
c.使用MappedByteBuffe的其他問(wèn)題;會(huì)存在內(nèi)存占用率較高和文件關(guān)閉不確定性的問(wèn)題;
2.2、OS的PageCache機(jī)制
PageCache是OS對(duì)文件的緩存,用于加速對(duì)文件的讀寫。一般來(lái)說(shuō),程序?qū)ξ募M(jìn)行順序讀寫的速度幾乎接近于內(nèi)存的讀寫訪問(wèn),這里的主要原因就是在于OS使用PageCache機(jī)制對(duì)讀寫訪問(wèn)操作進(jìn)行了性能優(yōu)化,將一部分的內(nèi)存用作PageCache。
(1)對(duì)于數(shù)據(jù)文件的讀取,如果一次讀取文件時(shí)出現(xiàn)未命中PageCache的情況,OS從物理磁盤上訪問(wèn)讀取文件的同時(shí),會(huì)順序?qū)ζ渌噜弶K的數(shù)據(jù)文件進(jìn)行預(yù)讀?。╬s:順序讀入緊隨其后的少數(shù)幾個(gè)頁(yè)面)。這樣,只要下次訪問(wèn)的文件已經(jīng)被加載至PageCache時(shí),讀取操作的速度基本等于訪問(wèn)內(nèi)存。
(2)對(duì)于數(shù)據(jù)文件的寫入,OS會(huì)先寫入至Cache內(nèi),隨后通過(guò)異步的方式由pdflush內(nèi)核線程將Cache內(nèi)的數(shù)據(jù)刷盤至物理磁盤上。
對(duì)于文件的順序讀寫操作來(lái)說(shuō),讀和寫的區(qū)域都在OS的PageCache內(nèi),此時(shí)讀寫性能接近于內(nèi)存。RocketMQ的大致做法是,將數(shù)據(jù)文件映射到OS的虛擬內(nèi)存中(通過(guò)JDK NIO的MappedByteBuffer),寫消息的時(shí)候首先寫入PageCache,并通過(guò)異步刷盤的方式將消息批量的做持久化(同時(shí)也支持同步刷盤);訂閱消費(fèi)消息時(shí)(對(duì)CommitLog操作是隨機(jī)讀?。?,由于PageCache的局部性熱點(diǎn)原理且整體情況下還是從舊到新的有序讀,因此大部分情況下消息還是可以直接從Page Cache中讀取,不會(huì)產(chǎn)生太多的缺頁(yè)(Page Fault)中斷而從磁盤讀取。

PageCache機(jī)制也不是完全無(wú)缺點(diǎn)的,當(dāng)遇到OS進(jìn)行臟頁(yè)回寫,內(nèi)存回收,內(nèi)存swap等情況時(shí),就會(huì)引起較大的消息讀寫延遲。
對(duì)于這些情況,RocketMQ采用了多種優(yōu)化技術(shù),比如內(nèi)存預(yù)分配,文件預(yù)熱,mlock系統(tǒng)調(diào)用等,來(lái)保證在最大可能地發(fā)揮PageCache機(jī)制優(yōu)點(diǎn)的同時(shí),盡可能地減少其缺點(diǎn)帶來(lái)的消息讀寫延遲。
三、RocketMQ存儲(chǔ)優(yōu)化技術(shù)
這一節(jié)將主要介紹RocketMQ存儲(chǔ)層采用的幾項(xiàng)優(yōu)化技術(shù)方案在一定程度上可以減少PageCache的缺點(diǎn)帶來(lái)的影響,主要包括內(nèi)存預(yù)分配,文件預(yù)熱和mlock系統(tǒng)調(diào)用。
3.1 預(yù)先分配MappedFile
在消息寫入過(guò)程中(調(diào)用CommitLog的putMessage()方法),CommitLog會(huì)先從MappedFileQueue隊(duì)列中獲取一個(gè) MappedFile,如果沒(méi)有就新建一個(gè)。
這里,MappedFile的創(chuàng)建過(guò)程是將構(gòu)建好的一個(gè)AllocateRequest請(qǐng)求(具體做法是,將下一個(gè)文件的路徑、下下個(gè)文件的路徑、文件大小為參數(shù)封裝為AllocateRequest對(duì)象)添加至隊(duì)列中,后臺(tái)運(yùn)行的AllocateMappedFileService服務(wù)線程(在Broker啟動(dòng)時(shí),該線程就會(huì)創(chuàng)建并運(yùn)行),會(huì)不停地run,只要請(qǐng)求隊(duì)列里存在請(qǐng)求,就會(huì)去執(zhí)行MappedFile映射文件的創(chuàng)建和預(yù)分配工作,分配的時(shí)候有兩種策略,一種是使用Mmap的方式來(lái)構(gòu)建MappedFile實(shí)例,另外一種是從TransientStorePool堆外內(nèi)存池中獲取相應(yīng)的DirectByteBuffer來(lái)構(gòu)建MappedFile(ps:具體采用哪種策略,也與刷盤的方式有關(guān))。并且,在創(chuàng)建分配完下個(gè)MappedFile后,還會(huì)將下下個(gè)MappedFile預(yù)先創(chuàng)建并保存至請(qǐng)求隊(duì)列中等待下次獲取時(shí)直接返回。RocketMQ中預(yù)分配MappedFile的設(shè)計(jì)非常巧妙,下次獲取時(shí)候直接返回就可以不用等待MappedFile創(chuàng)建分配所產(chǎn)生的時(shí)間延遲。

3.2 文件預(yù)熱&&mlock系統(tǒng)調(diào)用
(1)mlock系統(tǒng)調(diào)用:其可以將進(jìn)程使用的部分或者全部的地址空間鎖定在物理內(nèi)存中,防止其被交換到swap空間。對(duì)于RocketMQ這種的高吞吐量的分布式消息隊(duì)列來(lái)說(shuō),追求的是消息讀寫低延遲,那么肯定希望盡可能地多使用物理內(nèi)存,提高數(shù)據(jù)讀寫訪問(wèn)的操作效率。
(2)文件預(yù)熱:預(yù)熱的目的主要有兩點(diǎn);第一點(diǎn),由于僅分配內(nèi)存并進(jìn)行mlock系統(tǒng)調(diào)用后并不會(huì)為程序完全鎖定這些內(nèi)存,因?yàn)槠渲械姆猪?yè)可能是寫時(shí)復(fù)制的。因此,就有必要對(duì)每個(gè)內(nèi)存頁(yè)面中寫入一個(gè)假的值。其中,RocketMQ是在創(chuàng)建并分配MappedFile的過(guò)程中,預(yù)先寫入一些隨機(jī)值至Mmap映射出的內(nèi)存空間里。第二,調(diào)用Mmap進(jìn)行內(nèi)存映射后,OS只是建立虛擬內(nèi)存地址至物理地址的映射表,而實(shí)際并沒(méi)有加載任何文件至內(nèi)存中。程序要訪問(wèn)數(shù)據(jù)時(shí)OS會(huì)檢查該部分的分頁(yè)是否已經(jīng)在內(nèi)存中,如果不在,則發(fā)出一次缺頁(yè)中斷。這里,可以想象下1G的CommitLog需要發(fā)生多少次缺頁(yè)中斷,才能使得對(duì)應(yīng)的數(shù)據(jù)才能完全加載至物理內(nèi)存中(ps:X86的Linux中一個(gè)標(biāo)準(zhǔn)頁(yè)面大小是4KB)?RocketMQ的做法是,在做Mmap內(nèi)存映射的同時(shí)進(jìn)行madvise系統(tǒng)調(diào)用,目的是使OS做一次內(nèi)存映射后對(duì)應(yīng)的文件數(shù)據(jù)盡可能多的預(yù)加載至內(nèi)存中,從而達(dá)到內(nèi)存預(yù)熱的效果。
四、RocketMQ存儲(chǔ)相關(guān)的模型與封裝類簡(jiǎn)析
(1)CommitLog:消息主體以及元數(shù)據(jù)的存儲(chǔ)主體,存儲(chǔ)Producer端寫入的消息主體內(nèi)容。單個(gè)文件大小默認(rèn)1G ,文件名長(zhǎng)度為20位,左邊補(bǔ)零,剩余為起始偏移量,比如00000000000000000000代表了第一個(gè)文件,起始偏移量為0,文件大小為1G=1073741824;當(dāng)?shù)谝粋€(gè)文件寫滿了,第二個(gè)文件為00000000001073741824,起始偏移量為1073741824,以此類推。消息主要是順序?qū)懭肴罩疚募?,?dāng)文件滿了,寫入下一個(gè)文件;
(2) ConsumeQueue:消息消費(fèi)的邏輯隊(duì)列,其中包含了這個(gè)MessageQueue在CommitLog中的起始物理位置偏移量offset,消息實(shí)體內(nèi)容的大小和Message Tag的哈希值。從實(shí)際物理存儲(chǔ)來(lái)說(shuō),ConsumeQueue對(duì)應(yīng)每個(gè)Topic和QueuId下面的文件。單個(gè)文件大小約5.72M,每個(gè)文件由30W條數(shù)據(jù)組成,每個(gè)文件默認(rèn)大小為600萬(wàn)個(gè)字節(jié),當(dāng)一個(gè)ConsumeQueue類型的文件寫滿了,則寫入下一個(gè)文件;
(3)IndexFile:用于為生成的索引文件提供訪問(wèn)服務(wù),通過(guò)消息Key值查詢消息真正的實(shí)體內(nèi)容。在實(shí)際的物理存儲(chǔ)上,文件名則是以創(chuàng)建時(shí)的時(shí)間戳命名的,固定的單個(gè)IndexFile文件大小約為400M,一個(gè)IndexFile可以保存 2000W個(gè)索引;
(4)MapedFileQueue:對(duì)連續(xù)物理存儲(chǔ)的抽象封裝類,源碼中可以通過(guò)消息存儲(chǔ)的物理偏移量位置快速定位該offset所在MappedFile(具體物理存儲(chǔ)位置的抽象)、創(chuàng)建、刪除MappedFile等操作;
(5)MappedFile:文件存儲(chǔ)的直接內(nèi)存映射業(yè)務(wù)抽象封裝類,源碼中通過(guò)操作該類,可以把消息字節(jié)寫入PageCache緩存區(qū)(commit),或者原子性地將消息持久化的刷盤(flush);
五、RocketMQ消息刷盤的主要過(guò)程
在RocketMQ中消息刷盤主要可以分為同步刷盤和異步刷盤兩種。

(1)同步刷盤:如上圖所示,只有在消息真正持久化至磁盤后,RocketMQ的Broker端才會(huì)真正地返回給Producer端一個(gè)成功的ACK響應(yīng)。同步刷盤對(duì)MQ消息可靠性來(lái)說(shuō)是一種不錯(cuò)的保障,但是性能上會(huì)有較大影響,一般適用于金融業(yè)務(wù)應(yīng)用領(lǐng)域。RocketMQ同步刷盤的大致做法是,基于生產(chǎn)者消費(fèi)者模型,主線程創(chuàng)建刷盤請(qǐng)求實(shí)例—GroupCommitRequest并在放入刷盤寫隊(duì)列后喚醒同步刷盤線程—GroupCommitService,來(lái)執(zhí)行刷盤動(dòng)作(其中用了CAS變量和CountDownLatch來(lái)保證線程間的同步)。這里,RocketMQ源碼中用讀寫雙緩存隊(duì)列(requestsWrite/requestsRead)來(lái)實(shí)現(xiàn)讀寫分離,其帶來(lái)的好處在于內(nèi)部消費(fèi)生成的同步刷盤請(qǐng)求可以不用加鎖,提高并發(fā)度。
(2)異步刷盤:能夠充分利用OS的PageCache的優(yōu)勢(shì),只要消息寫入PageCache即可將成功的ACK返回給Producer端。消息刷盤采用后臺(tái)異步線程提交的方式進(jìn)行,降低了讀寫延遲,提高了MQ的性能和吞吐量。異步和同步刷盤的區(qū)別在于,異步刷盤時(shí),主線程并不會(huì)阻塞,在將刷盤線程wakeup后,就會(huì)繼續(xù)執(zhí)行。
六、結(jié)語(yǔ)
在參考了@艾瑞克的那篇RocketMQ存儲(chǔ)相關(guān)技術(shù)博文后,讓我理解了公眾號(hào)的文章與其他技術(shù)細(xì)節(jié)文章應(yīng)該是有所區(qū)別的,公眾號(hào)文章還是力求精簡(jiǎn)(ps:貼大量代碼尤其需要慎重),篇幅太長(zhǎng)會(huì)影響閱讀體驗(yàn),更多的內(nèi)容應(yīng)該以各種設(shè)計(jì)圖和少量的文字為說(shuō)明。同時(shí),由于RocketMQ本身較為復(fù)雜,光看技術(shù)文章只能理解和領(lǐng)會(huì)一個(gè)大概,更多地還是需要自己多擼源碼、Debug以及多實(shí)踐才能對(duì)其有一個(gè)較為深入的理解。
由于目前微信對(duì)本公眾號(hào)依然沒(méi)有放開(kāi)評(píng)論功能,需要討論的同學(xué)可以直接在公號(hào)內(nèi)給我留言,我會(huì)依次回復(fù)內(nèi)容。如果喜歡本文,請(qǐng)收藏后點(diǎn)個(gè)贊并轉(zhuǎn)發(fā)朋友圈哦。