【RocketMQ原始碼分析】深入訊息儲存(3)

AntzUhl發表於2021-04-08

前文回顧

CommitLog篇 ——【RocketMQ原始碼分析】深入訊息儲存(1)

ConsumeQueue篇 ——【RocketMQ原始碼分析】深入訊息儲存(2)

前面兩篇已經說過了訊息如何儲存到CommitLog,以及ConsumeQueue的構建流程,到了第三篇,我們有一個不得不跨過的坎兒,MappedFile —— 記憶體檔案對映。

MappedFile的存在是RocketMQ選擇將訊息直接儲存到磁碟的關鍵因素,在第一篇CommitLog儲存流程開篇中,我就寫過一個思路。

  1. 即用到記憶體又用到本地磁碟
  2. 填充和交換
  3. 檔案對映到記憶體
  4. 隨機讀介面去訪問

這裡出現的幾個關鍵句,都離不開本篇要說的MappedFile。

RocketMQ既然要去與磁碟互動儲存檔案,不同IO方法在效能差距上都是千差萬別的,怎麼高效的與磁碟/記憶體進行互動,是很多涉及儲存的中介軟體強大與否的重要標誌。

實現一個程式內基於佇列的訊息持久化儲存引擎

這是幾年前天池中介軟體大賽的題目,目標就是設計一個利用有限記憶體、較多磁碟空間來實現一個訊息佇列,這樣看其實思路在第一篇就已經說過了,重點是他要求這個佇列支援聚合操作。

【RocketMQ原始碼分析】深入訊息儲存(3)

這讓我想到ElasticSearch的聚合場景,如果要實現那麼複雜的聚合功能,也太南了吧。

不過好在題目只是要求做指定時間段的訊息加和,這無非就是維護一個訊息儲存的偏移量與時間的儲存就好了。

為了深入瞭解記憶體檔案對映,我們可以來讀讀它的原始碼,這裡相對於CommitLog、ConsumeQueue更加底層,更多涉及的是IO、Buffer、PageCache等知識。

從頁表談到零拷貝

在我過去學習組合語言的時候,有兩個定址相關的暫存器。

段暫存器、變址暫存器。

在8086的年代,地址匯流排是20位,但暫存器16位,定址能力有限,為了保證1M的定址能力,是將兩個16位暫存器一起使用,以段基址和偏移地址的形式,達到1M定址能力。

這個思想在作業系統保護模式下也是一樣的,假如我們有一臺32位作業系統,記憶體4GB。

我們來思考一下它的記憶體佈局,核心空間和使用者空間這是我們熟知的概念了,假如記憶體空間不做任何操作,按順序性讓我們去訪問,首先一個大問題就是記憶體隔離,兩個程式之間如何做到記憶體互不汙染,這也引出了Java虛擬機器記憶體分配的一個問題,分配之後的記憶體空間被垃圾回收器清理,剩下的空間大大小小可能不連續,後續一個需要佔據大記憶體的物件可能無法儲存,JVM可以選擇回收-清理的方式保證沒有碎片,這是因為有棧上的引用指向堆,一個大物件就算被移動也不用擔心,但作業系統不同,如果想用類似JVM回收-清理的方式減少碎片記憶體,首先一個要面對的問題就是地址變更,後續程式在定址時可能找不到目標。

此處需要注意地址變更,因為後面我們也會提到,作業系統的PageCache操作不當也會引起這個問題。

還有一個問題是,這種循序的空間並不安全,所有程式之間都可以互相訪問到對方的地址,這是一些修改器的常用手段。

基於以上問題,作業系統映入了保護模式,基於頁表將記憶體空間調整為虛擬記憶體,與實際的實體記憶體區分開。

現在的頁表通常是二級頁表,所謂兩級頁表就是對頁表再進行分頁,一個頁表內的所有頁表項是連續存放的,頁表本質上是一堆資料,也是以頁為單位存放在記憶體。

第一級稱為頁目錄表。每個頁表的實體地址在頁目錄表中都以頁目錄項(PDE)的形式來儲存,4MB的頁表再次分頁可以分為1K(4MB/4KB)個頁,對每個頁的描述需要4個位元組,所以頁目錄表佔用4K大小,正好是一個標準頁的大小,其指向第二級表。線性地址的高10位產生第一級的索引,由索引得到的表項中,指定並選擇了1K個二級表中的一個頁表。

第二級稱為頁表,存放在一個4K大小的頁面中,包含1K個表項,每個表項包含一個頁的物理基地址。線性地址的中間10位產生第二級索引,可以獲得包含頁的實體地址的頁表項。這個實體地址的高20位與線性地址的低12位形成了最終的實體地址。

有了頁表就能很好的劃分程式空間,以及減少碎片空間了,對於一個程式而言,理論上最大可使用空間為4GB。基於此,作業系統的記憶體操作大多都是基於頁(4KB).

