深入剖析 RocketMQ 原始碼 - 訊息儲存模組

vivo網際網路技術發表於2021-11-09

一、簡介

RocketMQ 是阿里巴巴開源的分散式訊息中介軟體,它借鑑了 Kafka 實現,支援訊息訂閱與釋出、順序訊息、事務訊息、定時訊息、訊息回溯、死信佇列等功能。RocketMQ 架構上主要分為四部分,如下圖所示:

  • Producer:訊息生產者,支援分散式叢集方式部署。

  • Consumer:訊息消費者,支援分散式叢集方式部署。

  • NameServer:名字服務,是一個非常簡單的 Topic 路由註冊中心,支援 Broker 的動態註冊與發現,Producer 和 Consumer 通過 NameServer 動態感知 Broker 的路由資訊。

  • Broker:Broker 主要負責訊息的儲存、轉發和查詢。

本文基於 Apache RocketMQ 4.9.1 版本剖析 Broker 中的訊息儲存模組是如何設計的。

二、儲存架構

RocketMQ 的訊息檔案路徑如圖所示。

CommitLog

訊息主體以及後設資料的儲存主體,儲存 Producer 端寫入的訊息主體內容,訊息內容不是定長的。單個檔案大小預設1G, 檔名長度為 20 位,左邊補零,剩餘為起始偏移量,比如 00000000000000000000 代表了第一個檔案,起始偏移量為 0,檔案大小為 1G=1073741824;當第一個檔案寫滿了,第二個檔案為 00000000001073741824,起始偏移量為 1073741824,以此類推。

ConsumeQueue

訊息消費佇列,Consumequeue 檔案可以看成是基於 CommitLog 的索引檔案。ConsumeQueue 檔案採取定長設計,每一個條目共 20 個位元組,分別為 8 位元組的 CommitLog 物理偏移量、4 位元組的訊息長度、8 位元組 tag hashcode,單個檔案由 30W 個條目組成,可以像陣列一樣隨機訪問每一個條目,每個 ConsumeQueue 檔案大小約 5.72M。

IndexFile

索引檔案,提供了一種可以通過 key 或時間區間來查詢訊息的方法。單個 IndexFile 檔案大小約為 400M,一個 IndexFile 可以儲存 2000W 個索引,IndexFile 的底層儲存設計類似 JDK 的 HashMap 資料結構。

其他檔案:包括 config 資料夾,存放執行時配置資訊;abort 檔案,說明 Broker 是否正常關閉;checkpoint 檔案,儲存 Commitlog、ConsumeQueue、Index 檔案最後一次刷盤時間戳。這些不在本文討論的範圍。

同 Kafka 相比,Kafka 每個 Topic 的每個 partition 對應一個檔案,順序寫入,定時刷盤。但一旦單個 Broker 的 Topic 過多,順序寫將退化為隨機寫。而 RocketMQ 單個 Broker 所有 Topic 在同一個 CommitLog 中順序寫,是能夠保證嚴格順序寫。RocketMQ 讀取訊息需要從 ConsumeQueue 中拿到訊息實際物理偏移再去 CommitLog 讀取訊息內容,會造成隨機讀取。

2.1 Page Cache 和 mmap

在正式介紹 Broker 訊息儲存模組實現前,先說明下 Page Cache 和 mmap 這兩個概念。

Page Cache 是 OS 對檔案的快取,用於加速對檔案的讀寫。一般來說,程式對檔案進行順序讀寫的速度幾乎接近於記憶體的讀寫速度,主要原因就是由於 OS 使用 Page Cache 機制對讀寫訪問操作進行了效能優化,將一部分的記憶體用作 Page Cache。對於資料的寫入,OS 會先寫入至 Cache 內,隨後通過非同步的方式由 pdflush 核心執行緒將 Cache 內的資料刷盤至物理磁碟上。對於資料的讀取,如果一次讀取檔案時出現未命中 Page Cache 的情況,OS 從物理磁碟上訪問讀取檔案的同時,會順序對其他相鄰塊的資料檔案進行預讀取。

mmap 是將磁碟上的物理檔案直接對映到使用者態的記憶體地址中,減少了傳統 IO 將磁碟檔案資料在作業系統核心地址空間的緩衝區和使用者應用程式地址空間的緩衝區之間來回進行拷貝的效能開銷。Java NIO 中的 FileChannel 提供了 map() 方法可以實現 mmap。FileChannel (檔案通道)和 mmap (記憶體對映) 讀寫效能比較可以參照這篇文章

