讀 RocketMQ 原始碼,學習併發程式設計三大神器

勇哥程式設計遊記發表於2022-11-27

筆者是 RocketMQ 的忠實粉絲,在閱讀原始碼的過程中,學習到了很多程式設計技巧。

這篇文章,筆者結合 RocketMQ 原始碼,分享併發程式設計三大神器的相關知識點。

1 CountDownLatch 實現網路同步請求

CountDownLatch 是一個同步工具類,用來協調多個執行緒之間的同步,它能夠使一個執行緒在等待另外一些執行緒完成各自工作之後,再繼續執行。

下圖是 CountDownLatch 的核心方法:

我們可以認為它內建一個計數器,建構函式初始化計數值。每當執行緒執行 countDown 方法,計數器的值就會減一,當計數器的值為 0 時,表示所有的任務都執行完成,然後在 CountDownLatch 上等待的執行緒就可以恢復執行接下來的任務。

舉例,資料庫有100萬條資料需要處理,單執行緒執行比較慢,我們可以將任務分為5個批次,執行緒池按照每個批次執行,當5個批次整體執行完成後,列印出任務執行的時間 。

 long start = System.currentTimeMillis();
 ExecutorService executorService = Executors.newFixedThreadPool(10);
 int batchSize = 5;
 CountDownLatch countDownLatch = new CountDownLatch(batchSize);
 for (int i = 0; i < batchSize; i++) {
   final int batchNumber = i;
   executorService.execute(new Runnable() {
      @Override
      public void run() {
        try {
           doSomething(batchNumber);
        } catch (Exception e) {
           e.printStackTrace();
        } finally {
           countDownLatch.countDown();
        }
      }
   });
}
countDownLatch.await();
System.out.println("任務執行耗時:" + (System.currentTimeMillis() - start) + "毫秒");

溫習完 CountDownLatch 的知識點,回到 RocketMQ 原始碼。

筆者在沒有接觸網路程式設計之前,一直很疑惑,網路同步請求是如何實現的?

同步請求指:客戶端執行緒發起呼叫後,需要在指定的超時時間內,等到響應結果,才能完成本次呼叫如果超時時間內沒有得到結果,那麼會丟擲超時異常。

RocketMQ 的同步傳送訊息介面見下圖:

追蹤原始碼,真正傳送請求的方法是通訊模組的同步請求方法 invokeSyncImpl

整體流程:

  1. 傳送訊息執行緒 Netty channel 物件呼叫 writeAndFlush 方法後 ,它的本質是透過 Netty 的讀寫執行緒將資料包傳送到核心 , 這個過程本身就是非同步的;
  2. ResponseFuture 類中內建一個 CountDownLatch 物件 ,responseFuture 物件呼叫 waitRepsone 方法,傳送訊息執行緒會阻塞 ;

  1. 客戶端收到響應命令後, 執行 processResponseCommand 方法,核心邏輯是執行 ResponseFuture 的 putResponse 方法。
讀 RocketMQ 原始碼,學習併發程式設計三大神器

該方法的本質就是填充響應物件,並呼叫 countDownLatch 的 countDown 方法 , 這樣傳送訊息執行緒就不再阻塞。

CountDownLatch 實現網路同步請求是非常實用的技巧,在很多開源中介軟體裡,比如 Metaq ,Xmemcached 都有類似的實現。

2 ReadWriteLock 名字服務路由管理

讀寫鎖是一把鎖分為兩部分:讀鎖和寫鎖,其中讀鎖允許多個執行緒同時獲得,而寫鎖則是互斥鎖。

它的規則是:讀讀不互斥,讀寫互斥,寫寫互斥,適用於讀多寫少的業務場景。

我們一般都使用 ReentrantReadWriteLock ,該類實現了 ReadWriteLock 。ReadWriteLock 介面也很簡單,其內部主要提供了兩個方法,分別返回讀鎖和寫鎖 。

 public interface ReadWriteLock {
    //獲取讀鎖
    Lock readLock();
    //獲取寫鎖
    Lock writeLock();
}

讀寫鎖的使用方式如下所示:

  1. 建立 ReentrantReadWriteLock 物件 , 當使用 ReadWriteLock 的時候,並不是直接使用,而是獲得其內部的讀鎖和寫鎖,然後分別呼叫 lock / unlock 方法 ;
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
  1. 讀取共享資料 ;
Lock readLock = readWriteLock.readLock();
readLock.lock();
try {
   // TODO 查詢共享資料
} finally {
   readLock.unlock();
}
  1. 寫入共享資料;
Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
   // TODO 修改共享資料
} finally {
   writeLock.unlock();
}

RocketMQ架構上主要分為四部分,如下圖所示 :

  1. Producer :訊息釋出的角色,Producer 透過 MQ 的負載均衡模組選擇相應的 Broker 叢集佇列進行訊息投遞,投遞的過程支援快速失敗並且低延遲。

  2. Consumer :訊息消費的角色,支援以 push 推,pull 拉兩種模式對訊息進行消費。

  3. BrokerServer :Broker主要負責訊息的儲存、投遞和查詢以及服務高可用保證。

  4. NameServer :名字服務是一個非常簡單的 Topic 路由註冊中心,其角色類似 Dubbo 中的zookeeper,支援Broker的動態註冊與發現。

