別造輪子了,先來“抄”一下 RocketMQ 的文件編程藝術(shù)
最近有朋友問我,如何從零開始設(shè)計一套高性能、高可靠、且支持快速查詢的文件存儲系統(tǒng)?
這是一個典型的后端面試高頻題,也是一個極具挑戰(zhàn)的真實工程難題。我的回答是:與其閉門造車,不如站在巨人的肩膀上——去“抄”一個頂級的開源實現(xiàn)。
他追問:“抄誰?”
我說:“當然是抄 RocketMQ!它在文件處理上的設(shè)計,堪稱 Java 領(lǐng)域的教科書,充滿了工程的智慧與藝術(shù)。無論是海量消息的順序?qū)懭耄€是高效的索引查詢,亦或是對內(nèi)存和磁盤的極致運用,RocketMQ 的每一處細節(jié)都值得我們細細品味。下面,就讓我們一同深入這座寶庫,探索其背后的文件編程藝術(shù)。”
消息存儲格式看文件編程
commitlog 文件設(shè)計:學習文件編程的起點
我們知道,RocketMQ 的全量消息均存儲在 commitlog 文件中。由于每條消息的大小不一,我們面臨第一個挑戰(zhàn):如何高效地組織這些消息,以便在讀取時能夠準確地區(qū)分每條消息的邊界?
在基于文件的編程模型中,首要任務(wù)是定義一套清晰的消息存儲格式。一個通用的實踐是將數(shù)據(jù)結(jié)構(gòu)設(shè)計為 Header + Body 的形式。其中,Header 部分采用定長設(shè)計,存放元數(shù)據(jù)信息;Body 部分則存放實際的數(shù)據(jù)。在 RocketMQ 的存儲協(xié)議中,我們可以將記錄消息總長度的 4 字節(jié)視為 Header,其后的所有字段則構(gòu)成 Body,包含了與消息相關(guān)的業(yè)務(wù)屬性,它們共同按照預(yù)定格式組裝。
圖片
對于 Header + Body 這種協(xié)議,提取一條完整消息通常分為兩步:首先,讀取定長的 Header,從中解析出 Body 的長度(在 RocketMQ 中即為消息的總長度)。然后,根據(jù)這個長度,從消息的起始位置 讀取完整的消息數(shù)據(jù),并按照預(yù)定義的格式解析出各個業(yè)務(wù)字段。
那問題又來了,如果確定一條消息的開頭呢?難不成從文件的開始處開始遍歷?
正如關(guān)系型數(shù)據(jù)庫會為每條記錄分配一個唯一的 ID,在文件編程模型中,我們也為每條消息引入了一個關(guān)鍵的身份標識:消息物理偏移量(Physical Offset),它精確地指明了消息在文件中的起始存儲位置。
圖片
因此,通過 物理偏移量 + 消息大小(SIZE) 這一組合,我們便能輕而易舉地從海量數(shù)據(jù)中精確定位并提取出任何一條完整的消息。
此外,commitlog 的文件組織還揭示了另一個通用實踐:文件通常以一個“魔數(shù)”(Magic Number)開頭,用于快速校驗文件類型和完整性。同時,在文件末尾,可能會使用特殊填充(PAD)來處理空間。例如,當文件剩余空間不足以容納一條完整的消息時,系統(tǒng)不會將消息拆分存儲,而是用 PAD 填充剩余部分,以保證下一條消息能從新文件的起始位置寫入,維持了消息的原子性。
consumequeue 文件設(shè)計:索引的藝術(shù)
commitlog 文件基于物理偏移量查詢消息效率極高,但若要按 Topic 進行檢索,則顯得力不從心。為了解決這一痛點,RocketMQ 精心設(shè)計了 consumequeue文件作為消費隊列索引。
圖片
consumequeue 的設(shè)計極具巧思。其核心在于,每個索引條目都采用固定長度設(shè)計:8 字節(jié)的 commitlog 物理偏移量、4 字節(jié)的消息長度和 8 字節(jié)的 Tag 哈希值。這里存儲的是 Tag 的哈希值而非原始字符串,正是為了確保每個條目定長。這種設(shè)計使得consumequeue 文件可以像數(shù)組一樣,通過下標(queueOffset)進行快速隨機訪問,極大地提升了索引查詢性能。
由此可見,在高性能文件存儲設(shè)計中,為特定查詢場景構(gòu)建高效的索引至關(guān)重要。而索引設(shè)計的關(guān)鍵原則之一,就是采用定長條目,從而實現(xiàn)類似數(shù)組的 O(1) 復(fù)雜度快速定位。
性能飛躍:內(nèi)存映射與頁緩存
解決了數(shù)據(jù)存儲格式與唯一標識的問題后,下一個核心挑戰(zhàn)是如何提升I/O性能。在文件編程實踐中,為了便于管理和數(shù)據(jù)刪除(例如過期的消息),通常會采用固定大小的日志文件策略,將數(shù)據(jù)切分成多個大小相等的文件段。RocketMQ 的 commitlog文件夾中那些 1G 大小的文件正是這一思想的體現(xiàn)。
圖片
采用定長文件的一個核心優(yōu)勢在于,它極大地簡化了內(nèi)存映射(Memory Mapping)的實現(xiàn)。通過內(nèi)存映射技術(shù),我們可以將磁盤文件直接映射到進程的虛擬地址空間,之后就可以像訪問內(nèi)存一樣讀寫磁盤上的數(shù)據(jù),從而繞過傳統(tǒng) read/write 系統(tǒng)調(diào)用帶來的多次數(shù)據(jù)拷貝,顯著提升文件操作性能。
在 Java 中使用內(nèi)存映射的示例代碼如下:
FileChannel fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
MappedByteBuffer mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
實現(xiàn)要點如下:
- 首先需要通過 RandomAccessFile 構(gòu)建一個文件讀寫通道 FileChannel。
- 再通過 FileChannel 的 map 方法創(chuàng)建內(nèi)存映射區(qū)域 MappedByteBuffer。
在 Linux 系統(tǒng)中,MappedByteBuffer 的背后正是強大的頁緩存(Page Cache)。Linux 會盡可能地利用空閑物理內(nèi)存作為頁緩存,以緩存磁盤數(shù)據(jù)。當應(yīng)用程序讀寫文件時,實際上是在與頁緩存交互。如果數(shù)據(jù)在緩存中(緩存命中),則無需磁盤 I/O;如果不在(緩存未命中),系統(tǒng)會觸發(fā)一個缺頁中斷,透明地將數(shù)據(jù)從磁盤加載到頁緩存中。整個過程由操作系統(tǒng)自動管理,對應(yīng)用層開發(fā)者完全透明,極大地簡化了高性能應(yīng)用的開發(fā)。
通過內(nèi)存映射寫入的數(shù)據(jù),首先會進入頁緩存,并不會立即刷寫到磁盤。持久化操作由操作系統(tǒng)根據(jù)其策略在后臺異步完成。這意味著,即使 Broker 進程異常崩潰,只要操作系統(tǒng)仍然運行,頁緩存中的數(shù)據(jù)就不會丟失,最終會被寫入磁盤,保證了一定程度的可靠性。然而,如果發(fā)生機器斷電或操作系統(tǒng)宕機等災(zāi)難性故障,尚未從頁緩存刷入磁盤的數(shù)據(jù)將會永久丟失,這是內(nèi)存映射技術(shù)需要權(quán)衡的風險。
磁盤性能提升:順序?qū)?/h3>
要進一步壓榨磁盤性能,另一個關(guān)鍵的設(shè)計原則是使用順序?qū)懱娲S機寫。與隨機寫相比,磁盤順序?qū)懙男阅芤叱鰩讉€數(shù)量級。這一原則在所有高性能存儲系統(tǒng)中都得到了廣泛應(yīng)用。以大家熟悉的 MySQL InnoDB 存儲引擎為例:當執(zhí)行一條更新語句時,數(shù)據(jù)首先在內(nèi)存(Buffer Pool)中被修改。為了保證事務(wù)的持久性,InnoDB 并不會立即將修改后的數(shù)據(jù)頁(這會導致隨機 I/O)刷回磁盤,而是先將變更以 Redo Log 的形式順序追加到一個專用的日志文件中,并確保 Redo Log 被同步刷盤。數(shù)據(jù)文件本身的更新則可以在后臺異步、批量地進行。
圖片
試想,如果沒有 Redo Log,每次更新都直接修改磁盤上的數(shù)據(jù)文件,那么更新不同表的數(shù)據(jù)就會導致磁頭在磁盤上大幅度來回尋道,產(chǎn)生大量的隨機 I/O,性能將慘不忍睹。通過引入 Redo Log,InnoDB 將多次離散的隨機寫操作,巧妙地轉(zhuǎn)換成了一次集中的順序?qū)懖僮鳎M管多了一步寫日志的操作,但整體性能卻得到了質(zhì)的飛躍。這個例子雄辯地證明了順序?qū)懴啾入S機寫的巨大優(yōu)勢。
因此,在設(shè)計文件存儲模型時,一個黃金法則是:盡可能地將操作設(shè)計為順序追加(Append-Only),避免原地更新(In-Place Update)。
資源管理的智慧:引用計數(shù)器
在基于 NIO 的文件編程中,我們頻繁地與 ByteBuffer 打交道。為了在不進行數(shù)據(jù)拷貝的前提下共享緩沖區(qū)內(nèi)容,slice() 方法被廣泛使用。它能創(chuàng)建一個與原 ByteBuffer 共享同一塊內(nèi)存區(qū)域,但擁有獨立 position、limit 和 mark 指針的新 ByteBuffer。
圖片
上圖 selectMappedBuffer 方法的作用,正是從一個內(nèi)存映射文件(如 commitlog)的指定位置“切割”出一段數(shù)據(jù)。這種零拷貝操作雖然高效,但也引入了復(fù)雜的資源管理問題:被主 MappedByteBuffer 切割(slice)出的多個子 ByteBuffer,它們的生命周期各不相同。我們必須確保在所有子 ByteBuffer 都使用完畢之前,主 MappedByteBuffer 不能被釋放,否則將導致懸空指針和程序崩潰。
RocketMQ 如何優(yōu)雅地解決這個問題呢?答案是引入引用計數(shù)器(Reference Counting)。
其核心思想是:每次調(diào)用 slice() 派生出一個新的 ByteBuffer 時,就將主對象的引用計數(shù)加一;當任何一個派生對象使用完畢被釋放時,就將引用計數(shù)減一。只有當引用計數(shù)歸零時,才真正執(zhí)行底層的資源釋放操作。
圖片
結(jié)合 selectMappedBuffer 方法的實現(xiàn),我們可以看到:
- 調(diào)用 hold() 方法來增加引用計數(shù),這標志著 MappedByteBuffer 又被“借用”了一次。
- 返回的 SelectMappedBufferResult 對象中封裝了派生出的 ByteBuffer。當使用者調(diào)用其 release() 方法時,內(nèi)部會調(diào)用 ReferenceResource 的 release(),使引用計數(shù)減一。
可靠與性能的權(quán)衡:同步與異步刷盤
內(nèi)存映射機制極大地提升了寫入性能,但其“數(shù)據(jù)先到內(nèi)存,后到磁盤”的特性也帶來了新的抉擇:當 Broker 接收到消息后,是應(yīng)該在數(shù)據(jù)寫入頁緩存后就向客戶端返回成功,還是必須等到數(shù)據(jù)被持久化到磁盤后才返回?
這本質(zhì)上是系統(tǒng)性能與數(shù)據(jù)可靠性之間的權(quán)衡。為此,RocketMQ 提供了兩種持久化策略:同步刷盤和異步刷盤。
所謂的 “刷盤”,在代碼層面其實就是調(diào)用 FileChannel 或 MappedByteBuffer 的 force() 方法,強制操作系統(tǒng)將頁緩存中對應(yīng)的數(shù)據(jù)寫入物理磁盤。
圖片
同步刷盤
同步刷盤策略要求 Broker 將消息寫入內(nèi)存后,必須立即將其持久化到磁盤,然后才能向客戶端返回成功響應(yīng)。
但這里有一個關(guān)鍵問題:同步刷盤是每條消息都單獨刷一次盤嗎?答案是否定的。
RocketMQ 的同步刷盤實現(xiàn)隱藏著一個重要的優(yōu)化:組提交(Group Commit)。其入口位于 GroupCommitService 類中,從類名就能看出其設(shè)計思想。
圖片
實現(xiàn)中有兩個關(guān)鍵點:
- 組提交:一次刷盤操作會將當前所有待刷盤的消息(一個消息組)一次性寫入磁盤,而不是一條一條地刷。這極大地減少了磁盤 I/O 次數(shù)。
- 同步轉(zhuǎn)異步:實現(xiàn)上采用 CountDownLatch 將同步調(diào)用轉(zhuǎn)換為異步處理模式。主線程提交一個刷盤請求后,會限時等待 (await) 后臺刷盤線程的結(jié)果,從而實現(xiàn)業(yè)務(wù)邏輯的解耦。
接下來繼續(xù)探討組提交的設(shè)計理念。
圖片
判斷一條刷盤請求成功的條件:當前已刷盤指針大于該條消息對應(yīng)的物理偏移量,這里使用了刷盤重試機制。然后喚醒主線程并返回刷盤結(jié)果。
所謂的組提交,其核心理念理念是調(diào)用刷盤時使用的是 MappedFileQueue.flush 方法,該方法并不是只將一條消息寫入磁盤,而是會將當期未刷盤的數(shù)據(jù)一次性刷寫到磁盤,既組提交,故即使在同步刷盤情況下,也并不是每一條消息都會被執(zhí)行 flush 方法,為了更直觀的展現(xiàn)組提交的設(shè)計理念,給出如下流程圖:
圖片
異步刷盤
同步刷盤提供了最高級別的數(shù)據(jù)可靠性,但犧牲了寫入性能。考慮到 RocketMQ 的消息首先寫入 PageCache,在非極端掉電情況下數(shù)據(jù)丟失的概率很小,因此,如果業(yè)務(wù)能容忍極低概率的數(shù)據(jù)丟失以換取更高的吞吐量,可以選擇異步刷盤。
異步刷盤模式下,Broker 將消息寫入 PageCache 后會立即向客戶端返回成功,然后由一個后臺線程(FlushRealTimeService)定時將臟頁數(shù)據(jù)刷入磁盤,默認間隔為 500ms。
你可能會猜測這是用 ScheduledExecutorService 之類的定時任務(wù)實現(xiàn)的,但 RocketMQ 的實現(xiàn)更為精妙。它同樣利用了帶超時時間的 CountDownLatch.await()。這種方式的好處在于:
- 在沒有新消息寫入時,線程會安靜地等待 500ms,避免了空輪詢帶來的 CPU 消耗。
- 一旦有新消息寫入(wakeup() 被調(diào)用),線程會立即被喚醒執(zhí)行刷盤,而無需等待 500ms 周期結(jié)束,保證了刷盤的及時性。
保障數(shù)據(jù)一致性:文件恢復(fù)機制
圖片
在 RocketMQ 的架構(gòu)中,commitlog 是主數(shù)據(jù)文件,而 consumequeue 和 indexFile 等索引文件是根據(jù) commitlog 的內(nèi)容異步構(gòu)建的。既然是異步構(gòu)建,就必然存在數(shù)據(jù)不一致的風險窗口。例如,Broker 在將數(shù)據(jù)轉(zhuǎn)發(fā)到 consumequeue 之前異常關(guān)閉,重啟后如何保證數(shù)據(jù)的一致性?
在探討恢復(fù)機制前,我們先設(shè)想幾個典型的異常場景:
- 消息已同步刷盤到 commitlog,但在轉(zhuǎn)發(fā)到 consumequeue 之前,機器斷電。
- 一次批量刷盤操作(例如 100MB 數(shù)據(jù))在執(zhí)行到一半(例如 50MB)時,機器斷電,導致 commitlog 文件末尾存在一條不完整的消息。
- commitlog 刷盤成功,但在更新 checkpoint 文件(記錄各文件刷盤點)之前,進程退出。
在 RocketMQ 中,文件恢復(fù)分為正常停止和異常停止兩種場景。
兩種場景定位恢復(fù)起點的邏輯略有不同,但一旦定位到起始恢復(fù)文件,后續(xù)的文件校正思路是統(tǒng)一的:
- 首先嘗試恢復(fù) consumequeue:遍歷 consumequeue 文件,根據(jù)其定長格式(8字節(jié)偏移量 + 4字節(jié)長度 + 8字節(jié)Tag哈希碼),找到最后一條完整條目所指向的 commitlog 物理偏移量,記為 maxPhysicalOfConsumequeue。
- 然后嘗試恢復(fù) commitlog:遍歷 commitlog 文件,校驗?zāi)?shù),并根據(jù)消息存儲格式找到最后一條完整的、校驗合格的消息,記錄其物理偏移量 physicalOffset。
- 對比與校正:
如果 commitlog 的有效偏移量小于 consumequeue 中記錄的最大偏移量,說明 consumequeue 中存在無效的“超前”索引,需要被截斷。
如果 commitlog 的有效偏移量大于 consumequeue 中記錄的最大偏移量,說明有部分消息還未建立索引,需要從 commitlog 中重新讀取這部分消息,并重建 consumequeue 和其他索引文件。
那么,如何高效地定位到可能需要恢復(fù)的文件呢?
正常退出定位文件
在 RocketMQ 啟動時候會創(chuàng)建一個名為 abort 的文件,并在正常關(guān)閉時將其刪除。因此,通過檢查 abort 文件是否存在,即可判斷上次是否為異常退出。
圖片
對于正常退出場景,恢復(fù)策略相對樂觀:
- ConsumeQueue 的恢復(fù)從每個主題的第一個文件開始。
- commitlog 的恢復(fù)從倒數(shù)第三個文件開始向后檢查。因為正常退出時,大部分文件都已完整寫入并刷盤。
異常退出定位文件
異常退出時,不確定性大大增加,恢復(fù)策略必須更加嚴謹。此時,checkpoint 文件就派上了用場。該文件記錄了 commitlog、consumequeue 等文件的最后一次刷盤時間戳。
圖片
- physicMsgTimestamp:commitlog 文件最后的刷盤的時間點
- logicsMsgTimestamp: consumequeue 文件最后的刷盤時間點
- indexMsgTimestamp: indexfile 文件最后的刷盤時間點
checkpoint 文件的更新總是在 commitlog 刷盤成功之后進行。
圖片
這意味著 checkpoint 中記錄的刷盤點是“絕對可靠”的,早于該時間點的數(shù)據(jù)一定已經(jīng)落盤。基于此,異常退出時的恢復(fù)策略是:
- ConsumeQueue 是按照 topic 進行恢復(fù)的,從第一文件開始恢復(fù)。
- commitlog 的恢復(fù)從最后一個文件開始,逐個向前掃描。讀取每個文件的第一條消息的存儲時間,與 checkpoint 中記錄的 physicMsgTimestamp 進行比較。一旦找到一個文件的起始時間小于等于 checkpoint 的時間戳,那么就從這個文件開始執(zhí)行恢復(fù)流程。
文件恢復(fù)的入口位于 DefaultMessageStore#recover 方法,讀者可根據(jù)上述理念,自行探索源碼,定會事半功倍。
終極性能優(yōu)化:Java 零拷貝
在高性能網(wǎng)絡(luò)文件服務(wù)中,“零拷貝”(Zero-Copy)是一個高頻詞匯。這里我們不贅述其底層原理,而是直接看 RocketMQ 在消息消費場景下,如何結(jié)合 Netty 實現(xiàn)零拷貝,將磁盤文件高效地發(fā)送到網(wǎng)絡(luò)。
圖片
零拷貝的關(guān)鍵實現(xiàn)要點:
- 當需要發(fā)送消息時,RocketMQ 首先通過內(nèi)存映射從 commitlog 文件中獲取一個代表消息數(shù)據(jù)的 ByteBuf。重要的是,這個 ByteBuf 僅僅是一個引用,數(shù)據(jù)本身仍在頁緩存中,并未被加載到 Java 堆內(nèi)存。
- 然后,將這個 ByteBuf 包裝成一個 Netty 的 FileRegion 對象,并最終調(diào)用其 transferTo 方法。該方法的底層實現(xiàn)委托給了 FileChannel.transferTo()。
在 ManyMessageTransfer 類中可以看到具體的 transferTo 實現(xiàn):
圖片
FileChannel.transferTo() 在 Linux 系統(tǒng)上會觸發(fā) sendfile() 系統(tǒng)調(diào)用。這個系統(tǒng)調(diào)用可以直接在內(nèi)核空間中,將數(shù)據(jù)從文件句柄(頁緩存)拷貝到套接字緩沖區(qū),避免了數(shù)據(jù)在內(nèi)核態(tài)和用戶態(tài)之間的來回拷貝,實現(xiàn)了真正的零拷貝,極大地提升了數(shù)據(jù)傳輸效率。
本文從文件編程的視角,跟隨 RocketMQ 的設(shè)計學習了諸多優(yōu)秀的工程技巧。希望這些內(nèi)容能對你有所啟發(fā)
結(jié)語
學習 RocketMQ,我們學的不僅是 “術(shù)”(具體的實現(xiàn)技巧),更是 “道”(解決問題的思想和方法論)。希望通過本文的剖析,能為你打開一扇通往高性能存儲世界的大門,并在未來的系統(tǒng)設(shè)計中,能夠更加游刃有余地運用這些閃耀著智慧光芒的編程藝術(shù)。