2.2 Broker 模組

下圖是 Broker 儲存架構圖,展示了 Broker 模組從收到訊息到返回響應業務流轉過程。

業務接入層:RocketMQ 基於 Netty 的 Reactor 多執行緒模型實現了底層通訊。Reactor 主執行緒池 eventLoopGroupBoss 負責建立 TCP 連線,預設只有一個執行緒。連線建立後,再丟給 Reactor 子執行緒池 eventLoopGroupSelector 進行讀寫事件的處理。

defaultEventExecutorGroup 負責 SSL 驗證、編解碼、空閒檢查、網路連線管理。然後根據 RomotingCommand 的業務請求碼 code 去 processorTable 這個本地快取變數中找到對應的 processor,封裝成 task 任務後,提交給對應的業務 processor 處理執行緒池來執行。Broker 模組通過這四級執行緒池提升系統吞吐量。

業務處理層:處理各種通過 RPC 呼叫過來的業務請求,其中:

  • SendMessageProcessor 負責處理 Producer 傳送訊息的請求;

  • PullMessageProcessor 負責處理 Consumer 消費訊息的請求;

  • QueryMessageProcessor 負責處理按照訊息 Key 等查詢訊息的請求。

儲存邏輯層:DefaultMessageStore 是 RocketMQ 的儲存邏輯核心類,提供訊息儲存、讀取、刪除等能力。

檔案對映層:把 Commitlog、ConsumeQueue、IndexFile 檔案對映為儲存物件 MappedFile。

資料傳輸層:支援基於 mmap 記憶體對映進行讀寫訊息,同時也支援基於 mmap 進行讀取訊息、堆外記憶體寫入訊息的方式進行讀寫訊息。

下面章節將從原始碼角度來剖析 RocketMQ 是如何實現高效能儲存。

三、訊息寫入

以單個訊息生產為例,訊息寫入時序邏輯如下圖,業務邏輯如上文 Broker 儲存架構所示在各層之間進行流轉。

最底層訊息寫入核心程式碼在 CommitLog 的 asyncPutMessage 方法中,主要分為獲取 MappedFile、往緩衝區寫訊息、提交刷盤請求三步。需要注意的是在這三步前後有自旋鎖或 ReentrantLock 的加鎖、釋放鎖,保證單個 Broker 寫訊息是序列的。

//org.apache.rocketmq.store.CommitLog::asyncPutMessage
public CompletableFuture<PutMessageResult> asyncPutMessage(final MessageExtBrokerInner msg) {
        ...
        putMessageLock.lock(); //spin or ReentrantLock ,depending on store config
        try {
            //獲取最新的 MappedFile
            MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile();
            ...
            //向緩衝區寫訊息
            result = mappedFile.appendMessage(msg, this.appendMessageCallback, putMessageContext);
            ...
            //提交刷盤請求
            CompletableFuture<PutMessageStatus> flushResultFuture = submitFlushRequest(result, msg);
            ...
        } finally {
            putMessageLock.unlock();
        }
        ...
    }

下面介紹這三步具體做了什麼事情。

3.1 MappedFile 初始化

在 Broker 初始化時會啟動管理 MappedFile 建立的 AllocateMappedFileService 非同步執行緒。訊息處理執行緒 和 AllocateMappedFileService 執行緒通過佇列 requestQueue 關聯。

訊息寫入時呼叫 AllocateMappedFileService 的 putRequestAndReturnMappedFile 方法往 requestQueue 放入提交建立 MappedFile 請求,這邊會同時構建兩個 AllocateRequest 放入佇列。

AllocateMappedFileService 執行緒迴圈從 requestQueue 獲取 AllocateRequest 來建立 MappedFile。訊息處理執行緒通過 CountDownLatch 等待獲取第一個 MappedFile 建立成功就返回。

當訊息處理執行緒需要再次建立 MappedFile 時,此時可以直接獲取之前已預建立的 MappedFile。這樣通過預建立 MappedFile ,減少檔案建立等待時間。