虛擬記憶體的映入使得作業系統管理劃分記憶體更加方便,實際進行虛擬地址對映到實體地址的單元是MMU,mmap記憶體檔案對映也是一樣,通過MMU對映到檔案。

為了解決磁碟IO效率低下的問題,作業系統在程式空間內增加了一片空間,用於與磁碟檔案進行地址對映,這部分記憶體也是虛擬記憶體地址,通過指標操作這部分記憶體,系統會自動將處理過的頁寫回對應的磁碟檔案位置,就不需要去呼叫系統read、write等函式,核心空間對這段區域的修改也直接反映使用者空間,從而可以實現不同程式間的檔案共享。

這部分記憶體對映需要維護一份頁表,用於管理記憶體——檔案地址的對映關係,如果當前虛擬記憶體地址找不到對應的實體地址,就會發生所謂的缺頁,缺頁時系統會根據地址偏移量在PageCache中檢視目標地址是否已經快取過了,如果有就直接指向該PageCache地址,如果沒有就需要將目標檔案載入入PageCache中。

通過mmap的對映功能,就能避免IO操作,直接去操作記憶體,這就是所謂的零拷貝技術。

下面將要從幾幅圖說起IO到零拷貝。

這是最普通的檔案伺服器傳輸檔案過程,首先在核心態將檔案從物理裝置讀取到核心空間,這是一次直接直接記憶體拷貝,然後使用者程式需要從核心中將資料讀取到使用者程式空間,完成讀的流程,這是一次CPU拷貝,至此,讀的過程完成了,程式需要將資料傳送給客戶端,這時有需要將資料放到核心空間的socket處,之後通過協議層傳送出去。

這整個流程需要兩次CPU拷貝、兩次直接記憶體拷貝,還需要不斷在核心態使用者態切換。(第一種:四次)

第二種模型是引入了mmap,在核心空間與使用者空間建立對映關係,就可以讓socket空間直接操作核心空間就能完成拷貝功能,還不需要在核心態使用者態之間切換,write系統呼叫使核心將資料從原始核心緩衝區複製到與套接字關聯的核心緩衝區中。

這個方式使用mmap代替了read,雖然看上去減少了拷貝,但是缺存在風險。當對映一個檔案到記憶體,然後呼叫write,在另一個程式write同一個檔案時,就會發生系統錯誤。(第二種:三次)

第三種模型,基於Linux新增引入的sendfile系統呼叫,不僅能減少檔案拷貝,還能減少系統切換,sendfile可以直接完成核心空間的拷貝流程,從核心空間拷貝到套接字空間,由此跳過了使用者空間。(第三種:三次)

第四種模型,在核心版本2.4中,對sendfile進行了優化,可以直接從核心空間將資料傳送到協議器,還消除了到套接字區域的資料拷貝,對於使用者級應用程式沒有任何變化。(第四種:兩次)

綜上,資料傳送的流程中資料不會結果多餘的拷貝,核心與使用者態空間內都不會有多餘的備份,這就是所謂的零拷貝技術,基於sendfile與mmap。

說回RocketMQ

MQ是IO使用的大戶,MMap、FileChannel、RandomAccessFile是MQ檔案操作最常使用的方法。

RocketMQ支援MMap與FileChannel,預設使用MMap,在PageCache繁忙時,會使用FileChannel,同樣也可以避免PageCache競爭鎖。

在MappedFile類中,可以看到FileChannel與MappedByteBuffer兩個變數,在Java程式碼中可以通過FileChannel的map方法將檔案對映到虛擬記憶體。

在MappedFile的init方法中也可以看到mmap初始化的過程。

在實際的寫入流程中,操作的buffer可能是mmap也可能是TransientStorePool申請來的直接記憶體,避免頁面被換出到交換區。

TransientStorePool是否啟用根據TransientStorePoolEnable確定,當開啟時,表示優先使用堆外記憶體儲存資料,通過Commit執行緒刷到記憶體對映Buffer中。

TransientStorePool是一個簡易的池化類,其中包含了池的大小,每個單元儲存的大小,儲存單元的佇列以及儲存配置類。具體的初始化操作可以在init方法中看到有迴圈使用allocateDirect申請JVM外的記憶體空間,相比於allocate申請到的JVM內的記憶體,堆外記憶體操作更加迅速,免去了資料從堆外再次拷貝到堆內的流程。

申請到記憶體後,取到了申請的記憶體地址。

Pointer pointer = new Pointer(address);
LibC.INSTANCE.mlock(pointer, new NativeLong(fileSize));

拿到地址後,建立一個指向該處的指標,呼叫本地連結庫的方法,將該地址的記憶體鎖住,防止釋放。

綜上,相信你已經對頁表、檔案系統IO操作有了一定的認識了。

相關文章