RocketMQ架構原理解析(二):訊息儲存

昔久發表於2021-11-17

一、概述

由前文可知,RocketMQ有幾個非常重要的概念:

  • broker 服務端,負責儲存、收發訊息
  • producer 客戶端1,負責產生訊息
  • consumer 客服端2,負責消費訊息

既然是訊息佇列,那訊息的儲存的重要程度不言而喻,本節我們聚焦broker服務端,看下訊息在broker端是如何儲存的,它的落盤策略是怎樣的,又是如何保證高效

另:後文的RocketMQ都是基於版本4.9.3

二、寫入流程

RocketMQ的普通單訊息寫入流程如下
訊息寫入流程

簡單可以分為三大塊:

  • 寫入前準備
  • 加鎖後訊息寫入
  • 訊息落盤及叢集同步

2.1 準備

其實訊息的寫入準備工作也比較好理解,主要是訊息狀態的檢查以及各類儲存狀態的檢查,可以參看上圖中的流程

根據上圖,在準備階段前,RocketMQ會判斷作業系統的Page Cache是否繁忙,他是怎麼做到的呢?其實Java本身沒有提供介面或函式來檢視Page Cache的狀態,但如果磁碟頻寬已經打滿,在Page Cache要將資料刷disk時,很有可能便陷入了阻塞,導致Page Cache資源緊張。而當我們的程式又有新的訊息要寫入Page Cache時,反向阻塞寫入請求,我們說這時Page Cache就產生了回壓,也就是Page Cache相當繁忙,請求已經不能及時處理了。RocketMQ判斷Page Cache是否繁忙的條件也很簡單,就是監控某個請求加鎖後,寫入是否超過1秒,如果超時的話,新的請求會快速失敗

2.2 訊息協議

RocketMQ有一套相對複雜的訊息協議編碼,大部分協議中的內容都是在加鎖前拼接生成
rmq訊息儲存格式

大部分訊息協議項都是定長欄位,變長欄位如下:

  • 1、born inet 產生訊息的producer的IP資訊 ipv4佔用4byte,ipv6佔16byte
  • 2、broker inet 接收訊息的broker的IP資訊 ipv4佔用4byte,ipv6佔16byte
  • 3、msg content 訊息內容 變長欄位(1-21億)byte
  • 4、topic content 訊息內容 變長欄位(1-127)byte
  • 5、properties content 屬性內容 變長欄位(0-32767)byte

2.3 加鎖

此處rmq提供了2種加鎖方式

  • 1、基於AQS的ReentrantLock (預設方式)
  • 2、基於CAS的自旋鎖,加鎖不成功的話,會無限重試

無論採用哪種策略,都是獨佔鎖,即同一時刻只允許一個執行緒加鎖成功。具體採用哪種方式,可通過配置修改。

兩種加鎖適用不同的場景,方式1在高併發場景下,能保持平穩的系統效能,但在低併發下表現一般;而方式二正好相反,在高併發場景下,因為採用自旋,會浪費大量的cpu,但在低併發時,卻可以獲得很高的效能。

所以官方文件中,為了提高效能,建議使用者在同步刷盤的時候採用獨佔鎖,非同步刷盤的時候採用自旋鎖。這個是根據加鎖時間長短決定的

2.4 鎖內操作

上文提到,寫入訊息的鎖是獨佔鎖,也就意味著同一時刻,只能有一個執行緒進入,我們看一下鎖內都做了哪些操作

  • 1、拿到或建立檔案操作物件MappedFile
    • 此處涉及點較多,我們在檔案寫入大節詳細展開
  • 2、二次整理要落盤的訊息格式
    • 之前已經整理過訊息協議了,為什麼此處還要進行二次整理?因為之前一些訊息協議在沒有加鎖的時候,還無法確定。主要是以下三項內容:
      • a、queueOffset 佇列偏移量,此值需要最終返回,且需要保證嚴格遞增,所以需要在鎖內進行
      • b、physicalOffset 物理偏移量,也就是全域性檔案的位置,注:此位置是全域性檔案的偏移量,不是當前檔案的偏移量,所以其值可能會大於1G
      • c、storeTimestamp 儲存時間戳,此處在鎖內進行,主要是為了保證訊息投遞的時間嚴格保序
  • 3、記錄寫入資訊
    • 記錄當前檔案寫入情況:比如已寫入位元組數、儲存時間等