NameServer 是一個幾乎無狀態節點,可叢集部署,節點之間無任何資訊同步。Broker 啟動之後會向所有 NameServer 定期(每 30s)傳送心跳包(路由資訊),NameServer 會定期掃描 Broker 存活列表,如果超過 120s 沒有心跳則移除此 Broker 相關資訊,代表下線。

那麼 NameServer 如何儲存路由資訊呢?

路由資訊透過幾個 HashMap 來儲存,當 Broker 向 Nameserver 傳送心跳包(路由資訊),Nameserver 需要對 HashMap 進行資料更新,但我們都知道 HashMap 並不是執行緒安全的,高併發場景下,容易出現 CPU 100% 問題,所以更新 HashMap 時需要加鎖,RocketMQ 使用了 JDK 的讀寫鎖 ReentrantReadWriteLock 。

  1. 更新路由資訊,操作寫鎖

  1. 查詢主題資訊,操作讀鎖

讀寫鎖適用於讀多寫少的場景,比如名字服務,配置服務等。

3 CompletableFuture 非同步訊息處理

RocketMQ 主從架構中,主節點與從節點之間資料同步/複製的方式有同步雙寫非同步複製兩種模式。

非同步複製是指訊息在主節點落盤成功後就告訴客戶端訊息傳送成功,無需等待訊息從主節點複製到從節點,訊息的複製由其他執行緒完成。

同步雙寫是指主節點將訊息成功落盤後,需要等待從節點複製成功,再告訴客戶端訊息傳送成功。

同步雙寫模式是阻塞的,筆者按照 RocketMQ 4.6.1 原始碼,整理出主節點處理一個傳送訊息的請求的時序圖。

整體流程:

  1. 生產者將訊息傳送到 Broker , Broker 接收到訊息後,傳送訊息處理器 SendMessageProcessor 的執行執行緒池 SendMessageExecutor 執行緒池來處理傳送訊息命令;

  2. 執行 ComitLog 的 putMessage 方法;

  3. ComitLog 內部先執行 appendMessage 方法;

  4. 然後提交一個 GroupCommitRequest 到同步複製服務 HAService ,等待 HAService 通知 GroupCommitRequest 完成;

  5. 返回寫入結果並響應客戶端 。

我們可以看到:傳送訊息的執行執行緒需要等待訊息複製從節點 , 並將訊息返回給生產者才能開始處理下一個訊息

RocketMQ 4.6.1 原始碼中,執行執行緒池的執行緒數量是 1 ,假如執行緒處理主從同步速度慢了,系統在這一瞬間無法處理新的傳送訊息請求,造成 CPU 資源無法被充分利用 , 同時系統的吞吐量也會降低。

那麼最佳化同步雙寫呢 ?

從 RocketMQ 4.7 開始,RocketMQ 引入了 CompletableFuture 實現了非同步訊息處理

  1. 傳送訊息的執行執行緒不再等待訊息複製到從節點後再處理新的請求,而是提前生成 CompletableFuture 並返回 ;
  2. HAService 中的執行緒在複製成功後,呼叫 CompletableFuture 的 complete 方法,通知 remoting 模組響應客戶端(執行緒池:PutMessageExecutor ) 。

我們分析下 RocketMQ 4.9.4 核心程式碼:

  1. Broker 接收到訊息後,傳送訊息處理器 SendMessageProcessor 的執行執行緒池 SendMessageExecutor 執行緒池來處理傳送訊息命令;
  2. 呼叫 SendMessageProcessor 的 asyncProcessRequest 方法;

  1. 呼叫 Commitlog 的 aysncPutMessage 方法寫入訊息 ;

    這段程式碼中,當 commitLog 執行完 appendMessage 後, 需要執行刷盤任務同步複製兩個任務。

    但這兩個任務並不是同步執行,而是非同步的方式。

  2. 複製執行緒複製訊息後,喚醒 future ;

  3. 組裝響應命令 ,並將響應命令返回給客戶端。

為了便於理解這一段訊息傳送處理過程的執行緒模型,筆者在 RocketMQ 原始碼中做了幾處埋點,修改 Logback 的日誌配置,傳送一條普通的訊息,觀察服務端日誌。

從日誌中,我們可以觀察到:

  1. 傳送訊息的執行執行緒(圖中紅色)在執行完建立刷盤 Future 和同步複製 future 之後,並沒有等待這兩個任務執行完成,而是在結束 asyncProcessRequest 方法後就可以處理傳送訊息請求了 ;
  2. 刷盤執行緒和複製執行緒執行完各自的任務後,喚醒 future,然後透過刷盤執行緒組裝儲存結果,最後透過 PutMessageExecutor 執行緒池(圖中黃色)將響應命令返回給客戶端。

筆者一直認為:非同步是更細粒度的使用系統資源的一種方式,在非同步訊息處理的過程中,透過 CompletableFuture 這個神器,各個執行緒各司其職,優雅且高效的提升了 RocketMQ 的效能。


如果我的文章對你有所幫助,還請幫忙點贊、在看、轉發一下,你的支援會激勵我輸出更高質量的文章,非常感謝!

相關文章