從源碼分析RocketMQ系列-消息拉取PullMessageProcessor詳解

導語
  在之前的分析中分析了關於SendMessageProcessor,並且提供了對應的源碼分析分析對於消息持久化的問題,下面來看另外一個PullMessageProcessor,在RocketMQ中比較重要的一個概念就是消息拉取,這個類就是表示消息的拉取操作。也是繼承了NettyRequestProcessor 接口並且實現了其中的RemotingCommand processRequest(ChannelHandlerContext ctx, RemotingCommand request) throws Exception; 這篇分享就來看看這個類的主要操作。


  通過之前的分享可以知道,對於消息的發送以及消息的收取其實,最後交給底層Netty之前都是進行的是封裝的操作,現在網上很多的分析都是沒有分析到核心的部分,而是將這些封裝的操作進行了剖析,但是實際上結合之前的分析,所有的操作都是由一個Pair來進行操作,這Pair是一個處理器和執行器的封裝,也就是說最後關於PullMessage的操作也會被封裝成這樣一個對象。在這個對象中真正負責操作的是處理器對象,也就是PullMessageProcessor對象,下面就來詳細的瞭解一下這個對象

類繼承關係

在這裏插入圖片描述
  通過上面的類繼承關係可以看到這個對象PullMessageProcessor其實是繼承了NettyRequestProcessor接口既然繼承了這個接口,那麼就要實現這個接口中的一個processRequest()方法,對於這個方法應該是不陌生的,在之前的分析中提到過。這個就是實際處理邏輯的方法,看看在PullMessageProcessor類中是怎麼實現的?

處理請求方法

processRequest() 方法法分析

  從上面的分析中可以知道processRequest()方法傳入的參數,其中一個表示Channel處理器的上下文對象,但是會看到下面代碼中內部調用的方法傳入的參數其實是一個Channel,並且這個內部方法傳入了三個參數,按照之前的分析,就需要先對這個方法的參數進行分析

@Override
public RemotingCommand processRequest(final ChannelHandlerContext ctx,
    RemotingCommand request) throws RemotingCommandException {
    return this.processRequest(ctx.channel(), request, true);
}

  首先會看到這個內部方法有三個參數

  • Channel 結合NIO中的Channel概念,進行理解
  • RemotingCommand 傳入的請求參數,這個在之前的分析中提到過
  • brokerAllowSuspend 是否允許被掛起,也就是是否允許在未找到消息的時候,暫時掛起處理線程,第一次傳入的參數默認爲true。
private RemotingCommand processRequest(final Channel channel, RemotingCommand request, boolean brokerAllowSuspend)

  對於RemotingCommand參數自然是不用多說了,這裏主要來分析一下其中另外兩個參數
Channel
  分析改方法完成之後發現對於Channel的使用只有兩種情況channel.remoteAddress()和下面截圖中的使用,而對於第一中使用更多的是出現與日誌的輸出中。也就是說下面截圖代碼纔是最關鍵的地方。這裏給大家分享一個思路,會發現在日誌中使用了channel.remoteAddress()的方法,那麼這裏就應該對Channel對象有所懷疑,這對象是不是Netty提供的對象,RocketMQ在Netty的基礎上進行了封裝,查看源碼會知道這裏獲取到的就是Netty的Channel對象,但是具體使用什麼樣的實現,這個就要看具體調用的方法了。畢竟Channel是接口。
在這裏插入圖片描述
  分析方法可以知道在上面截圖中有一個對象FileRegion,這個接口是Netty的接口,它由一個默認實現類DefaultFileRegion,當然這個實現類是有Netty提供的,在RocketMQ中有如下的三個實現類
在這裏插入圖片描述
  從圖中不難發現,其實這裏使用的顯然不是由Netty實現的的默認類而是RocketMQ中實現的三個類其中的一個,這裏先來看一個東西,看這個三個類的實現包PageCache。並且後面有一個ManyMessageTransfer 類的存在,這個在上篇分享的時候提到過叫頁緩存與內存映射,當然提到頁緩存技術不得不提的就是零拷貝。