三、檔案開闢及寫入

3.1 檔案開闢

檔案的開闢是非同步進行,有獨立的執行緒專門負責開闢檔案。我們可以先看下檔案開闢的簡單模型
非同步建立MappFile

也就是putMsg的執行緒會將開闢檔案的請求委託給allocate file執行緒,然後進入阻塞,待allocate file執行緒將檔案開闢完畢後,再喚醒putMsg執行緒

那此處我們便產生了2點疑問:

  • 1、putMsg把開闢檔案的請求交給了allocate file執行緒,直到allocate file執行緒開闢完畢後才會喚醒putMsg執行緒,其實並沒有起到非同步開闢節省時間的目的,直接在putMsg執行緒中開闢檔案不好嗎?
  • 2、建立檔案本身感覺並不耗時,不管是拿到檔案的FileChannnel還是MappedByteBuffer,都是一件很快的操作,費盡周章的非同步開闢真的有必要嗎?

這兩個疑問將逐步說明

3.1.1 開啟堆外緩衝池

至此我們要引入一個非常重要的配置變數transientStorePoolEnable,該配置項只在非同步刷盤(FlushDiskType == AsyncFlush)的場景下,才會生效

如果配置項中,將transientStorePoolEnable置為false,便稱為“開啟堆外緩衝池”。那麼這個變數到底起到什麼作用呢?

transientStorePoolEnable型別建立MappFile

系統啟動時,會預設開闢5個(引數transientStorePoolSize控制)堆外記憶體DirectByteBuffer,迴圈利用。寫訊息時,訊息都暫存至此,通過執行緒CommitRealTimeService將資料定時刷到page cache,當資料flush到disk後,再將DirectByteBuffer歸還給緩衝池

而開闢過程是在broker啟動時進行的;如上圖所示,空間一旦開闢完畢後,檔案都是預先建立好的,使用時直接返回檔案引用即可,相當高效。但首次啟動需要大量開闢堆外記憶體空間,會拉長broker的啟動時長。我們看一下這塊開闢的原始碼

/**
 * It's a heavy init method.
 */
public void init() {
    for (int i = 0; i < poolSize; i++) {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(fileSize);
        ......
        availableBuffers.offer(byteBuffer);
    }
}

註釋中也標識了這是個重量級的方法,主要耗時點在ByteBuffer.allocateDirect(fileSize),其實開闢記憶體並不耗時,耗時集中在為記憶體區域賦0操作,以下是JDK中DirectByteBuffer原始碼:

