再談MappedByteBuffer與DirectBuffer

去年年底的時候,爲我們的分佈式消息隊列中間件寫源碼分析系列文章的時候,看到我們的存儲部分是參考了RocketMQ的實現。這部分有一個很有意思的內容

public AppendMessageResult appendMessage(final Object msg, final AppendMessageCallback cb) throws IOException {
          
        int currentPos = this.wrotePostion.get();     
        if (currentPos < this.fileSize) {
        	ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
            byteBuffer.position(currentPos);
            AppendMessageResult result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, msg);
            this.wrotePostion.addAndGet(result.getWroteBytes());
            this.storeTimestamp = result.getStoreTimestamp();
            return result;
        }
        
        log.error("MapedFile.appendMessage return null, wrotePostion: " + currentPos + " fileSize: " + this.fileSize);
        return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
}

可以看到,在寫消息的時候,並沒有直接寫入到mmap出來的MappedByteBuffer中,而是寫到了另外一個單獨的DirectBuffer中,消息刷盤也要先把DirectBuffer的數據commit到FileChannel中,再由FileChannel刷盤。讀消息依舊從MappedByteBuffer中讀取,但是隻能讀取已經commit的數據。

但是在我們的分佈式消息隊列中,卻直接讀取DirectBuffer,那麼也就不用必須讀commit位置前的數據,只要寫到DirectBuffer的數據都可以讀取消費。

看似帶來的好處是消息消費沒有了延遲,不需要等待定時任務commit數據後,才能被消費。但是,這違背了RocketMQ設計的初衷,這裏不談對與錯、不談好與壞,我們只談設計初衷,RocketMQ之所以這麼設計,基本初衷是爲了從設計層面實現讀寫分離,而我們的改動恰好完全違背了該設計的初衷。

我們一點一點的看RocketMQ是如何設計的。

首先我們要知道FileChannel與MappedByteBuffer之間的關係

* A mapping, once established, is not dependent upon the file channel
* that was used to create it.  Closing the channel, in particular, has no
* effect upon the validity of the mapping.

MappedByteBuffer自從FileChannel映射mmap出來之後,就基本沒有任何關係了。並且我們看FileChannel的force方法有這麼一個解釋