頁緩存與內存映射
  頁緩存(PageCache)是OS對文件的緩存,用於加速對文件的讀寫。一般來說,程序對文件進行順序讀寫的速度幾乎接近於內存的讀寫速度,主要原因就是由於OS使用PageCache機制對讀寫訪問操作進行了性能優化,將一部分的內存用作PageCache。對於數據的寫入,OS會先寫入至Cache內,隨後通過異步的方式由pdflush內核線程將Cache內的數據刷盤至物理磁盤上。對於數據的讀取,如果一次讀取文件時出現未命中PageCache的情況,OS從物理磁盤上訪問讀取文件的同時,會順序對其他相鄰塊的數據文件進行預讀取。
  在RocketMQ中,ConsumeQueue邏輯消費隊列存儲的數據較少,並且是順序讀取,在page cache機制的預讀取作用下,Consume Queue文件的讀性能幾乎接近讀內存,即使在有消息堆積情況下也不會影響性能。而對於CommitLog消息存儲的日誌數據文件來說,讀取消息內容時候會產生較多的隨機訪問讀取,嚴重影響性能。如果選擇合適的系統IO調度算法,比如設置調度算法爲“Deadline”(此時塊存儲採用SSD的話),隨機讀的性能也會有所提升。
  另外,RocketMQ主要通過MappedByteBuffer對文件進行讀寫操作。其中,利用了NIO中的FileChannel模型將磁盤上的物理文件直接映射到用戶態的內存地址中(這種Mmap的方式減少了傳統IO將磁盤文件數據在操作系統內核地址空間的緩衝區和用戶應用程序地址空間的緩衝區之間來回進行拷貝的性能開銷),將對文件的操作轉化爲直接對內存地址進行操作,從而極大地提高了文件的讀寫效率(正因爲需要使用內存映射機制,故RocketMQ的文件存儲都使用定長結構來存儲,方便一次將整個文件映射至內存)。

brokerAllowSuspend
  對於這個參數在該方法中有如下的幾處被使用了,從下面代碼截圖中可看到其實,第一次和第三次使用都不是太重要,其中最重要的一次應該是第二次的調用
  第一次
在這裏插入圖片描述
  第二次
在這裏插入圖片描述
  第三次
在這裏插入圖片描述

PullRequestHoldService 服務

在第二次調用的時候有如下的一些注意

  • hasSuspendFlag 構建拉取消息是的拉取標記,默認是true
  • suspendTimeoutMillisLong 是從DefaultMQPullConsumer 的brokerSuspendMaxTimeMillis屬性值
  • pullRequest 對象是被PullRequestHoldService 線程調度,觸發拉取消息
  • response = null; 這個設置表示 此次調用不會向客戶端輸出任何字節,客戶端網絡請求客戶端的讀事件不會被觸發,不會有響應結果就表示,會一直處於等待狀態

也就是在第二次觸發事件的時候,調用了PullRequestHoldService服務,並調用了其中的suspendPullRequest()方法。這個方法其實就是將ManyPullRequest進行了封裝。
在這裏插入圖片描述
  也會注意到PullRequestHoldService服務的run()方法
在這裏插入圖片描述
  如果代碼開啓了長輪詢模式,每次掛起五秒鐘,然後就再次嘗試拉取,如果不開啓長輪詢,則值掛起一次,並且時間也是有ShortPollTimeMill進行設置,然後去進行查找消息。在其中的checkHoldRequest()方法中遍歷pullRequestTable 如果拉取任務的待拉取偏移量小於當前隊列的最大偏移量是執行拉取,否則沒有超過最大等待時間則進行等待,否則返回未拉取消息,返回給消息客戶端
在這裏插入圖片描述
  在notifyMessageArriving()方法中調用了與給喚醒方法executeRequestWhenWakeup(),這個方法,是在PullMessageProcessor中提供的
在這裏插入圖片描述
executeRequestWhenWakeup()方法說明
在這裏插入圖片描述

Netty FileRegion 實現零拷貝

  這就不難理解了,下面就來介紹一下通過 FileRegion 實現零拷貝。Netty 中使用 FileRegion 實現文件傳輸的零拷貝, 不過在底層 FileRegion 是依賴於 Java NIO FileChannel.transfer 的零拷貝功能.

  首先我們從最基礎的 Java IO 開始吧. 假設我們希望實現一個文件拷貝的功能, 那麼使用傳統的方式, 我們有如下實現:

public static void copyFile(String srcFile, String destFile) throws Exception {
    byte[] temp = new byte[1024];
    FileInputStream in = new FileInputStream(srcFile);
    FileOutputStream out = new FileOutputStream(destFile);
    int length;
    while ((length = in.read(temp)) != -1) {
        out.write(temp, 0, length);
    }

    in.close();
    out.close();
}

  上面是一個典型的讀寫二進制文件的代碼實現了. 不用我說, 大家肯定都知道, 上面的代碼中不斷中源文件中讀取定長數據到 temp 數組中, 然後再將 temp 中的內容寫入目的文件, 這樣的拷貝操作對於小文件倒是沒有太大的影響, 但是如果我們需要拷貝大文件時, 頻繁的內存拷貝操作就消耗大量的系統資源了.
