CommitLog篇 ——【RocketMQ源碼分析】深刻消息存儲(1)java
ConsumeQueue篇 ——【RocketMQ源碼分析】深刻消息存儲(2)緩存
前面兩篇已經說過了消息如何存儲到CommitLog,以及ConsumeQueue的構建流程,到了第三篇,咱們有一個不得不跨過的坎兒,MappedFile —— 內存文件映射。安全
MappedFile的存在是RocketMQ選擇將消息直接存儲到磁盤的關鍵因素,在第一篇CommitLog存儲流程開篇中,我就寫過一個思路。服務器
這裏出現的幾個關鍵句,都離不開本篇要說的MappedFile。app
RocketMQ既然要去與磁盤交互存儲文件,不一樣IO方法在性能差距上都是千差萬別的,怎麼高效的與磁盤/內存進行交互,是不少涉及存儲的中間件強大與否的重要標誌。dom
實現一個進程內基於隊列的消息持久化存儲引擎socket
這是幾年前天池中間件大賽的題目,目標就是設計一個利用有限內存、較多磁盤空間來實現一個消息隊列,這樣看其實思路在第一篇就已經說過了,重點是他要求這個隊列支持聚合操做。函數
這讓我想到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。
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操做有了必定的認識了。