* <p> This method is only guaranteed to force changes that were made to
* this channel's file via the methods defined in this class.  It may or
* may not force changes that were made by modifying the content of a
* {@link MappedByteBuffer <i>mapped byte buffer</i>} obtained by
* invoking the {@link #map map} method.  Invoking the {@link
* MappedByteBuffer#force force} method of the mapped byte buffer will
* force changes made to the buffer's content to be written.  </p>

FileChannel的force只能保證把通過FileChannel寫的數據刷盤,而通過映射出來的MappedByteBuffer寫的數據,是不保證刷盤的。所以,要保證寫數據與刷盤是通過同一途徑。即如果使用DirectBuffer寫數據,那麼commit與刷盤是都要使用FileChannel相關方法。如果直接寫MappedByteBuffer,那麼刷盤時要使用MappedByteBuffer相關方法。

這個比較好理解,接下來我們要理解,既然FileChannel與MappedByteBuffer毫無關係,爲什麼commit之後的數據,就可以在MappedByteBuffer中能讀取的到?

我們先來看Page cache的概念。

頁緩存是Linux系統內核實現磁盤緩存。它主要用來減少對磁盤的I/O操作。具體的講,是通過把磁盤中的數據緩存到物理內存中,把對磁盤的訪問變爲對物理內存的訪問。

我們的MappedByteBuffer就是通過mmap出來的一部分page cache。

而linux的寫數據又採用了write-back的策略,即寫操作直接寫到緩存中,後端存儲不會立即直接更新,而是將page cache中被寫入的頁標記爲“髒頁”,並且被加入到髒頁列表中。等待write-back進程或者用戶手動刷盤,將髒頁列表中的頁數據寫回到磁盤,再清理“髒頁”標識。

所以FileChannel寫數據(commit)後,實際也是寫到了page cache中,而FileChannel與MappedByteBuffer映射的是同一份物理文件,所以都映射的相同的page cache。通過FileChannel commit後的數據,在MappedByteBuffer中即爲可見。

既然利用了linux的page cache,減少了內核空間與用戶空間的數據拷貝,那麼也要承受其帶來的問題,缺頁異常與緩存回收。

先看缺頁異常,分爲兩種Minor page faults和Major page faults。

Minor page faults即實際的指令(數據)已經在物理內存page cache中,只是沒有被分配給當前進程,只需要通過MMU把當前page cache分配給當前進程即可,不需要磁盤I/O。

Major page faults就是我們常說的page faults了,數據不在物理內存page cache中,就需要訪問磁盤讀取數據,再放到物理內存中。

而實際上linux採用的都是“copy on write”(注意這裏與我們Java中的CopyOnWrite系列集合不是一個概念),即mmap的時候,不會實際分配物理內存,而只是提前分配了虛擬內存,只有當真正去使用這些數據的時候,纔會通過page faults實際映射文件。

在RocketMQ中爲了避免這種“copy on write”的策略,採用了預熱的方式,創建並mmap文件後,提前把每頁都寫一個字節的數據。

再看第二個問題,緩存回收。物理內存畢竟有限,爲了給更重要的數據騰出空間或者收縮緩存空間,需要把一部分page清除。linux採用雙鏈策略,即維護兩個LRU鏈表:活躍鏈表與非活躍鏈表。只會替換非活躍鏈表緩存,活躍鏈表的緩存都從非活躍鏈表晉升而來。這就帶來了一個問題,我們辛辛苦苦映射到物理內存的page cache,如果被換出,再次訪問時又需要page faults。所以在RocketMQ中採用了內存鎖定,通過mlock使得我們創建的DirectBuffer和MappedByteBuffer不會被換出。

在接下來我們需要理解爲什麼寫DirectBuffer而不是HeapBuffer。我們看FileChannel的write方法,最終調用了IOUtil的write方法,在寫數據時會判斷是否爲DirectBuffer,如果不是的話,會臨時創建DirectBuffer並拷貝數據,再通過DirectBuffer寫數據。這是爲了避免寫數據時,發生GC導致數據發生變化。所以採用DirectBuffer省去了拷貝數據的過程,進一步提高了性能。

既然無論是寫MappedByteBuffer還是寫DirectBuffer,最終都是先寫到了page cache中,又是如何做到讀寫分離的?在RocketMQ中,數據寫入到DirectBuffer中不會立即commit,而是需要積攢若干頁(默認4頁)後,批量commit。一方面是爲了提高性能,減少了操作系統髒頁刷新的頻率。另一方面也正是做到了讀寫分離。因爲commit以整頁爲單位,沒有被commit的數據是不會被讀取到,所以不會出現同時讀寫同一page的情況。並且commit是批量定時進行,又進一步減少了page cache讀寫衝突的概率。

我們再看下linux髒頁刷盤的三種場景:

  1. 空閒內存低於一定閾值時,linux會寫回髒頁以便釋放內存。
  2. 髒頁駐留時間超過一定閾值,linux會寫回髒頁。
  3. 用戶進行手動觸發。

我們批量寫數據,再配合定時手動觸發回寫,也就避免了linux內核進行髒頁回寫。同樣提高了性能。

綜上,RocketMQ在設計層面實現了讀寫分離的效果,並且在性能方面做到了很極致。同樣事情都有利弊,這也導致了數據消費的及時性,即數據必須要等到commit後纔可以被消費到。

再返回來看,我們的分佈式消息隊列參考了RocketMQ的存儲實現,但是卻修改了讀取消息的部分,採用了直接讀取DirectBuffer的方式,這種做法是否妥當,還值得商榷。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章