//org.apache.rocketmq.store.AllocateMappedFileService::putRequestAndReturnMappedFile
public MappedFile putRequestAndReturnMappedFile(String nextFilePath, String nextNextFilePath, int fileSize) {
    //請求建立 MappedFile
    AllocateRequest nextReq = new AllocateRequest(nextFilePath, fileSize);
    boolean nextPutOK = this.requestTable.putIfAbsent(nextFilePath, nextReq) == null;
    ...
    //請求預先建立下一個 MappedFile
    AllocateRequest nextNextReq = new AllocateRequest(nextNextFilePath, fileSize);
    boolean nextNextPutOK = this.requestTable.putIfAbsent(nextNextFilePath, nextNextReq) == null;
    ...
    //獲取本次建立 MappedFile
    AllocateRequest result = this.requestTable.get(nextFilePath);
    ...
}
 
//org.apache.rocketmq.store.AllocateMappedFileService::run
public void run() {
    ..
    while (!this.isStopped() && this.mmapOperation()) {
    }
    ...
}
 
//org.apache.rocketmq.store.AllocateMappedFileService::mmapOperation
private boolean mmapOperation() {
    ...
    //從佇列獲取 AllocateRequest
    req = this.requestQueue.take();
    ...
    //判斷是否開啟堆外記憶體池
    if (messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
        //開啟堆外記憶體的 MappedFile
        mappedFile = ServiceLoader.load(MappedFile.class).iterator().next();
        mappedFile.init(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
    } else {
        //普通 MappedFile
        mappedFile = new MappedFile(req.getFilePath(), req.getFileSize());
    }
    ...
    //MappedFile 預熱
    if (mappedFile.getFileSize() >= this.messageStore.getMessageStoreConfig()
        .getMappedFileSizeCommitLog()
        &&
        this.messageStore.getMessageStoreConfig().isWarmMapedFileEnable()) {
        mappedFile.warmMappedFile(this.messageStore.getMessageStoreConfig().getFlushDiskType(),
            this.messageStore.getMessageStoreConfig().getFlushLeastPagesWhenWarmMapedFile());
    }
    req.setMappedFile(mappedFile);
    ...
}

每次新建普通 MappedFile 請求,都會建立 mappedByteBuffer,下面程式碼展示了 Java mmap 是如何實現的。

//org.apache.rocketmq.store.MappedFile::init
private void init(final String fileName, final int fileSize) throws IOException {
    ...
    this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
    this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
    ...
}

如果開啟堆外記憶體,即 transientStorePoolEnable = true 時,mappedByteBuffer 只是用來讀訊息,堆外記憶體用來寫訊息,從而實現對於訊息的讀寫分離。堆外記憶體物件不是每次新建 MappedFile 都需要建立,而是系統啟動時根據堆外記憶體池大小就初始化好了。每個堆外記憶體 DirectByteBuffer 都與 CommitLog 檔案大小相同,通過鎖定住該堆外記憶體,確保不會被置換到虛擬記憶體中去。

//org.apache.rocketmq.store.TransientStorePool
public void init() {
    for (int i = 0; i < poolSize; i++) {
        //分配與 CommitLog 檔案大小相同的堆外記憶體
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(fileSize);
        final long address = ((DirectBuffer) byteBuffer).address();
        Pointer pointer = new Pointer(address);
        //鎖定堆外記憶體,確保不會被置換到虛擬記憶體中去
        LibC.INSTANCE.mlock(pointer, new NativeLong(fileSize));
        availableBuffers.offer(byteBuffer);
    }
}

上面的 mmapOperation 方法中有段 MappedFile 預熱邏輯。為什麼需要檔案預熱呢?檔案預熱怎麼做的呢?

因為通過 mmap 對映,只是建立了程式虛擬記憶體地址與實體記憶體地址之間的對映關係,並沒有將 Page Cache 載入至記憶體。讀寫資料時如果沒有命中寫 Page Cache 則發生缺頁中斷,從磁碟重新載入資料至記憶體,這樣會影響讀寫效能。為了防止缺頁異常,阻止作業系統將相關的記憶體頁排程到交換空間(swap space),RocketMQ 通過對檔案預熱,檔案預熱實現如下。

//org.apache.rocketmq.store.MappedFile::warmMappedFile
public void warmMappedFile(FlushDiskType type, int pages) {
        ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
        int flush = 0;
        //通過寫入 1G 的位元組 0 來讓作業系統分配實體記憶體空間,如果沒有填充值,作業系統不會實際分配實體記憶體,防止在寫入訊息時發生缺頁異常
        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();
                }
            }
 
            //prevent gc
            if (j % 1000 == 0) {
                Thread.sleep(0);
            }
        }
 
        //force flush when prepare load finished
        if (type == FlushDiskType.SYNC_FLUSH) {
            mappedByteBuffer.force();
        }
        ...
        this.mlock();
}
 
//org.apache.rocketmq.store.MappedFile::mlock
public void mlock() {
    final long beginTime = System.currentTimeMillis();
    final long address = ((DirectBuffer) (this.mappedByteBuffer)).address();
    Pointer pointer = new Pointer(address);
 
    //通過系統呼叫 mlock 鎖定該檔案的 Page Cache,防止其被交換到 swap 空間
    int ret = LibC.INSTANCE.mlock(pointer, new NativeLong(this.fileSize));
 
    //通過系統呼叫 madvise 給作業系統建議,說明該檔案在不久的將來要被訪問
    int ret = LibC.INSTANCE.madvise(pointer, new NativeLong(this.fileSize), LibC.MADV_WILLNEED);
}

綜上所述,RocketMQ 每次都預建立一個檔案來減少檔案建立延遲,通過檔案預熱避免了讀寫時缺頁異常。

3.2 訊息寫入

3.2.1 寫入 CommitLog

CommitLog 中每條訊息儲存的邏輯檢視如下圖所示, TOTALSIZE 是整個訊息佔用儲存空間大小。

下面表格說明下每條訊息包含哪些欄位,以及這些欄位佔用空間大小和欄位簡介。

訊息的寫入是呼叫MappedFile 的 appendMessagesInner方法。

//org.apache.rocketmq.store.MappedFile::appendMessagesInner
public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb,
        PutMessageContext putMessageContext) {
    //判斷使用 DirectBuffer 還是 MappedByteBuffer 進行寫操作
    ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
    ..
    byteBuffer.position(currentPos);
    AppendMessageResult result  = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos,
                    (MessageExtBrokerInner) messageExt, putMessageContext);
    ..
    return result;
}
 
//org.apache.rocketmq.store.CommitLog::doAppend
public AppendMessageResult doAppend(final long fileFromOffset, final ByteBuffer byteBuffer, final int maxBlank,
    final MessageExtBrokerInner msgInner, PutMessageContext putMessageContext) {
    ...
    ByteBuffer preEncodeBuffer = msgInner.getEncodedBuff();
    ...
    //這邊只是將訊息寫入緩衝區,還未實際刷盤
    byteBuffer.put(preEncodeBuffer);
    msgInner.setEncodedBuff(null);
    ...
    return result;
}

至此,訊息最終寫入 ByteBuffer,還沒有持久到磁碟,具體何時持久化,下一小節會具體講刷盤機制。這邊有個疑問 ConsumeQueue 和 IndexFile 是怎麼寫入的?

答案是在儲存架構圖中儲存邏輯層的 ReputMessageService。MessageStore 在初始化的時候,會啟動一個 ReputMessageService 非同步執行緒,它啟動後便會在迴圈中不斷呼叫 doReput 方法,用來通知 ConsumeQueue 和 IndexFile 進行更新。ConsumeQueue 和 IndexFile 之所以可以非同步更新是因為 CommitLog 中儲存了恢復 ConsumeQueue 和 IndexFile 所需佇列和 Topic 等資訊,即使 Broker 服務異常當機,Broker 重啟後可以根據 CommitLog 恢復 ConsumeQueue 和IndexFile。

//org.apache.rocketmq.store.DefaultMessageStore.ReputMessageService::run
public void run() {
    ...
    while (!this.isStopped()) {
        Thread.sleep(1);
         this.doReput();
    }
    ...
}
 
//org.apache.rocketmq.store.DefaultMessageStore.ReputMessageService::doReput
private void doReput() {
    ...
    //獲取CommitLog中儲存的新訊息
    DispatchRequest dispatchRequest =
        DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false);
    int size = dispatchRequest.getBufferSize() == -1 ? dispatchRequest.getMsgSize() : dispatchRequest.getBufferSize();
 
    if (dispatchRequest.isSuccess()) {
        if (size > 0) {
            //如果有新訊息,則分別呼叫 CommitLogDispatcherBuildConsumeQueue、CommitLogDispatcherBuildIndex 進行構建 ConsumeQueue 和 IndexFile
            DefaultMessageStore.this.doDispatch(dispatchRequest);
    }
    ...
}

3.2.2 寫入 ConsumeQueue

如下圖所示,ConsumeQueue 每一條記錄共 20 個位元組,分別為 8 位元組的 CommitLog 物理偏移量、4 位元組的訊息長度、8位元組 tag hashcode。

ConsumeQueue 記錄持久化邏輯如下。

//org.apache.rocketmq.store.ConsumeQueue::putMessagePositionInfo
private boolean putMessagePositionInfo(final long offset, final int size, final long tagsCode,
    final long cqOffset) {
    ...
    this.byteBufferIndex.flip();
    this.byteBufferIndex.limit(CQ_STORE_UNIT_SIZE);
    this.byteBufferIndex.putLong(offset);
    this.byteBufferIndex.putInt(size);
    this.byteBufferIndex.putLong(tagsCode);
 
    final long expectLogicOffset = cqOffset * CQ_STORE_UNIT_SIZE;
 
    MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile(expectLogicOffset);
    if (mappedFile != null) {
        ...
        return mappedFile.appendMessage(this.byteBufferIndex.array());
    }
}

3.2.3 寫入 IndexFile

IndexFile 的檔案邏輯結構如下圖所示,類似於 JDK 的 HashMap 的陣列加連結串列結構。主要由 Header、Slot Table、Index Linked List 三部分組成。

Header:IndexFile 的頭部,佔 40 個位元組。主要包含以下欄位:

  • beginTimestamp:該 IndexFile 檔案中包含訊息的最小儲存時間。

  • endTimestamp:該 IndexFile 檔案中包含訊息的最大儲存時間。

  • beginPhyoffset:該 IndexFile 檔案中包含訊息的最小 CommitLog 檔案偏移量。

  • endPhyoffset:該 IndexFile 檔案中包含訊息的最大 CommitLog 檔案偏移量。

  • hashSlotcount:該 IndexFile 檔案中包含的 hashSlot 的總數。

  • indexCount:該 IndexFile 檔案中已使用的 Index 條目個數。

Slot Table:預設包含 500w 個 Hash 槽,每個 Hash 槽儲存的是相同 hash 值的第一個 IndexItem 儲存位置 。

Index Linked List:預設最多包含 2000w 個 IndexItem。其組成如下所示:

  • Key Hash:訊息 key 的 hash,當根據 key 搜尋時比較的是其 hash,在之後會比較 key 本身。

  • CommitLog Offset:訊息的物理位移。

  • Timestamp:該訊息儲存時間與第一條訊息的時間戳的差值。

  • Next Index Offset:發生 hash 衝突後儲存的下一個 IndexItem 的位置。

Slot Table 中每個 hash 槽存放的是 IndexItem 在 Index Linked List 的位置,如果 hash 衝突時,新的 IndexItem 插入連結串列頭, 它的 Next Index Offset 中存放之前連結串列頭 IndexItem 位置,同時覆蓋 Slot Table 中的 hash 槽為最新 IndexItem 位置。程式碼如下:

//org.apache.rocketmq.store.index.IndexFile::putKey
public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
    int keyHash = indexKeyHashMethod(key);
    int slotPos = keyHash % this.hashSlotNum;
    int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
    ...
    //從 Slot Table 獲取當前最新訊息位置
    int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
    ...
    int absIndexPos =
        IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
            + this.indexHeader.getIndexCount() * indexSize;
 
    this.mappedByteBuffer.putInt(absIndexPos, keyHash);
    this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
    this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
    //存放之前連結串列頭 IndexItem 位置
    this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);
    //更新 Slot Table 中 hash 槽的值為最新訊息位置
    this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());
 
    if (this.indexHeader.getIndexCount() <= 1) {
        this.indexHeader.setBeginPhyOffset(phyOffset);
        this.indexHeader.setBeginTimestamp(storeTimestamp);
    }
 
    if (invalidIndex == slotValue) {
        this.indexHeader.incHashSlotCount();
    }
    this.indexHeader.incIndexCount();
    this.indexHeader.setEndPhyOffset(phyOffset);
    this.indexHeader.setEndTimestamp(storeTimestamp);
 
    return true;
    ...
}

綜上所述一個完整的訊息寫入流程包括:同步寫入 Commitlog 檔案快取區,非同步構建 ConsumeQueue、IndexFile 檔案。

3.3 訊息刷盤

RocketMQ 訊息刷盤主要分為同步刷盤和非同步刷盤。

(1) 同步刷盤:只有在訊息真正持久化至磁碟後 RocketMQ 的 Broker 端才會真正返回給 Producer 端一個成功的 ACK 響應。同步刷盤對 MQ 訊息可靠性來說是一種不錯的保障,但是效能上會有較大影響,一般金融業務使用該模式較多。

(2) 非同步刷盤:能夠充分利用 OS 的 Page Cache 的優勢,只要訊息寫入 Page Cache 即可將成功的 ACK 返回給 Producer 端。訊息刷盤採用後臺非同步執行緒提交的方式進行,降低了讀寫延遲,提高了 MQ 的效能和吞吐量。非同步刷盤包含開啟堆外記憶體和未開啟堆外記憶體兩種方式。

在 CommitLog 中提交刷盤請求時,會根據當前 Broker 相關配置決定是同步刷盤還是非同步刷盤。

//org.apache.rocketmq.store.CommitLog::submitFlushRequest
public CompletableFuture<PutMessageStatus> submitFlushRequest(AppendMessageResult result, MessageExt messageExt) {
    //同步刷盤
    if (FlushDiskType.SYNC_FLUSH == this.defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
        final GroupCommitService service = (GroupCommitService) this.flushCommitLogService;
        if (messageExt.isWaitStoreMsgOK()) {
            GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes(),
                    this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout());
            service.putRequest(request);
            return request.future();
        } else {
            service.wakeup();
            return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
        }
    }
    //非同步刷盤
    else {
        if (!this.defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
            flushCommitLogService.wakeup();
        } else  {
            //開啟堆外記憶體的非同步刷盤
            commitLogService.wakeup();
        }
        return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
    }
}

GroupCommitService、FlushRealTimeService、CommitRealTimeService 三者繼承關係如圖;

GroupCommitService:同步刷盤執行緒。如下圖所示,訊息寫入到 Page Cache 後通過 GroupCommitService 同步刷盤,訊息處理執行緒阻塞等待刷盤結果。

//org.apache.rocketmq.store.CommitLog.GroupCommitService::run
public void run() {
    ...
    while (!this.isStopped()) {
        this.waitForRunning(10);
        this.doCommit();
    }
    ...
}
 
//org.apache.rocketmq.store.CommitLog.GroupCommitService::doCommit
private void doCommit() {
    ...
    for (GroupCommitRequest req : this.requestsRead) {
        boolean flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();
        for (int i = 0; i < 2 && !flushOK; i++) {
            CommitLog.this.mappedFileQueue.flush(0);
            flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();
        }
        //喚醒等待刷盤完成的訊息處理執行緒
        req.wakeupCustomer(flushOK ? PutMessageStatus.PUT_OK : PutMessageStatus.FLUSH_DISK_TIMEOUT);
    }
    ...
}
 
//org.apache.rocketmq.store.MappedFile::flush
public int flush(final int flushLeastPages) {
    if (this.isAbleToFlush(flushLeastPages)) {
        ...
        //使用到了 writeBuffer 或者 fileChannel 的 position 不為 0 時用 fileChannel 進行強制刷盤
        if (writeBuffer != null || this.fileChannel.position() != 0) {
            this.fileChannel.force(false);
        } else {
            //使用 MappedByteBuffer 進行強制刷盤
            this.mappedByteBuffer.force();
        }
        ...
    }
}

FlushRealTimeService:未開啟堆外記憶體的非同步刷盤執行緒。如下圖所示,訊息寫入到 Page Cache 後,訊息處理執行緒立即返回,通過 FlushRealTimeService 非同步刷盤。

//org.apache.rocketmq.store.CommitLog.FlushRealTimeService
public void run() {
    ...
    //判斷是否需要週期性進行刷盤
    if (flushCommitLogTimed) {
        //固定休眠 interval 時間間隔
        Thread.sleep(interval);
    } else {
        // 如果被喚醒就刷盤,非週期性刷盤
        this.waitForRunning(interval);
    }
    ...
    // 這邊和 GroupCommitService 用的是同一個強制刷盤方法
    CommitLog.this.mappedFileQueue.flush(flushPhysicQueueLeastPages);
    ...
}

CommitRealTimeService:開啟堆外記憶體的非同步刷盤執行緒。如下圖所示,訊息處理執行緒把訊息寫入到堆外記憶體後立即返回。後續先通過 CommitRealTimeService 把訊息由堆外記憶體非同步提交至 Page Cache,再由 FlushRealTimeService 執行緒非同步刷盤。

注意:在訊息非同步提交至 Page Cache 後,業務就可以從 MappedByteBuffer 讀取到該訊息。