DirectByteBuffer(int cap) {                   // package-private
    super(-1, 0, cap, cap);
    ......

    long base = 0;
    try {
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    unsafe.setMemory(base, size, (byte) 0);
    ......
}

我們發現在開闢完記憶體後,開始執行了賦0操作unsafe.setMemory(base, size, 0)。其實可以利用反射巧妙地繞過這個耗時點

private static Field addr;
private static Field capacity;

static {
    try {
        addr = Buffer.class.getDeclaredField("address");
        addr.setAccessible(true);
        capacity = Buffer.class.getDeclaredField("capacity");
        capacity.setAccessible(true);
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    }
}

public static ByteBuffer newFastByteBuffer(int cap) {
    long address = unsafe.allocateMemory(cap);
    ByteBuffer bb = ByteBuffer.allocateDirect(0).order(ByteOrder.nativeOrder());
    try {
        addr.setLong(bb, address);
        capacity.setInt(bb, cap);
    } catch (IllegalAccessException e) {
        return null;
    }
    bb.clear();
    return bb;
}

3.1.2 關閉堆外緩衝池

關閉堆外記憶體池的話,就會啟動MappedByteBuffer

常規型別建立MappFile

  • a、首次啟動
    • 第一次啟動的時候,allocate執行緒會先後建立2個檔案,第一個檔案建立完畢後,便會返回putMsg執行緒並喚醒它,然後allocate執行緒進而繼續非同步建立下一個檔案
  • b、後續啟動
    • 後續請求allocate執行緒都會將已經建立好的檔案直接返回給putMsg執行緒,然後繼續非同步建立下一個檔案,這樣便真正實現了非同步建立檔案的效果

3.1.3 檔案預熱

我們再回顧一下本章剛開始提出的2個疑問:

  • 1、putMsg把開闢檔案的請求交給了allocate file執行緒,直到allocate file執行緒開闢完畢後才會喚醒putMsg執行緒,其實並沒有起到非同步開闢節省時間的目的,直接在putMsg執行緒中開闢檔案不好嗎?
  • 2、建立檔案本身感覺並不耗時,不管是拿到檔案的FileChannnel還是MappedByteBuffer,都是一件很快的操作,費盡周章的非同步開闢真的有必要嗎?

第一個問題已經迎刃而解,即allocate執行緒通過非同步建立下一個檔案的方式,實現真正非同步

本節討論的便是第二個問題,其實如果只是單純建立檔案的話,的確是非常快的,不至於再使用非同步操作。但RocketMQ對於新建檔案有個檔案預熱(通過配置warmMapedFileEnable啟停)功能,當然目的是為了磁碟提速,我麼先看下原始碼

org.apache.rocketmq.store.MappedFile#warmMappedFile

for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
    byteBuffer.put(i, (byte) 0);
    // force flush when flush disk type is sync
    if (type == FlushDiskType.SYNC_FLUSH) {
        if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
            flush = i;
            mappedByteBuffer.force();
        }
    }
}

簡單來說,就是將MappedByteBuffer每隔4K就寫入一個0 byte,然後將整個檔案撐滿;如果刷盤策略是同步刷盤的話,還需要呼叫mappedByteBuffer.force(),當然這個操作是相當相當耗時的,所以也就需要我們進行非同步處理。這樣也就解釋了第二個問題

但檔案預熱真的有效嗎?我們不妨做個簡單的基準測試

public class FileWriteCompare {

    private static String filePath = "/Users/likangning/test/index3.data";

    private static int fileSize = 1024 * 1024 * 1024;

    private static boolean warmFile = true;

    private static int batchSize = 4096;

    @Test
    public void test() throws Exception {
        File file = new File(filePath);
        if (file.exists()) {
            file.delete();
        }
        file.createNewFile();
        FileChannel fileChannel = FileChannel.open(file.toPath(), StandardOpenOption.WRITE, StandardOpenOption.READ);
        MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileSize);

        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(batchSize);
        long beginTime = System.currentTimeMillis();
        mappedByteBuffer.position(0);
        while (mappedByteBuffer.remaining() >= batchSize) {
            byteBuffer.position(batchSize);
            byteBuffer.flip();
            mappedByteBuffer.put(byteBuffer);
        }
        System.out.println("time cost is : " + (System.currentTimeMillis() - beginTime));
    }
}

簡單來說就是通過MappedByteBuffer寫入1G檔案,在我本地電腦上,平均耗時在 550ms 左右

然後在MappedByteBuffer寫檔案前加入預熱操作

private void warmFile(MappedByteBuffer mappedByteBuffer) {
    if (!warmFile) {
        return;
    }
    int pageSize = 4096;
    long begin = System.currentTimeMillis();
    for (int i = 0, j = 0; i < fileSize; i += pageSize, j++) {
        mappedByteBuffer.put(i, (byte) 0);
    }
    System.out.println("warm file time cost " + (System.currentTimeMillis() - begin));
}

耗時情況如下:

warm file time cost 492
time cost is : 125

預熱後,寫檔案的耗時縮短了很多,但預熱本身的耗時也幾乎等同於檔案寫入的耗時了