下面我們來看一下使用 Java NIO 的 FileChannel 是如何實現零拷貝的:

public static void copyFileWithFileChannel(String srcFileName, String destFileName) throws Exception {
    RandomAccessFile srcFile = new RandomAccessFile(srcFileName, "r");
    FileChannel srcFileChannel = srcFile.getChannel();

    RandomAccessFile destFile = new RandomAccessFile(destFileName, "rw");
    FileChannel destFileChannel = destFile.getChannel();

    long position = 0;
    long count = srcFileChannel.size();

    srcFileChannel.transferTo(position, count, destFileChannel);
}

  可以看到, 使用了 FileChannel 後, 我們就可以直接將源文件的內容直接拷貝(transferTo) 到目的文件中, 而不需要額外借助一個臨時 buffer, 避免了不必要的內存操作.

有了上面的一些理論知識, 我們來看一下在 Netty 中是怎麼使用 FileRegion 來實現零拷貝傳輸一個文件的:

@Override
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
    RandomAccessFile raf = null;
    long length = -1;
    try {
	    // 1. 通過 RandomAccessFile 打開一個文件.
        raf = new RandomAccessFile(msg, "r");
        length = raf.length();
    } catch (Exception e) {
        ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');
        return;
    } finally {
        if (length < 0 && raf != null) {
            raf.close();
        }
    }

    ctx.write("OK: " + raf.length() + '\n');
    if (ctx.pipeline().get(SslHandler.class) == null) {
        // SSL not enabled - can use zero-copy file transfer.
        // 2. 調用 raf.getChannel() 獲取一個 FileChannel.
        // 3. 將 FileChannel 封裝成一個 DefaultFileRegion
        ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
    } else {
        // SSL enabled - cannot use zero-copy file transfer.
        ctx.write(new ChunkedFile(raf));
    }
    ctx.writeAndFlush("\n");
}

上面的代碼是 Netty 的一個例子, 其源碼在 netty/example/src/main/java/io/netty/example/file/FileServerHandler.java
  可以看到, 第一步是通過 RandomAccessFile 打開一個文件, 然後 Netty 使用了 DefaultFileRegion 來封裝一個 FileChannel 即: new DefaultFileRegion(raf.getChannel(), 0, length) 當有了 FileRegion 後, 我們就可以直接通過它將文件的內容直接寫入 Channel 中, 而不需要像傳統的做法: 拷貝文件內容到臨時 buffer, 然後再將 buffer 寫入 Channel. 通過這樣的零拷貝操作, 無疑對傳輸大文件很有幫助

  有了上面這個例子就不難理解使用Netty FileRegion實現文件零拷貝

消息拉取分析

要開啓長輪詢, 在 broker 配置文件中 longPollingEnable=true, 默認是開啓的。

  消息拉取爲了提高網絡性能,在消息服務端根據拉取偏移量去物理文件查找消息時沒有找到,並不立即返回消息未找到,而是會將該線程掛起一段時間,然後再次重試,直到重試。掛起分爲長輪詢或短輪詢,在broker 端可以通過 longPollingEnable=true 來開啓長輪詢。

  • 短輪詢:longPollingEnable=false,第一次未拉取到消息後等待 shortPollingTimeMills時間後再試。shortPollingTimeMills默認爲1S。

  • 長輪詢:longPollingEnable=true,會根據消費者端設置的掛起超時時間,受DefaultMQPullConsumer 的brokerSuspendMaxTimeMillis控制,默認20s,(brokerSuspendMaxTimeMillis),長輪詢有兩個線程來相互實現。

PullRequestHoldService:每隔5s重試一次。
DefaultMessageStore#ReputMessageService,每當有消息到達後,轉發消息,然後調用PullRequestHoldService 線程中的拉取任務,嘗試拉取,每處理一次,Thread.sleep(1), 繼續下一次檢查。

總結

  本次主要是對PullMessageProcessor的邏輯進行了簡單的說明,整個的流程按照代碼執行流程進行分析,中間也提到了幾個關鍵的概念,在上面已經做了總結,主要注意的是就是長輪詢和零拷貝。

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