講講 MQ 消息中間件與 MMAP、PageCache 的故事

作者:禪與計算機程序設計藝術
鏈接:https://www.jianshu.com/p/94e5009c2837

一般的 IO 調用

首先來看一下一般的 IO 調用。在傳統的文件 IO 操作中,我們都是調用操作系統提供的底層標準 IO 系統調用函數 read()、write() ,此時調用此函數的進程(在 JAVA 中即 java 進程)由當前的用戶態切換到內核態,然後 OS 的內核代碼負責將相應的文件數據讀取到內核的 IO 緩衝區,然後再把數據從內核 IO 緩衝區拷貝到進程的私有地址空間中去,這樣便完成了一次 IO 操作。如下圖所示。

注意兩點:

Java 的 IO 讀寫大致分爲三種:

1、普通 IO(java.io)

例如 FileWriter、FileReader 等,普通 IO 是傳統字節傳輸方式,讀寫慢阻塞,單向一個 Read 對應一個 Write 。

2、文件通道 FileChannel(java.nio)
FileChannel fileChannel = new RandomAccessFile(new File("data.txt")"rw").getChannel()

3、內存映射 MMAP(java.nio)

MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, position, fileSize)

mmap 把文件映射到用戶空間裏的虛擬內存,省去了從內核緩衝區複製到用戶空間的過程,文件中的位置在虛擬內存中有了對應的地址,可以像操作內存一樣操作這個文件,相當於已經把整個文件放入內存,但在真正使用到這些數據前卻不會消耗物理內存,也不會有讀寫磁盤的操作,只有真正使用這些數據時,也就是圖像準備渲染在屏幕上時,虛擬內存管理系統 VMS

MMAP 並非是文件 IO 的銀彈,它只有在一次寫入很小量數據的場景下才能表現出比 FileChannel 稍微優異的性能。緊接着我還要告訴你一些令你沮喪的事,至少在 JAVA 中使用 MappedByteBuffer 是一件非常麻煩並且痛苦的事,主要表現爲三點:

OS 的 PageCache 機制

PageCache 是 OS 對文件的緩存,用於加速對文件的讀寫。一般來說,程序對文件進行順序讀寫的速度幾乎接近於內存的讀寫訪問,這裏的主要原因就是在於 OS 使用 PageCache 機制對讀寫訪問操作進行了性能優化,將一部分的內存用作 PageCache
1、對於數據文件的讀取

如果一次讀取文件時出現未命中(cache miss)PageCache 的情況,OS 從物理磁盤上訪問讀取文件的同時,會順序對其他相鄰塊的數據文件進行預讀取(ps:順序讀入緊隨其後的少數幾個頁面)。這樣,只要下次訪問的文件已經被加載至 PageCache 時,讀取操作的速度基本等於訪問內存
1、對於數據文件的寫入

OS 會先寫入至 Cache 內,隨後通過異步的方式由 pdflush 內核線程將 Cache 內的數據刷盤至物理磁盤上
對於文件的順序讀寫操作來說,讀和寫的區域都在 OS 的 PageCache 內,此時讀寫性能接近於內存。RocketMQ 的大致做法是,將數據文件映射到 OS 的虛擬內存中(通過 JDK NIO 的 MappedByteBuffer),寫消息的時候首先寫入 PageCache,並通過異步刷盤的方式將消息批量的做持久化(同時也支持同步刷盤);訂閱消費消息時(對 CommitLog 操作是隨機讀取),由於 PageCache 的局部性熱點原理且整體情況下還是從舊到新的有序讀,因此大部分情況下消息還是可以直接從 Page Cache(cache hit)中讀取,不會產生太多的缺頁(Page Fault)中斷而從磁盤讀取:

PageCache 機制也不是完全無缺點的,當遇到 OS 進行髒頁回寫,內存回收,內存 swap 等情況時,就會引起較大的消息讀寫延遲。

對於這些情況,RocketMQ 採用了多種優化技術,比如內存預分配,文件預熱,mlock 系統調用等,來保證在最大可能地發揮 PageCache 機制優點的同時,儘可能地減少其缺點帶來的消息讀寫延遲

RocketMQ 存儲優化技術

對於 RocketMQ 來說,它是把內存映射文件串聯起來,組成了鏈表;因爲內存映射文件本身大小有限制,只能是 2G(默認 1G);所以需要把多個內存映射文件串聯成一個鏈表;這裏介紹 RocketMQ 存儲層採用的幾項優化技術方案在一定程度上可以減少 PageCache 的缺點帶來的影響,主要包括內存預分配,文件預熱和 mlock 系統調用

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 創建分配所產生的時間延遲

2 文件預熱 && mlock 系統調用(TransientStorePool)

mlock 系統調用

其可以將進程使用的部分或者全部的地址空間鎖定在物理內存中,防止其被交換到 swap 空間。對於 RocketMQ 這種的高吞吐量的分佈式消息隊列來說,追求的是消息讀寫低延遲,那麼肯定希望儘可能地多使用物理內存,提高數據讀寫訪問的操作效率。

文件預熱

預熱的目的主要有兩點:

第一點,由於僅分配內存並進行 mlock 系統調用後並不會爲程序完全鎖定這些內存,因爲其中的分頁可能是寫時複製的。因此,就有必要對每個內存頁面中寫入一個假的值。其中,RocketMQ 是在創建並分配 MappedFile 的過程中,預先寫入一些隨機值至 Mmap 映射出的內存空間裏。

第二,調用 Mmap 進行內存映射後,OS 只是建立虛擬內存地址至物理地址的映射表,而實際並沒有加載任何文件至內存中。程序要訪問數據時 OS 會檢查該部分的分頁是否已經在內存中,如果不在,則發出一次缺頁中斷。這裏,可以想象下 1G 的 CommitLog 需要發生多少次缺頁中斷,才能使得對應的數據才能完全加載至物理內存中(ps:X86 的 Linux 中一個標準頁面大小是 4KB)?

RocketMQ 的做法是

在做 Mmap 內存映射的同時進行 madvise 系統調用,目的是使 OS 做一次內存映射後對應的文件數據儘可能多的預加載至內存中,從而達到內存預熱的效果。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/0r4DH3O0OQ03IeLMCMUnSQ