【RocketMQ源碼分析】深入消息存儲(3)


前文回顧

CommitLog篇 ——【RocketMQ源碼分析】深入消息存儲(1)

ConsumeQueue篇 ——【RocketMQ源碼分析】深入消息存儲(2)

前面兩篇已經說過了消息如何存儲到CommitLog,以及ConsumeQueue的構建流程,到了第三篇,我們有一個不得不跨過的坎兒,MappedFile —— 內存文件映射。

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

  1. 即用到內存又用到本地磁盤
  2. 填充和交換
  3. 文件映射到內存
  4. 隨機讀接口去訪問

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

RocketMQ既然要去與磁盤交互存儲文件,不同IO方法在性能差距上都是千差萬別的,怎么高效的與磁盤/內存進行交互,是很多涉及存儲的中間件強大與否的重要標志。

實現一個進程內基於隊列的消息持久化存儲引擎

這是幾年前天池中間件大賽的題目,目標就是設計一個利用有限內存、較多磁盤空間來實現一個消息隊列,這樣看其實思路在第一篇就已經說過了,重點是他要求這個隊列支持聚合操作。

這讓我想到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操作有了一定的認識了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM