RocketMQ如何構建ComsumerQueue的?

前言

RocketMQ的消息都是按照先來後到,順序的存儲在CommitLog中的,而消費者通常只關心某個Topic下的消息。順序的查找CommitLog肯定是不現實的,我們可以構建一個索引文件,裏面存放着某個Topic下面所有消息在CommitLog中的位置,這樣消費者獲取消息的時候,只需要先查找這個索引文件,然後再去CommitLog中獲取消息就 OK了。這個索引文件,就是我們的ComsumerQueue。

如何構建

在Broker中,構建ComsummerQueue不是存儲完CommitLog就馬上同步構建的,而是通過一個線程任務異步的去做這個事情。在DefaultMessageStore中有一個ReputMessageService成員,它就是負責構建ComsumerQueue的任務線程。

public class DefaultMessageStore implements MessageStore {
    // 。。。。省略無關代碼
    private final ReputMessageService reputMessageService;
 }   

ReputMessageService繼承自ServiceThread,表明其是一個服務線程,它的run方法很簡單,如下所示:

 public void run() {
            while (!this.isStopped()) {
                try {
                    Thread.sleep(1);
                    this.doReput(); // 構建ComsumerQueue
                } catch (Exception e) {
                    DefaultMessageStore.log.warn(this.getServiceName() + " service has exception. ", e);
                }
            }
        }

在run方法裏,每休息1毫秒就進行一次構建ComsumerQueue的動作。因爲必須先寫入CommitLog,然後才能進行ComsumerQueue的構建。那麼不排除構建ComsumerQueue的速度太快了,而CommitLog還沒寫入新的消息。這時就需要sleep下,讓出cpu時間片,避免浪費CPU資源。

doReput

doReput的代碼如下所示:

private void doReput() {
            for (boolean doNext = true; this.isCommitLogAvailable() && doNext; ) {
                SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);// 拿到所有的最新寫入CommitLog的數據
                if (result != null) {
                    try {
                        this.reputFromOffset = result.getStartOffset();

                        for (int readSize = 0; readSize < result.getSize() && doNext; ) {
                            DispatchRequest dispatchRequest =
                            DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false); // 一條一條的讀消息
                            int size = dispatchRequest.getMsgSize();

                            if (dispatchRequest.isSuccess()) {
                                if (size > 0) {
                                    DefaultMessageStore.this.doDispatch(dispatchRequest); // 派發消息,進行處理,其中就包括構建ComsumerQueue
                                    this.reputFromOffset += size;
                                    readSize += size;
                                } else if (size == 0) { // 
                                    this.reputFromOffset = DefaultMessageStore.this.commitLog.rollNextFile(this.reputFromOffset);
                                    readSize = result.getSize();
                                }
                            } else if (!dispatchRequest.isSuccess()) { // 獲取消息異常

                                if (size > 0) {
                                    log.error("[BUG]read total count not equals msg total size. reputFromOffset={}", reputFromOffset);
                                    this.reputFromOffset += size;
                                } else {
                                    doNext = false;
                                    if (DefaultMessageStore.this.brokerConfig.getBrokerId() == MixAll.MASTER_ID) {
                                        this.reputFromOffset += result.getSize() - readSize;
                                    }
                                }
                            }
                        }
                    } finally {
                        result.release();
                    }
                } else {
                    doNext = false;
                }
            }
        }

爲了突出重點,省略了一些和構建ComsumerQueue不相干的代碼。在doReput裏面,其實做了3件事情:
1-獲取最新寫入到CommitLog中的數據byteBuffer。
2-從byteBuffer中一條條的讀取消息,並派發出去處理。
3-更新reputFromOffset位移。

從何處構建

reputFromOffset是一個非常重要參數,它指出了我們應該從哪裏開始構建ComsumerQueue。在DefaultMessageStore的start()方法中,對reputFromOffset進行了初始化:

public void start() throws Exception 
        if (this.getMessageStoreConfig().isDuplicationEnable()) {
            this.reputMessageService.setReputFromOffset(this.commitLog.getConfirmOffset());
        } else {
            this.reputMessageService.setReputFromOffset(this.commitLog.getMaxOffset());
        }
        
        this.reputMessageService.start();
    }

如果允許消息重複,那麼reputFromOffset會從CommitLog的ConfirmOffset中獲取,否則獲取CommitLog的最大偏移量。duplicationEnable默認是關閉的,也就是默認是獲取CommitLog的最大寫入的偏移量。
我對這個confirmOffset其實並不理解,在代碼裏也沒有搜到設置該值的源頭,應該是需要自己實現MessageStore類的時候才用得到。既然默認是不允許重複的,那麼這個就不再去深究了,也不影響我們對ComsumerQueue的理解。

看到這裏,我其實是有一個疑問的:爲什麼會從CommitLog的最大偏移量開始構建呢?。按照正常的思路,應該最開始從零開始構建,然後構建一個,reputFromOffset就累加一個。構建完一批後,如果又可以構建了,則接着從上次結束的地方開始構建。我們來一探究竟。

我們先看看getMaxOffset方法:

public long getMaxOffset() {
        MappedFile mappedFile = getLastMappedFile();
        if (mappedFile != null) {
            return mappedFile.getFileFromOffset() + mappedFile.getReadPosition();
        }
        return 0;
    }

在RocketMQ第一次啓動時,沒有發送消息,Commit文件還沒有創建。此時getLastMappedFile()返回的是null,因此getMaxOffset拿到的就是0。也就是說,當RocketMQ第一次啓動,構建ConsumerQueue是從頭開始的,這個符合我們的期望。通過斷點查看reputFromOffset,確實如此,如下所示:
在這裏插入圖片描述
那麼如果CommitLog還有數據沒有處理完,Broker通過shutdown正常停止了呢?那下次重新啓動,reputFromOffset設置成了getMaxOffset,那不是丟了一部分數據了嘛?

不必擔心,在DefaultMessageStore關閉時,會盡力等待數據追上。具體來說就是通過50次sleep,每次100毫秒,如果在這時間內ConsumerQueue可以把數據追平(reputFromOffset == maxOffset),那麼就沒有問題。如果仍然不能追平,那沒辦法了,打個警告日誌吧。後續可以通過手工重發消息處理。

public void shutdown() {
            for (int i = 0; i < 50 && this.isCommitLogAvailable(); i++) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignored) {
                }
            }

            if (this.isCommitLogAvailable()) {
                log.warn("shutdown ReputMessageService, but commitlog have not finish to be dispatched, CL: {} reputFromOffset: {}",
                    DefaultMessageStore.this.commitLog.getMaxOffset(), this.reputFromOffset);
            }

            super.shutdown();
        }

好,繼續回到我們doReput所做的3件事情。

獲取數據

當設置好reputFromOffset之後,就可以從CommitLog中獲取從reputFromOffset到目前已經寫入的所有消息內容。

public SelectMappedBufferResult getData(final long offset, final boolean returnFirstOnNotFound) {
        int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMapedFileSizeCommitLog();
        MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, returnFirstOnNotFound);
        if (mappedFile != null) {
            int pos = (int) (offset % mappedFileSize);
            SelectMappedBufferResult result = mappedFile.selectMappedBuffer(pos);
            return result;
        }

        return null;
    }

首先從mappedFileQueue中,根據偏移量找到消息的mappedFile文件,具體算法是根據reputFromOffset-第一個文件的offset,然後除以單個文件大小,得到該文件的索引值。如果該文件存在,則返回,否則循環查找一遍。
找到文件後,取出對應的byteBuffer內容,封裝後返回。

派發處理

拿到所有消息的byteBuffer後,循環讀取消息,封裝成DispatchRequest進行派發處理。

DefaultMessageStore.this.doDispatch(dispatchRequest);

DispatchRequest類如下所示:

public class DispatchRequest {
    private final String topic;
    private final int queueId;
    private final long commitLogOffset;
    private final int msgSize;
    private final long tagsCode;
    private final long storeTimestamp;
    private final long consumeQueueOffset; // 邏輯偏移量,非物理偏移量
    private final String keys;
    private final boolean success;
    private final String uniqKey;

    // .....省略非重點
  }  

構建ComsumerQueue的派發處理在CommitLogDispatcherBuildConsumeQueue類中進行處理,如下所示:

 public void dispatch(DispatchRequest request) {
            final int tranType = MessageSysFlag.getTransactionValue(request.getSysFlag());
            switch (tranType) {
                case MessageSysFlag.TRANSACTION_NOT_TYPE:
                case MessageSysFlag.TRANSACTION_COMMIT_TYPE:
                    DefaultMessageStore.this.putMessagePositionInfo(request);
                    break;
                case MessageSysFlag.TRANSACTION_PREPARED_TYPE:
                case MessageSysFlag.TRANSACTION_ROLLBACK_TYPE:
                    break;
            }
        }

由於正常情況下是非事務消息,因此走到的是MessageSysFlag.TRANSACTION_NOT_TYPE。

public void putMessagePositionInfo(DispatchRequest dispatchRequest) {
        ConsumeQueue cq = this.findConsumeQueue(dispatchRequest.getTopic(), dispatchRequest.getQueueId());
        cq.putMessagePositionInfoWrapper(dispatchRequest);
    }

然後根據topic和queueId找到對應的ConsumerQueue。這裏queueId是Producer發送消息時就根據算法選好了的,具體怎麼選的可以參考之前的文章:消息隊列如何選擇的?。找到隊列後,就可以保存相關信息了,如下所示:

 boolean result = this.putMessagePositionInfo(request.getCommitLogOffset(),
                request.getMsgSize(), tagsCode, request.getConsumeQueueOffset());

其中這個cosumerQueueOffset是邏輯偏移量,並非物理偏移量。因爲每一個條目都是固定的20個字節大小,
存放的內容是8字節的消息偏移+4字節的消息長度+8字節的tagsCode。這樣存儲一個索引條目時,通過這個邏輯偏移量*20字節,就可以得到它的物理偏移量。如下所示:

 final long expectLogicOffset = cqOffset * CQ_STORE_UNIT_SIZE;

然後就是熟悉的流程了,找到偏移所在的文件,然後保存內容。

更新reputFromOffset位移

在循環處理消息時,每處理一條,則將reputFromOffset更新。這裏更新是根據獲取消息的結果來的,因此有必要看看是如何獲取單條消息的。

 /**
     * check the message and returns the message size
     *
     * @return 0 Come the end of the file // >0 Normal messages // -1 Message checksum failure
     */
    public DispatchRequest checkMessageAndReturnSize(java.nio.ByteBuffer byteBuffer, final boolean checkCRC,
        final boolean readBody) {}

註釋寫的比較清楚,size=0,說明到了文件末尾。 > 0說明是正常的消息。< 0說明消息有誤。下面我們跟着checkMessageAndReturnSize代碼一個個來看:

            int magicCode = byteBuffer.getInt();
            switch (magicCode) {
                case MESSAGE_MAGIC_CODE:
                    break;
                case BLANK_MAGIC_CODE:
                    return new DispatchRequest(0, true /* success */);
                default:
                    log.warn("found a illegal magic code 0x" + Integer.toHexString(magicCode));
                    return new DispatchRequest(-1, false /* success */);
            }

在之前的講解RocketMQ消息存儲結構-CommitLog的時候,講過這個magicCode:
在這裏插入圖片描述
因此當該mappedFile沒有空間存儲該條消息時,其magicCode是BLANK_MAGIC_CODE,此時我們返回的DispatchRequest(0, true /* success */)。後續的處理就是滾動到下一個mappedFile:
在這裏插入圖片描述
此時reputFromOffset更新爲下個文件的偏移開始位置。
注:rollNextFile裏面reputFromOffset計算顯得略微複雜了,不知爲何。其實直接取該mappedFile的fileFromOffset就可以了。

當magicCode不正確的時候,表明消息出問題了。此時返回DispatchRequest(-1, false /* success */)。後續處理就是結束本次doReput,並將reputFromOffset設置到本次數據末尾。
在這裏插入圖片描述
消息出問題後,Broker MASTER節點的reputFromOffset跳過了剩下的數據,這意味着後續的部分數據都被忽略不再處理了。爲什麼不僅僅跳過該條消息呢?這裏我還不大清楚。

接下來是檢查消息CRC,如果檢查失敗,返回的也是DispatchRequest(-1, false /* success */),處理方法同magicCode一樣,都是消息本身有問題的處理方式。

然後是手動計算一遍消息大小和讀取的消息大小是否一致,如果不一致,則返回DispatchRequest(totalSize, false/* success */)。此時reputFromOffset就跳過該條消息,如下所示:

this.reputFromOffset += size;

至此,reputFromOffset的更新,我們就講完了。

通過此篇文章,我們大致的瞭解了RocketMQ是如何構建消費隊列(ConsumerQueue)的。通過一個服務線程,異步的從CommitLog中獲取已經寫入的消息,然後將消息位置,大小,tagsCode保存至我們的選好的ConsumerQueue中。但是,這裏的保存僅僅是寫入byteBuffer,還沒有真正的落到物理文件上。真正的落盤操作,也是通過服務線程進行異步處理的,限於篇幅,我們後續再談。

發佈了379 篇原創文章 · 獲贊 87 · 訪問量 59萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章