訊息寫入到堆外記憶體 writeBuffer 後,會通過 isAbleToCommit 方法判斷是否積累到至少提交頁數(預設4頁)。如果頁數達到最小提交頁數,則批量提交;否則還是駐留在堆外記憶體,這邊有丟失訊息風險。通過這種批量操作,讀和寫的 Page Cahe 會間隔數頁,降低了 Page Cahe 讀寫衝突的概率,實現了讀寫分離。具體實現邏輯如下:

//org.apache.rocketmq.store.CommitLog.CommitRealTimeService
class CommitRealTimeService extends FlushCommitLogService {
    @Override
    public void run() {
        while (!this.isStopped()) {
            ...
            int commitDataLeastPages = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getCommitCommitLogLeastPages();
 
            ...
            //把訊息 commit 到記憶體緩衝區,最終呼叫的是 MappedFile::commit0 方法,只有達到最少提交頁數才能提交成功,否則還在堆外記憶體中
            boolean result = CommitLog.this.mappedFileQueue.commit(commitDataLeastPages);
            if (!result) {
                //喚醒 flushCommitLogService,進行強制刷盤
                flushCommitLogService.wakeup();
            }
            ...
            this.waitForRunning(interval);
        }
    }
}
 
//org.apache.rocketmq.store.MappedFile::commit0
protected void commit0() {
    int writePos = this.wrotePosition.get();
    int lastCommittedPosition = this.committedPosition.get();
     
    //訊息提交至 Page Cache,並未實際刷盤
    if (writePos - lastCommittedPosition > 0) {
        ByteBuffer byteBuffer = writeBuffer.slice();
        byteBuffer.position(lastCommittedPosition);
        byteBuffer.limit(writePos);
        this.fileChannel.position(lastCommittedPosition);
        this.fileChannel.write(byteBuffer);
        this.committedPosition.set(writePos);
    }
}

下面總結一下三種刷盤機制的使用場景及優缺點。

四、訊息讀取

訊息讀取邏輯相比寫入邏輯簡單很多,下面著重分析下根據 offset 查詢訊息和根據 key 查詢訊息是如何實現的。

4.1 根據 offset 查詢

讀取訊息的過程就是先從 ConsumeQueue 中找到訊息在 CommitLog 的物理偏移地址,然後再從 CommitLog 檔案中讀取訊息的實體內容。

//org.apache.rocketmq.store.DefaultMessageStore::getMessage
public GetMessageResult getMessage(final String group, final String topic, final int queueId, final long offset,
    final int maxMsgNums,
    final MessageFilter messageFilter) {
    long nextBeginOffset = offset;
 
    GetMessageResult getResult = new GetMessageResult();
 
    final long maxOffsetPy = this.commitLog.getMaxOffset();
    //找到對應的 ConsumeQueue
    ConsumeQueue consumeQueue = findConsumeQueue(topic, queueId);
    ...
    //根據 offset 找到對應的 ConsumeQueue 的 MappedFile
    SelectMappedBufferResult bufferConsumeQueue = consumeQueue.getIndexBuffer(offset);
    status = GetMessageStatus.NO_MATCHED_MESSAGE;
    long maxPhyOffsetPulling = 0;
 
    int i = 0;
    //能返回的最大資訊大小,不能大於 16M
    final int maxFilterMessageCount = Math.max(16000, maxMsgNums * ConsumeQueue.CQ_STORE_UNIT_SIZE);
    for (; i < bufferConsumeQueue.getSize() && i < maxFilterMessageCount; i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {
        //CommitLog 實體地址
        long offsetPy = bufferConsumeQueue.getByteBuffer().getLong();
        int sizePy = bufferConsumeQueue.getByteBuffer().getInt();
        maxPhyOffsetPulling = offsetPy;
        ...
        //根據 offset 和 size 從 CommitLog 拿到具體的 Message
        SelectMappedBufferResult selectResult = this.commitLog.getMessage(offsetPy, sizePy);
        ...
        //將 Message 放入結果集
        getResult.addMessage(selectResult);
        status = GetMessageStatus.FOUND;
    }
 
    //更新 offset
    nextBeginOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);
 
    long diff = maxOffsetPy - maxPhyOffsetPulling;
    long memory = (long) (StoreUtil.TOTAL_PHYSICAL_MEMORY_SIZE
        * (this.messageStoreConfig.getAccessMessageInMemoryMaxRatio() / 100.0));
    getResult.setSuggestPullingFromSlave(diff > memory);
    ...
    getResult.setStatus(status);
    getResult.setNextBeginOffset(nextBeginOffset);
    return getResult;
}