以上是沒有強制刷盤的測試效果,如果強制刷盤(#force)的話,個人經驗是檔案預熱一定會帶來效能的提升。從前兩天結束的第二屆中介軟體效能挑戰賽來看,檔案預熱至少帶來10%以上的提升。但是同非強制刷盤一樣,檔案預熱操作實在是太重了

整體來看,檔案預熱後的寫入操作,確實能帶來效能上的提升,但是如果在系統壓力較大、磁碟吞吐緊張的場景下,勢必導致broker抖動,甚至請求超時,反而得不償失。明白了此層概念後,再通過大量benchmark來決定是否開啟此配置,做到有的放矢

3.2 檔案寫入

經過以上整理分析後,檔案寫入將變得非常輕;不論是DirectByteBuffer還是MappedByteBuffer都可以抽象為ByteBuffer,進而直接呼叫ByteBuffer.write()

四、刷盤策略

4.1 非同步刷盤

非同步刷盤策略

4.1.1 非同步+關閉寫緩衝

對應如下配置

FlushDiskType == AsyncFlush && transientStorePoolEnable == false

非同步刷盤,且關閉緩衝池,對應的非同步刷盤執行緒是FlushRealTimeService

上文可知,次策略是通過MappedByteBuffer寫入的資料,所以此時資料已經在 page cache 中了

我們總結一下刷盤的策略:

  • 1、固定頻率刷盤

不響應中斷,固定500ms(可配置)刷盤,但刷盤的時候,如果發現未落盤資料不足16K(可配置),那麼將進入下一個迴圈,如果滿16K的話,會將所有未落盤的資料落盤。此處補充說明下,不論是FileChannel還是MappedByteBuffer都不提供指定區間的刷盤策略,只提供一個force()方法,所以無法精確控制落盤資料的大小。

如果資料寫入量很少,一直沒有填充滿16K,就不會落盤了嗎?不是的,此處兜底的方案是,執行緒發現距離上次無條件全量刷盤已經超過10000ms(可配置),那麼此時就會無條件觸發全量刷盤

  • 2、非固定頻率刷盤

與「固定頻率刷盤」比較相似,唯一不同點是,當前刷盤策略是響應中斷的,即每次有新的訊息到來的時候,都會傳送喚醒訊號,如果刷盤執行緒正好處在500ms等待期間的話,將被喚醒。但此處的喚醒並非嚴謹的喚醒,有可能傳送了喚醒訊號,但刷盤執行緒並未成功響應,兜底方案便是500ms的重試。下面簡單黏貼一下等待、喚醒的程式碼,不再贅述

org.apache.rocketmq.common.ServiceThread

// 喚醒
public void wakeup() {
    if (hasNotified.compareAndSet(false, true)) {
        waitPoint.countDown(); // notify
    }
}

// 睡眠並響應喚醒
protected void waitForRunning(long interval) {
    if (hasNotified.compareAndSet(true, false)) {
        this.onWaitEnd();
        return;
    }

    //entry to wait
    waitPoint.reset();

    try {
        waitPoint.await(interval, TimeUnit.MILLISECONDS);
    } catch (InterruptedException e) {
        log.error("Interrupted", e);
    } finally {
        hasNotified.set(false);
        this.onWaitEnd();
    }
}

綜上,資料在page cache中最長的等待時間為(10000+500)ms

4.1.2 非同步+開啟寫緩衝

對應如下配置

FlushDiskType == AsyncFlush && transientStorePoolEnable == true

非同步刷盤,且開啟緩衝池,對應的非同步刷盤執行緒是CommitRealTimeService

首先需要明確一點的是,當前配置下,在寫入階段,資料是直接寫入DirectByteBuffer的,這樣做的好處及弊端也非常鮮明。

  • 好處:資料不用寫page cache,放入DirectByteBuffer後便很快返回,減少了使用者態與核心態的切換開銷,效能非常高
  • 弊端:資料可靠性降為最低階別,即程式掛掉的話,就會丟資料。因為資料即沒有寫入page cache,也沒有落盤至disk,僅僅是在程式內部維護了一塊臨時快取,程式重啟或crash掉的話,資料一定會丟失

值得一提的是,此種刷盤模式,寫入動作使用的是FileChannel,且僅僅呼叫FileChannel.write()方法將資料寫入page cache,並沒有直接強制刷盤,而是將強制落盤的任務轉交給FlushRealTimeService執行緒來操作,而FlushRealTimeService執行緒最終也會呼叫FileChannel進行強制刷盤

在RocketMQ內部,無論採用什麼刷盤策略,都是單一操作物件在寫入/讀取檔案;即如果使用MappedByteBuffer寫檔案,那一定會通過MappedByteBuffer刷盤,如果使用FileChannel寫檔案,那一定會通過FileChannel 刷盤,不存在混合操作的情況

疑問:為什麼RocketMQ不依賴作業系統的非同步刷盤,而費勁周章的設計如此刷盤策略呢?

個人理解,作為一個成熟開源的元件,資料的安全性至關重要,還是要儘可能保證資料穩步有序落盤;OS的非同步刷盤固然好使,但RocketMQ對其把控較弱,當作業系統crash或者斷電的時候,造成的資料丟失影響不可控

4.2 同步刷盤

需要說明的是,如果FlushDiskType配置的是同步刷盤的話,那麼此處資料一定已經被MappedByteBuffer寫入了pageCache,接下來要做的便是真正的落盤操作。與非同步落盤相似,同步落盤要根據配置項Message.isWaitStoreMsgOK()(等待訊息落盤)來分別說明

同步刷盤的落盤執行緒統一都是GroupCommitService

同步刷盤策略

4.2.1 不等待落盤ack

當前模式如圖所示,整體流程比較簡單,寫入執行緒僅僅負責喚醒落盤執行緒,然後便執行後續邏輯,執行緒不阻塞;落盤執行緒每次休息10ms(可被寫入執行緒喚醒)後,如果發現有資料未落盤,便將page cache中的資料強制force到磁碟

我們發現,其實相比較非同步刷盤來說,同步刷盤輪訓的時間只有10ms,遠小於非同步刷盤的500ms,也是比較好理解的。但當前模式寫入執行緒不會阻塞,也就是不會等待訊息真正儲存到disk後再返回,如果此時反生作業系統crash或者斷電,那未落盤的資料便會丟失

個人感覺,將FlushDiskType已經設定為Sync,表明資料會強制落盤,卻又引入Message.isWaitStoreMsgOK(),來左右落盤策略,多多少少會給使用者造成使用及理解上的困惑

4.2.2 等待落盤ack

相比較上文,本小節便是資料需要真正儲存到disk後才進行返回。寫入執行緒在喚醒落盤執行緒後便進入阻塞,直至落盤執行緒將資料刷到disk後再將其喚醒

不過這裡需要處理一個邊界問題,即舊CommitLog的tail,及新CommitLog的head。例如現在有2個寫入執行緒將資料寫入了page cache,而這2個請求一個落在前CommitLog的尾部,另外一個落在新CommitLog的頭部,這個時候,落盤執行緒需要檢測到這兩個訊息的分佈,然後依次將兩個CommitLog資料落盤

五、執行緒模型

2_執行緒模型

RocketMQ中所有的非同步處理執行緒都繼承自抽象類org.apache.rocketmq.common.ServiceThread,此類定義了簡單的喚醒、通知模型,但並不嚴格保證喚醒,而是通過輪訓作為兜底方案。實測發現喚醒動作在資料量較大時,存在效能損耗,改為簡單的輪詢落盤模式,效能提高明顯

六、結束語

本章我們聚焦分析了一條訊息在broker端落地的全過程,但整個流程還是比較複雜的,不過有些部分沒有提及(比如說訊息在master落地後是如何同步至salve端的),主要是考慮這些部分跟儲存關聯度不是很強,放在一起思路容易發散,這些部分會放在後文專門開標題闡述

相關文章