4.2 根據 key 查詢

讀取訊息的過程就是用 topic 和 key 找到 IndexFile 索引檔案中的一條記錄,根據記錄中的 CommitLog 的 offset 從 CommitLog 檔案中讀取訊息的實體內容。

//org.apache.rocketmq.store.DefaultMessageStore::queryMessage
public QueryMessageResult queryMessage(String topic, String key, int maxNum, long begin, long end) {
    QueryMessageResult queryMessageResult = new QueryMessageResult();
    long lastQueryMsgTime = end;
     
    for (int i = 0; i < 3; i++) {
        //獲取 IndexFile 索引檔案中記錄的訊息在 CommitLog 檔案物理偏移地址
        QueryOffsetResult queryOffsetResult = this.indexService.queryOffset(topic, key, maxNum, begin, lastQueryMsgTime);
        ...
        for (int m = 0; m < queryOffsetResult.getPhyOffsets().size(); m++) {
            long offset = queryOffsetResult.getPhyOffsets().get(m);
            ...
            MessageExt msg = this.lookMessageByOffset(offset);
            if (0 == m) {
                lastQueryMsgTime = msg.getStoreTimestamp();
            }
            ...
            //在 CommitLog 檔案獲取訊息內容
            SelectMappedBufferResult result = this.commitLog.getData(offset, false);
            ...
            queryMessageResult.addMessage(result);
            ...
        }
    }
 
    return queryMessageResult;
}

在 IndexFile 索引檔案,查詢 CommitLog 檔案物理偏移地址實現如下:

//org.apache.rocketmq.store.index.IndexFile::selectPhyOffset
public void selectPhyOffset(final List<Long> phyOffsets, final String key, final int maxNum,
final long begin, final long end, boolean lock) {
    int keyHash = indexKeyHashMethod(key);
    int slotPos = keyHash % this.hashSlotNum;
    int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
    //獲取相同 hash 值 key 的第一個 IndexItme 儲存位置,即連結串列的首節點
    int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
     
    //遍歷連結串列節點
    for (int nextIndexToRead = slotValue; ; ) {
        if (phyOffsets.size() >= maxNum) {
            break;
        }
 
        int absIndexPos =
            IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
                + nextIndexToRead * indexSize;
 
        int keyHashRead = this.mappedByteBuffer.getInt(absIndexPos);
        long phyOffsetRead = this.mappedByteBuffer.getLong(absIndexPos + 4);
 
        long timeDiff = (long) this.mappedByteBuffer.getInt(absIndexPos + 4 + 8);
        int prevIndexRead = this.mappedByteBuffer.getInt(absIndexPos + 4 + 8 + 4);
 
        if (timeDiff < 0) {
            break;
        }
 
        timeDiff *= 1000L;
 
        long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff;
        boolean timeMatched = (timeRead >= begin) && (timeRead <= end);
 
        //符合條件的結果加入 phyOffsets
        if (keyHash == keyHashRead && timeMatched) {
            phyOffsets.add(phyOffsetRead);
        }
 
        if (prevIndexRead <= invalidIndex
            || prevIndexRead > this.indexHeader.getIndexCount()
            || prevIndexRead == nextIndexToRead || timeRead < begin) {
            break;
        }
         
        //繼續遍歷連結串列
        nextIndexToRead = prevIndexRead;
    }
    ...
}

五、總結

本文從原始碼的角度介紹了 RocketMQ 儲存系統的核心模組實現,包括儲存架構、訊息寫入和訊息讀取。

RocketMQ 把所有 Topic 下的訊息都寫入到 CommitLog 裡面,實現了嚴格的順序寫。通過檔案預熱防止 Page Cache 被交換到 swap 空間,減少讀寫檔案時缺頁中斷。使用 mmap 對 CommitLog 檔案進行讀寫,將對檔案的操作轉化為直接對記憶體地址進行操作,從而極大地提高了檔案的讀寫效率。

對於效能要求高、資料一致性要求不高的場景下,可以通過開啟堆外記憶體,實現讀寫分離,提升磁碟的吞吐量。總之,儲存模組的學習需要對作業系統原理有一定了解。作者採用的效能極致優化方案值得我們好好學習。

六、參考文獻

1.RocketMQ 官方文件

作者:vivo網際網路伺服器團隊-Zhang Zhenglin

相關文章