面渣逆襲:RocketMQ二十三問

三分惡發表於2022-04-07

基礎

1.為什麼要使用訊息佇列呢?

訊息佇列主要有三大用途,我們拿一個電商系統的下單舉例:

  • 解耦:引入訊息佇列之前,下單完成之後,需要訂單服務去呼叫庫存服務減庫存,呼叫營銷服務加營銷資料……引入訊息佇列之後,可以把訂單完成的訊息丟進佇列裡,下游服務自己去呼叫就行了,這樣就完成了訂單服務和其它服務的解耦合。

    訊息佇列解耦

  • 非同步:訂單支付之後,我們要扣減庫存、增加積分、傳送訊息等等,這樣一來這個鏈路就長了,鏈路一長,響應時間就變長了。引入訊息佇列,除了更新訂單狀態,其它的都可以非同步去做,這樣一來就來,就能降低響應時間。

    訊息佇列非同步

  • 削峰:訊息佇列合一用來削峰,例如秒殺系統,平時流量很低,但是要做秒殺活動,秒殺的時候流量瘋狂懟進來,我們的伺服器,Redis,MySQL各自的承受能力都不一樣,直接全部流量照單全收肯定有問題啊,嚴重點可能直接打掛了。

    我們可以把請求扔到佇列裡面,只放出我們服務能處理的流量,這樣就能抗住短時間的大流量了。

    訊息佇列削峰

    解耦、非同步、削峰,是訊息佇列最主要的三大作用。

2.為什麼要選擇RocketMQ?

市場上幾大訊息佇列對比如下:

四大訊息佇列對比

總結一下

選擇中介軟體的可以從這些維度來考慮:可靠性,效能,功能,可運維行,可擴充性,社群活躍度。目前常用的幾個中介軟體,ActiveMQ作為“老古董”,市面上用的已經不多,其它幾種:

  • RabbitMQ:

  • 優點:輕量,迅捷,容易部署和使用,擁有靈活的路由配置

  • 缺點:效能和吞吐量不太理想,不易進行二次開發

  • RocketMQ:

    • 優點:效能好,高吞吐量,穩定可靠,有活躍的中文社群

    • 缺點:相容性上不是太好

  • Kafka:

    • 優點:擁有強大的效能及吞吐量,相容性很好
    • 缺點:由於“攢一波再處理”導致延遲比較高

我們的系統是面向使用者的C端系統,具有一定的併發量,對效能也有比較高的要求,所以選擇了低延遲、吞吐量比較高,可用性比較好的RocketMQ。

3.RocketMQ有什麼優缺點?

RocketMQ優點:

  • 單機吞吐量:十萬級
  • 可用性:非常高,分散式架構
  • 訊息可靠性:經過引數優化配置,訊息可以做到0丟失
  • 功能支援:MQ功能較為完善,還是分散式的,擴充套件性好
  • 支援10億級別的訊息堆積,不會因為堆積導致效能下降
  • 原始碼是Java,方便結合公司自己的業務二次開發
  • 天生為金融網際網路領域而生,對於可靠性要求很高的場景,尤其是電商裡面的訂單扣款,以及業務削峰,在大量交易湧入時,後端可能無法及時處理的情況
  • RoketMQ在穩定性上可能更值得信賴,這些業務場景在阿里雙11已經經歷了多次考驗,如果你的業務有上述併發場景,建議可以選擇RocketMQ

RocketMQ缺點:

  • 支援的客戶端語言不多,目前是Java及c++,其中c++不成熟
  • 沒有在 MQ核心中去實現JMS等介面,有些系統要遷移需要修改大量程式碼

4.訊息佇列有哪些訊息模型?

訊息佇列有兩種模型:佇列模型釋出/訂閱模型

  • 佇列模型

    這是最初的一種訊息佇列模型,對應著訊息佇列“發-存-收”的模型。生產者往某個佇列裡面傳送訊息,一個佇列可以儲存多個生產者的訊息,一個佇列也可以有多個消費者,但是消費者之間是競爭關係,也就是說每條訊息只能被一個消費者消費。

    佇列模型

  • 釋出/訂閱模型

    如果需要將一份訊息資料分發給多個消費者,並且每個消費者都要求收到全量的訊息。很顯然,佇列模型無法滿足這個需求。解決的方式就是釋出/訂閱模型。

    在釋出 - 訂閱模型中,訊息的傳送方稱為釋出者(Publisher),訊息的接收方稱為訂閱者(Subscriber),服務端存放訊息的容器稱為主題(Topic)。釋出者將訊息傳送到主題中,訂閱者在接收訊息之前需要先“訂閱主題”。“訂閱”在這裡既是一個動作,同時還可以認為是主題在消費時的一個邏輯副本,每份訂閱中,訂閱者都可以接收到主題的所有訊息。

    釋出-訂閱模型

    它和 “佇列模式” 的異同:生產者就是釋出者,佇列就是主題,消費者就是訂閱者,無本質區別。唯一的不同點在於:一份訊息資料是否可以被多次消費。

5.那RocketMQ的訊息模型呢?

RocketMQ使用的訊息模型是標準的釋出-訂閱模型,在RocketMQ的術語表中,生產者、消費者和主題,與釋出-訂閱模型中的概念是完全一樣的。

RocketMQ本身的訊息是由下面幾部分組成:

RocketMQ訊息的組成

  • Message

Message(訊息)就是要傳輸的資訊。

一條訊息必須有一個主題(Topic),主題可以看做是你的信件要郵寄的地址。

一條訊息也可以擁有一個可選的標籤(Tag)和額處的鍵值對,它們可以用於設定一個業務 Key 並在 Broker 上查詢此訊息以便在開發期間查詢問題。

  • Topic

Topic(主題)可以看做訊息的歸類,它是訊息的第一級型別。比如一個電商系統可以分為:交易訊息、物流訊息等,一條訊息必須有一個 Topic 。

Topic 與生產者和消費者的關係非常鬆散,一個 Topic 可以有0個、1個、多個生產者向其傳送訊息,一個生產者也可以同時向不同的 Topic 傳送訊息。

一個 Topic 也可以被 0個、1個、多個消費者訂閱。

  • Tag

Tag(標籤)可以看作子主題,它是訊息的第二級型別,用於為使用者提供額外的靈活性。使用標籤,同一業務模組不同目的的訊息就可以用相同 Topic 而不同的 Tag 來標識。比如交易訊息又可以分為:交易建立訊息、交易完成訊息等,一條訊息可以沒有 Tag

標籤有助於保持你的程式碼乾淨和連貫,並且還可以為 RocketMQ 提供的查詢系統提供幫助。

  • Group

RocketMQ中,訂閱者的概念是通過消費組(Consumer Group)來體現的。每個消費組都消費主題中一份完整的訊息,不同消費組之間消費進度彼此不受影響,也就是說,一條訊息被Consumer Group1消費過,也會再給Consumer Group2消費。

消費組中包含多個消費者,同一個組內的消費者是競爭消費的關係,每個消費者負責消費組內的一部分訊息。預設情況,如果一條訊息被消費者Consumer1消費了,那同組的其他消費者就不會再收到這條訊息。

  • Message Queue

Message Queue(訊息佇列),一個 Topic 下可以設定多個訊息佇列,Topic 包括多個 Message Queue ,如果一個 Consumer 需要獲取 Topic下所有的訊息,就要遍歷所有的 Message Queue。

RocketMQ還有一些其它的Queue——例如ConsumerQueue。

  • Offset

在Topic的消費過程中,由於訊息需要被不同的組進行多次消費,所以消費完的訊息並不會立即被刪除,這就需要RocketMQ為每個消費組在每個佇列上維護一個消費位置(Consumer Offset),這個位置之前的訊息都被消費過,之後的訊息都沒有被消費過,每成功消費一條訊息,消費位置就加一。

也可以這麼說,Queue 是一個長度無限的陣列,Offset 就是下標。

RocketMQ的訊息模型中,這些就是比較關鍵的概念了。畫張圖總結一下:
RocketMQ訊息模型

6.訊息的消費模式瞭解嗎?

訊息消費模式有兩種:Clustering(叢集消費)和Broadcasting(廣播消費)。

兩種消費模式

預設情況下就是叢集消費,這種模式下一個消費者組共同消費一個主題的多個佇列,一個佇列只會被一個消費者消費,如果某個消費者掛掉,分組內其它消費者會接替掛掉的消費者繼續消費。

而廣播消費訊息會發給消費者組中的每一個消費者進行消費。

7.RoctetMQ基本架構瞭解嗎?

先看圖,RocketMQ的基本架構:

RocketMQ架構

RocketMQ 一共有四個部分組成:NameServer,Broker,Producer 生產者,Consumer 消費者,它們對應了:發現、發、存、收,為了保證高可用,一般每一部分都是叢集部署的。

8.那能介紹一下這四部分嗎?

類比一下我們生活的郵政系統——

郵政系統要正常執行,離不開下面這四個角色, 一是發信者,二 是收信者, 三是負責暫存傳輸的郵局, 四是負責協調各個地方郵局的管理機構。 對應到 RocketMQ 中,這四個角色就是 Producer、 Consumer、 Broker 、NameServer。

RocketMQ類比郵政體系

NameServer

NameServer 是一個無狀態的伺服器,角色類似於 Kafka使用的 Zookeeper,但比 Zookeeper 更輕量。
特點:

  • 每個 NameServer 結點之間是相互獨立,彼此沒有任何資訊互動。
  • Nameserver 被設計成幾乎是無狀態的,通過部署多個結點來標識自己是一個偽叢集,Producer 在傳送訊息前從 NameServer 中獲取 Topic 的路由資訊也就是發往哪個 Broker,Consumer 也會定時從 NameServer 獲取 Topic 的路由資訊,Broker 在啟動時會向 NameServer 註冊,並定時進行心跳連線,且定時同步維護的 Topic 到 NameServer。

功能主要有兩個:

  • 1、和Broker 結點保持長連線。
  • 2、維護 Topic 的路由資訊。

Broker

訊息儲存和中轉角色,負責儲存和轉發訊息。

  • Broker 內部維護著一個個 Consumer Queue,用來儲存訊息的索引,真正儲存訊息的地方是 CommitLog(日誌檔案)。

RocketMQ儲存-圖片來源官網

  • 單個 Broker 與所有的 Nameserver 保持著長連線和心跳,並會定時將 Topic 資訊同步到 NameServer,和 NameServer 的通訊底層是通過 Netty 實現的。

Producer

訊息生產者,業務端負責傳送訊息,由使用者自行實現和分散式部署。

  • Producer由使用者進行分散式部署,訊息由Producer通過多種負載均衡模式傳送到Broker叢集,傳送低延時,支援快速失敗。
  • RocketMQ 提供了三種方式傳送訊息:同步、非同步和單向
    • 同步傳送:同步傳送指訊息傳送方發出資料後會在收到接收方發回響應之後才發下一個資料包。一般用於重要通知訊息,例如重要通知郵件、營銷簡訊。
    • 非同步傳送:非同步傳送指傳送方發出資料後,不等接收方發回響應,接著傳送下個資料包,一般用於可能鏈路耗時較長而對響應時間敏感的業務場景,例如使用者視訊上傳後通知啟動轉碼服務。
    • 單向傳送:單向傳送是指只負責傳送訊息而不等待伺服器回應且沒有回撥函式觸發,適用於某些耗時非常短但對可靠性要求並不高的場景,例如日誌收集。

Consumer

訊息消費者,負責消費訊息,一般是後臺系統負責非同步消費。

  • Consumer也由使用者部署,支援PUSH和PULL兩種消費模式,支援叢集消費廣播消費,提供實時的訊息訂閱機制
  • Pull:拉取型消費者(Pull Consumer)主動從訊息伺服器拉取資訊,只要批量拉取到訊息,使用者應用就會啟動消費過程,所以 Pull 稱為主動消費型。
  • Push:推送型消費者(Push Consumer)封裝了訊息的拉取、消費進度和其他的內部維護工作,將訊息到達時執行的回撥介面留給使用者應用程式來實現。所以 Push 稱為被動消費型別,但其實從實現上看還是從訊息伺服器中拉取訊息,不同於 Pull 的是 Push 首先要註冊消費監聽器,當監聽器處觸發後才開始消費訊息。

進階

9.如何保證訊息的可用性/可靠性/不丟失呢?

訊息可能在哪些階段丟失呢?可能會在這三個階段發生丟失:生產階段、儲存階段、消費階段。

所以要從這三個階段考慮:

訊息傳遞三階段

生產

在生產階段,主要通過請求確認機制,來保證訊息的可靠傳遞

  • 1、同步傳送的時候,要注意處理響應結果和異常。如果返回響應OK,表示訊息成功傳送到了Broker,如果響應失敗,或者發生其它異常,都應該重試。
  • 2、非同步傳送的時候,應該在回撥方法裡檢查,如果傳送失敗或者異常,都應該進行重試。
  • 3、如果發生超時的情況,也可以通過查詢日誌的API,來檢查是否在Broker儲存成功。

儲存

儲存階段,可以通過配置可靠性優先的 Broker 引數來避免因為當機丟訊息,簡單說就是可靠性優先的場景都應該使用同步。

  • 1、訊息只要持久化到CommitLog(日誌檔案)中,即使Broker當機,未消費的訊息也能重新恢復再消費。
  • 2、Broker的刷盤機制:同步刷盤和非同步刷盤,不管哪種刷盤都可以保證訊息一定儲存在pagecache中(記憶體中),但是同步刷盤更可靠,它是Producer傳送訊息後等資料持久化到磁碟之後再返回響應給Producer。

同步刷盤和非同步刷盤-圖片來源官網

  • 3、Broker通過主從模式來保證高可用,Broker支援Master和Slave同步複製、Master和Slave非同步複製模式,生產者的訊息都是傳送給Master,但是消費既可以從Master消費,也可以從Slave消費。同步複製模式可以保證即使Master當機,訊息肯定在Slave中有備份,保證了訊息不會丟失。

消費

從Consumer角度分析,如何保證訊息被成功消費?

  • Consumer保證訊息成功消費的關鍵在於確認的時機,不要在收到訊息後就立即傳送消費確認,而是應該在執行完所有消費業務邏輯之後,再傳送消費確認。因為訊息佇列維護了消費的位置,邏輯執行失敗了,沒有確認,再去佇列拉取訊息,就還是之前的一條。

10.如何處理訊息重複的問題呢?

對分散式訊息佇列來說,同時做到確保一定投遞和不重複投遞是很難的,就是所謂的“有且僅有一次” 。 RocketMQ擇了確保一定投遞,保證訊息不丟失,但有可能造成訊息重複。

處理訊息重複問題,主要有業務端自己保證,主要的方式有兩種:業務冪等訊息去重

訊息重複處理

業務冪等:第一種是保證消費邏輯的冪等性,也就是多次呼叫和一次呼叫的效果是一樣的。這樣一來,不管訊息消費多少次,對業務都沒有影響。

訊息去重:第二種是業務端,對重複的訊息就不再消費了。這種方法,需要保證每條訊息都有一個惟一的編號,通常是業務相關的,比如訂單號,消費的記錄需要落庫,而且需要保證和訊息確認這一步的原子性。

具體做法是可以建立一個消費記錄表,拿到這個訊息做資料庫的insert操作。給這個訊息做一個唯一主鍵(primary key)或者唯一約束,那麼就算出現重複消費的情況,就會導致主鍵衝突,那麼就不再處理這條訊息。

11.怎麼處理訊息積壓?

發生了訊息積壓,這時候就得想辦法趕緊把積壓的訊息消費完,就得考慮提高消費能力,一般有兩種辦法:

訊息積壓處理

  • 消費者擴容:如果當前Topic的Message Queue的數量大於消費者數量,就可以對消費者進行擴容,增加消費者,來提高消費能力,儘快把積壓的訊息消費玩。
  • 訊息遷移Queue擴容:如果當前Topic的Message Queue的數量小於或者等於消費者數量,這種情況,再擴容消費者就沒什麼用,就得考慮擴容Message Queue。可以新建一個臨時的Topic,臨時的Topic多設定一些Message Queue,然後先用一些消費者把消費的資料丟到臨時的Topic,因為不用業務處理,只是轉發一下訊息,還是很快的。接下來用擴容的消費者去消費新的Topic裡的資料,消費完了之後,恢復原狀。

訊息遷移擴容消費

12.順序訊息如何實現?

順序訊息是指訊息的消費順序和產生順序相同,在有些業務邏輯下,必須保證順序,比如訂單的生成、付款、發貨,這個訊息必須按順序處理才行。

順序訊息

順序訊息分為全域性順序訊息和部分順序訊息,全域性順序訊息指某個 Topic 下的所有訊息都要保證順序;

部分順序訊息只要保證每一組訊息被順序消費即可,比如訂單訊息,只要保證同一個訂單 ID 個訊息能按順序消費即可。

部分順序訊息

部分順序訊息相對比較好實現,生產端需要做到把同 ID 的訊息傳送到同一個 Message Queue ;在消費過程中,要做到從同一個Message Queue讀取的訊息順序處理——消費端不能併發處理順序訊息,這樣才能達到部分有序。

部分順序訊息

傳送端使用 MessageQueueSelector 類來控制 把訊息發往哪個 Message Queue 。

順序訊息生產-例子來源官方

消費端通過使用 MessageListenerOrderly 來解決單 Message Queue 的訊息被併發處理的問題。
消費端述

全域性順序訊息

RocketMQ 預設情況下不保證順序,比如建立一個 Topic ,預設八個寫佇列,八個讀佇列,這時候一條訊息可能被寫入任意一個佇列裡;在資料的讀取過程中,可能有多個 Consumer ,每個 Consumer 也可能啟動多個執行緒並行處理,所以訊息被哪個 Consumer 消費,被消費的順序和寫人的順序是否一致是不確定的。

要保證全域性順序訊息, 需要先把 Topic 的讀寫佇列數設定為 一,然後Producer Consumer 的併發設定,也要是一。簡單來說,為了保證整個 Topic全域性訊息有序,只能消除所有的併發處理,各部分都設定成單執行緒處理 ,這時候就完全犧牲RocketMQ的高併發、高吞吐的特性了。

全域性順序訊息

13.如何實現訊息過濾?

有兩種方案:

  • 一種是在 Broker 端按照 Consumer 的去重邏輯進行過濾,這樣做的好處是避免了無用的訊息傳輸到 Consumer 端,缺點是加重了 Broker 的負擔,實現起來相對複雜。
  • 另一種是在 Consumer 端過濾,比如按照訊息設定的 tag 去重,這樣的好處是實現起來簡單,缺點是有大量無用的訊息到達了 Consumer 端只能丟棄不處理。

一般採用Cosumer端過濾,如果希望提高吞吐量,可以採用Broker過濾。

對訊息的過濾有三種方式:

訊息過濾

  • 根據Tag過濾:這是最常見的一種,用起來高效簡單

    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_EXAMPLE");
    consumer.subscribe("TOPIC", "TAGA || TAGB || TAGC");
    
  • SQL 表示式過濾:SQL表示式過濾更加靈活

    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_4");
    // 只有訂閱的訊息有這個屬性a, a >=0 and a <= 3
    consumer.subscribe("TopicTest", MessageSelector.bySql("a between 0 and 3");
    consumer.registerMessageListener(new MessageListenerConcurrently() {
       @Override
       public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
           return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
       }
    });
    consumer.start();
    
    
  • Filter Server 方式:最靈活,也是最複雜的一種方式,允許使用者自定義函式進行過濾

14.延時訊息瞭解嗎?

電商的訂單超時自動取消,就是一個典型的利用延時訊息的例子,使用者提交了一個訂單,就可以傳送一個延時訊息,1h後去檢查這個訂單的狀態,如果還是未付款就取消訂單釋放庫存。

RocketMQ是支援延時訊息的,只需要在生產訊息的時候設定訊息的延時級別:

      // 例項化一個生產者來產生延時訊息
      DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup");
      // 啟動生產者
      producer.start();
      int totalMessagesToSend = 100;
      for (int i = 0; i < totalMessagesToSend; i++) {
          Message message = new Message("TestTopic", ("Hello scheduled message " + i).getBytes());
          // 設定延時等級3,這個訊息將在10s之後傳送(現在只支援固定的幾個時間,詳看delayTimeLevel)
          message.setDelayTimeLevel(3);
          // 傳送訊息
          producer.send(message);
      }

但是目前RocketMQ支援的延時級別是有限的:

private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";

RocketMQ怎麼實現延時訊息的?

簡單,八個字:臨時儲存+定時任務

Broker收到延時訊息了,會先傳送到主題(SCHEDULE_TOPIC_XXXX)的相應時間段的Message Queue中,然後通過一個定時任務輪詢這些佇列,到期後,把訊息投遞到目標Topic的佇列中,然後消費者就可以正常消費這些訊息。

延遲訊息處理流程-圖片來源見水印

15.怎麼實現分散式訊息事務的?半訊息?

半訊息:是指暫時還不能被 Consumer 消費的訊息,Producer 成功傳送到 Broker 端的訊息,但是此訊息被標記為 “暫不可投遞” 狀態,只有等 Producer 端執行完本地事務後經過二次確認了之後,Consumer 才能消費此條訊息。

依賴半訊息,可以實現分散式訊息事務,其中的關鍵在於二次確認以及訊息回查:

RocketMQ實現訊息事務

  • 1、Producer 向 broker 傳送半訊息
  • 2、Producer 端收到響應,訊息傳送成功,此時訊息是半訊息,標記為 “不可投遞” 狀態,Consumer 消費不了。
  • 3、Producer 端執行本地事務。
  • 4、正常情況本地事務執行完成,Producer 向 Broker 傳送 Commit/Rollback,如果是 Commit,Broker 端將半訊息標記為正常訊息,Consumer 可以消費,如果是 Rollback,Broker 丟棄此訊息。
  • 5、異常情況,Broker 端遲遲等不到二次確認。在一定時間後,會查詢所有的半訊息,然後到 Producer 端查詢半訊息的執行情況。
  • 6、Producer 端查詢本地事務的狀態
  • 7、根據事務的狀態提交 commit/rollback 到 broker 端。(5,6,7 是訊息回查)
  • 8、消費者段消費到訊息之後,執行本地事務,執行本地事務。

16.死信佇列知道嗎?

死信佇列用於處理無法被正常消費的訊息,即死信訊息。

當一條訊息初次消費失敗,訊息佇列 RocketMQ 會自動進行訊息重試;達到最大重試次數後,若消費依然失敗,則表明消費者在正常情況下無法正確地消費該訊息,此時,訊息佇列 RocketMQ 不會立刻將訊息丟棄,而是將其傳送到該消費者對應的特殊佇列中,該特殊佇列稱為死信佇列

死信訊息的特點

  • 不會再被消費者正常消費。
  • 有效期與正常訊息相同,均為 3 天,3 天后會被自動刪除。因此,需要在死信訊息產生後的 3 天內及時處理。

死信佇列的特點

  • 一個死信佇列對應一個 Group ID, 而不是對應單個消費者例項。
  • 如果一個 Group ID 未產生死信訊息,訊息佇列 RocketMQ 不會為其建立相應的死信佇列。
  • 一個死信佇列包含了對應 Group ID 產生的所有死信訊息,不論該訊息屬於哪個 Topic。

RocketMQ 控制檯提供對死信訊息的查詢、匯出和重發的功能。

17.如何保證RocketMQ的高可用?

NameServer因為是無狀態,且不相互通訊的,所以只要叢集部署就可以保證高可用。

NameServer叢集

RocketMQ的高可用主要是在體現在Broker的讀和寫的高可用,Broker的高可用是通過叢集主從實現的。

Broker叢集、主從示意圖

Broker可以配置兩種角色:Master和Slave,Master角色的Broker支援讀和寫,Slave角色的Broker只支援讀,Master會向Slave同步訊息。

也就是說Producer只能向Master角色的Broker寫入訊息,Cosumer可以從Master和Slave角色的Broker讀取訊息。

Consumer 的配置檔案中,並不需要設定是從 Master 讀還是從 Slave讀,當 Master 不可用或者繁忙的時候, Consumer 的讀請求會被自動切換到從 Slave。有了自動切換 Consumer 這種機制,當一個 Master 角色的機器出現故障後,Consumer 仍然可以從 Slave 讀取訊息,不影響 Consumer 讀取訊息,這就實現了讀的高可用。

如何達到傳送端寫的高可用性呢?在建立 Topic 的時候,把 Topic 的多個Message Queue 建立在多個 Broker 組上(相同 Broker 名稱,不同 brokerId機器組成 Broker 組),這樣當 Broker 組的 Master 不可用後,其他組Master 仍然可用, Producer 仍然可以傳送訊息 RocketMQ 目前還不支援把Slave自動轉成 Master ,如果機器資源不足,需要把 Slave 轉成 Master ,則要手動停止 Slave 色的 Broker ,更改配置檔案,用新的配置檔案啟動 Broker。

原理

18.說一下RocketMQ的整體工作流程?

簡單來說,RocketMQ是一個分散式訊息佇列,也就是訊息佇列+分散式系統

作為訊息佇列,它是--的一個模型,對應的就是Producer、Broker、Cosumer;作為分散式系統,它要有服務端、客戶端、註冊中心,對應的就是Broker、Producer/Consumer、NameServer

所以我們看一下它主要的工作流程:RocketMQ由NameServer註冊中心叢集、Producer生產者叢集、Consumer消費者叢集和若干Broker(RocketMQ程式)組成:

  1. Broker在啟動的時候去向所有的NameServer註冊,並保持長連線,每30s傳送一次心跳
  2. Producer在傳送訊息的時候從NameServer獲取Broker伺服器地址,根據負載均衡演算法選擇一臺伺服器來傳送訊息
  3. Conusmer消費訊息的時候同樣從NameServer獲取Broker地址,然後主動拉取訊息來消費

RocketMQ整體工作流程

19.為什麼RocketMQ不使用Zookeeper作為註冊中心呢?

Kafka我們都知道採用Zookeeper作為註冊中心——當然也開始逐漸去Zookeeper,RocketMQ不使用Zookeeper其實主要可能從這幾方面來考慮:

  1. 基於可用性的考慮,根據CAP理論,同時最多隻能滿足兩個點,而Zookeeper滿足的是CP,也就是說Zookeeper並不能保證服務的可用性,Zookeeper在進行選舉的時候,整個選舉的時間太長,期間整個叢集都處於不可用的狀態,而這對於一個註冊中心來說肯定是不能接受的,作為服務發現來說就應該是為可用性而設計。
  2. 基於效能的考慮,NameServer本身的實現非常輕量,而且可以通過增加機器的方式水平擴充套件,增加叢集的抗壓能力,而Zookeeper的寫是不可擴充套件的,Zookeeper要解決這個問題只能通過劃分領域,劃分多個Zookeeper叢集來解決,首先操作起來太複雜,其次這樣還是又違反了CAP中的A的設計,導致服務之間是不連通的。
  3. 持久化的機制來帶的問題,ZooKeeper 的 ZAB 協議對每一個寫請求,會在每個 ZooKeeper 節點上保持寫一個事務日誌,同時再加上定期的將記憶體資料映象(Snapshot)到磁碟來保證資料的一致性和永續性,而對於一個簡單的服務發現的場景來說,這其實沒有太大的必要,這個實現方案太重了。而且本身儲存的資料應該是高度定製化的。
  4. 訊息傳送應該弱依賴註冊中心,而RocketMQ的設計理念也正是基於此,生產者在第一次傳送訊息的時候從NameServer獲取到Broker地址後快取到本地,如果NameServer整個叢集不可用,短時間內對於生產者和消費者並不會產生太大影響。

20.Broker是怎麼儲存資料的呢?

RocketMQ主要的儲存檔案包括CommitLog檔案、ConsumeQueue檔案、Indexfile檔案。
訊息儲存檔案

訊息儲存的整體的設計:

訊息儲存整體設計-來源官網

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

    CommitLog檔案儲存於${Rocket_Home}/store/commitlog目錄中,從圖中我們可以明顯看出來檔名的偏移量,每個檔案預設1G,寫滿後自動生成一個新的檔案。

    CommitLog

  • ConsumeQueue:訊息消費佇列,引入的目的主要是提高訊息消費的效能,由於RocketMQ是基於主題topic的訂閱模式,訊息消費是針對主題進行的,如果要遍歷commitlog檔案中根據topic檢索訊息是非常低效的。

    Consumer即可根據ConsumeQueue來查詢待消費的訊息。其中,ConsumeQueue(邏輯消費佇列)作為消費訊息的索引,儲存了指定Topic下的佇列訊息在CommitLog中的起始物理偏移量offset,訊息大小size和訊息Tag的HashCode值。

    ConsumeQueue檔案可以看成是基於Topic的CommitLog索引檔案,故ConsumeQueue資料夾的組織方式如下:topic/queue/file三層組織結構,具體儲存路徑為:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同樣ConsumeQueue檔案採取定長設計,每一個條目共20個位元組,分別為8位元組的CommitLog物理偏移量、4位元組的訊息長度、8位元組tag hashcode,單個檔案由30W個條目組成,可以像陣列一樣隨機訪問每一個條目,每個ConsumeQueue檔案大小約5.72M;

    Comsumer Queue

  • IndexFile:IndexFile(索引檔案)提供了一種可以通過key或時間區間來查詢訊息的方法。Index檔案的儲存位置是:\(HOME \store\index\){fileName},檔名fileName是以建立時的時間戳命名的,固定的單個IndexFile檔案大小約為400M,一個IndexFile可以儲存 2000W個索引,IndexFile的底層儲存設計為在檔案系統中實現HashMap結構,故RocketMQ的索引檔案其底層實現為hash索引。

    IndexFile檔案示意圖-來源參考[2]

總結一下:RocketMQ採用的是混合型的儲存結構,即為Broker單個例項下所有的佇列共用一個日誌資料檔案(即為CommitLog)來儲存。

RocketMQ的混合型儲存結構(多個Topic的訊息實體內容都儲存於一個CommitLog中)針對Producer和Consumer分別採用了資料和索引部分相分離的儲存結構,Producer傳送訊息至Broker端,然後Broker端使用同步或者非同步的方式對訊息刷盤持久化,儲存至CommitLog中。

只要訊息被刷盤持久化至磁碟檔案CommitLog中,那麼Producer傳送的訊息就不會丟失。正因為如此,Consumer也就肯定有機會去消費這條訊息。當無法拉取到訊息後,可以等下一次訊息拉取,同時服務端也支援長輪詢模式,如果一個訊息拉取請求未拉取到訊息,Broker允許等待30s的時間,只要這段時間內有新訊息到達,將直接返回給消費端。

這裡,RocketMQ的具體做法是,使用Broker端的後臺服務執行緒—ReputMessageService不停地分發請求並非同步構建ConsumeQueue(邏輯消費佇列)和IndexFile(索引檔案)資料。

總結

21.說說RocketMQ怎麼對檔案進行讀寫的?

RocketMQ對檔案的讀寫巧妙地利用了作業系統的一些高效檔案讀寫方式——PageCache順序讀寫零拷貝

  • PageCache、順序讀取

在RocketMQ中,ConsumeQueue邏輯消費佇列儲存的資料較少,並且是順序讀取,在page cache機制的預讀取作用下,Consume Queue檔案的讀效能幾乎接近讀記憶體,即使在有訊息堆積情況下也不會影響效能。而對於CommitLog訊息儲存的日誌資料檔案來說,讀取訊息內容時候會產生較多的隨機訪問讀取,嚴重影響效能。如果選擇合適的系統IO排程演算法,比如設定排程演算法為“Deadline”(此時塊儲存採用SSD的話),隨機讀的效能也會有所提升。

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

  • 零拷貝

另外,RocketMQ主要通過MappedByteBuffer對檔案進行讀寫操作。其中,利用了NIO中的FileChannel模型將磁碟上的物理檔案直接對映到使用者態的記憶體地址中(這種Mmap的方式減少了傳統IO,將磁碟檔案資料在作業系統核心地址空間的緩衝區,和使用者應用程式地址空間的緩衝區之間來回進行拷貝的效能開銷),將對檔案的操作轉化為直接對記憶體地址進行操作,從而極大地提高了檔案的讀寫效率(正因為需要使用記憶體對映機制,故RocketMQ的檔案儲存都使用定長結構來儲存,方便一次將整個檔案對映至記憶體)。

說說什麼是零拷貝?

在作業系統中,使用傳統的方式,資料需要經歷幾次拷貝,還要經歷使用者態/核心態切換。

傳統檔案傳輸示意圖-來源《圖解作業系統》

  1. 從磁碟複製資料到核心態記憶體;
  2. 從核心態記憶體複製到使用者態記憶體;
  3. 然後從使用者態記憶體複製到網路驅動的核心態記憶體;
  4. 最後是從網路驅動的核心態記憶體複製到網路卡中進行傳輸。

所以,可以通過零拷貝的方式,減少使用者態與核心態的上下文切換記憶體拷貝的次數,用來提升I/O的效能。零拷貝比較常見的實現方式是mmap,這種機制在Java中是通過MappedByteBuffer實現的。

mmap示意圖-來源《圖解作業系統》

22.訊息刷盤怎麼實現的呢?

RocketMQ提供了兩種刷盤策略:同步刷盤和非同步刷盤

  • 同步刷盤:在訊息達到Broker的記憶體之後,必須刷到commitLog日誌檔案中才算成功,然後返回Producer資料已經傳送成功。
  • 非同步刷盤:非同步刷盤是指訊息達到Broker記憶體後就返回Producer資料已經傳送成功,會喚醒一個執行緒去將資料持久化到CommitLog日誌檔案中。

Broker 在訊息的存取時直接操作的是記憶體(記憶體對映檔案),這可以提供系統的吞吐量,但是無法避免機器掉電時資料丟失,所以需要持久化到磁碟中。

刷盤的最終實現都是使用NIO中的 MappedByteBuffer.force() 將對映區的資料寫入到磁碟,如果是同步刷盤的話,在Broker把訊息寫到CommitLog對映區後,就會等待寫入完成。

非同步而言,只是喚醒對應的執行緒,不保證執行的時機,流程如圖所示。

非同步刷盤

22.能說下 RocketMQ 的負載均衡是如何實現的?

RocketMQ中的負載均衡都在Client端完成,具體來說的話,主要可以分為Producer端傳送訊息時候的負載均衡和Consumer端訂閱訊息的負載均衡。

Producer的負載均衡

Producer端在傳送訊息的時候,會先根據Topic找到指定的TopicPublishInfo,在獲取了TopicPublishInfo路由資訊後,RocketMQ的客戶端在預設方式下selectOneMessageQueue()方法會從TopicPublishInfo中的messageQueueList中選擇一個佇列(MessageQueue)進行傳送訊息。具這裡有一個sendLatencyFaultEnable開關變數,如果開啟,在隨機遞增取模的基礎上,再過濾掉not available的Broker代理。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-VuMGUv6B-1649246218316)(https://cdn.jsdelivr.net/gh/fighter3/picgo/img/20220405230011.png)]

所謂的"latencyFaultTolerance",是指對之前失敗的,按一定的時間做退避。例如,如果上次請求的latency超過550Lms,就退避3000Lms;超過1000L,就退避60000L;如果關閉,採用隨機遞增取模的方式選擇一個佇列(MessageQueue)來傳送訊息,latencyFaultTolerance機制是實現訊息傳送高可用的核心關鍵所在。

Consumer的負載均衡

在RocketMQ中,Consumer端的兩種消費模式(Push/Pull)都是基於拉模式來獲取訊息的,而在Push模式只是對pull模式的一種封裝,其本質實現為訊息拉取執行緒在從伺服器拉取到一批訊息後,然後提交到訊息消費執行緒池後,又“馬不停蹄”的繼續向伺服器再次嘗試拉取訊息。如果未拉取到訊息,則延遲一下又繼續拉取。在兩種基於拉模式的消費方式(Push/Pull)中,均需要Consumer端知道從Broker端的哪一個訊息佇列中去獲取訊息。因此,有必要在Consumer端來做負載均衡,即Broker端中多個MessageQueue分配給同一個ConsumerGroup中的哪些Consumer消費。

  1. Consumer端的心跳包傳送

在Consumer啟動後,它就會通過定時任務不斷地向RocketMQ叢集中的所有Broker例項傳送心跳包(其中包含了,訊息消費分組名稱、訂閱關係集合、訊息通訊模式和客戶端id的值等資訊)。Broker端在收到Consumer的心跳訊息後,會將它維護在ConsumerManager的本地快取變數—consumerTable,同時並將封裝後的客戶端網路通道資訊儲存在本地快取變數—channelInfoTable中,為之後做Consumer端的負載均衡提供可以依據的後設資料資訊。

  1. Consumer端實現負載均衡的核心類—RebalanceImpl

    在Consumer例項的啟動流程中的啟動MQClientInstance例項部分,會完成負載均衡服務執行緒—RebalanceService的啟動(每隔20s執行一次)。

    通過檢視原始碼可以發現,RebalanceService執行緒的run()方法最終呼叫的是RebalanceImpl類的rebalanceByTopic()方法,這個方法是實現Consumer端負載均衡的核心。

    rebalanceByTopic()方法會根據消費者通訊型別為“廣播模式”還是“叢集模式”做不同的邏輯處理。這裡主要來看下叢集模式下的主要處理流程:

img

(1) 從rebalanceImpl例項的本地快取變數—topicSubscribeInfoTable中,獲取該Topic主題下的訊息消費佇列集合(mqSet);

(2) 根據topic和consumerGroup為引數呼叫mQClientFactory.findConsumerIdList()方法向Broker端傳送通訊請求,獲取該消費組下消費者Id列表;

(3) 先對Topic下的訊息消費佇列、消費者Id排序,然後用訊息佇列分配策略演算法(預設為:訊息佇列的平均分配演算法),計算出待拉取的訊息佇列。這裡的平均分配演算法,類似於分頁的演算法,將所有MessageQueue排好序類似於記錄,將所有消費端Consumer排好序類似頁數,並求出每一頁需要包含的平均size和每個頁面記錄的範圍range,最後遍歷整個range而計算出當前Consumer端應該分配到的的MessageQueue。

Cosumer分配

(4) 然後,呼叫updateProcessQueueTableInRebalance()方法,具體的做法是,先將分配到的訊息佇列集合(mqSet)與processQueueTable做一個過濾比對。
負載均衡示意圖-來源官網

  • 上圖中processQueueTable標註的紅色部分,表示與分配到的訊息佇列集合mqSet互不包含。將這些佇列設定Dropped屬性為true,然後檢視這些佇列是否可以移除出processQueueTable快取變數,這裡具體執行removeUnnecessaryMessageQueue()方法,即每隔1s 檢視是否可以獲取當前消費處理佇列的鎖,拿到的話返回true。如果等待1s後,仍然拿不到當前消費處理佇列的鎖則返回false。如果返回true,則從processQueueTable快取變數中移除對應的Entry;
  • 上圖中processQueueTable的綠色部分,表示與分配到的訊息佇列集合mqSet的交集。判斷該ProcessQueue是否已經過期了,在Pull模式的不用管,如果是Push模式的,設定Dropped屬性為true,並且呼叫removeUnnecessaryMessageQueue()方法,像上面一樣嘗試移除Entry;
  • 最後,為過濾後的訊息佇列集合(mqSet)中的每個MessageQueue建立一個ProcessQueue物件並存入RebalanceImpl的processQueueTable佇列中(其中呼叫RebalanceImpl例項的computePullFromWhere(MessageQueue mq)方法獲取該MessageQueue物件的下一個進度消費值offset,隨後填充至接下來要建立的pullRequest物件屬性中),並建立拉取請求物件—pullRequest新增到拉取列表—pullRequestList中,最後執行dispatchPullRequest()方法,將Pull訊息的請求物件PullRequest依次放入PullMessageService服務執行緒的阻塞佇列pullRequestQueue中,待該服務執行緒取出後向Broker端發起Pull訊息的請求。其中,可以重點對比下,RebalancePushImpl和RebalancePullImpl兩個實現類的dispatchPullRequest()方法不同,RebalancePullImpl類裡面的該方法為空。

訊息消費佇列在同一消費組不同消費者之間的負載均衡,其核心設計理念是在一個訊息消費佇列在同一時間只允許被同一消費組內的一個消費者消費,一個訊息消費者能同時消費多個訊息佇列。

23.RocketMQ訊息長輪詢瞭解嗎?

所謂的長輪詢,就是Consumer 拉取訊息,如果對應的 Queue 如果沒有資料,Broker 不會立即返回,而是把 PullReuqest hold起來,等待 queue 有了訊息後,或者長輪詢阻塞時間到了,再重新處理該 queue 上的所有 PullRequest。

長輪詢簡單示意圖

  • PullMessageProcessor#processRequest

                    //如果沒有拉到資料
                    case ResponseCode.PULL_NOT_FOUND:
                        // broker 和 consumer 都允許 suspend,預設開啟
                        if (brokerAllowSuspend && hasSuspendFlag) {
                            long pollingTimeMills = suspendTimeoutMillisLong;
                            if (!this.brokerController.getBrokerConfig().isLongPollingEnable()) {
                                pollingTimeMills = this.brokerController.getBrokerConfig().getShortPollingTimeMills();
                            }
    
                            String topic = requestHeader.getTopic();
                            long offset = requestHeader.getQueueOffset();
                            int queueId = requestHeader.getQueueId();
                            //封裝一個PullRequest
                            PullRequest pullRequest = new PullRequest(request, channel, pollingTimeMills,
                                    this.brokerController.getMessageStore().now(), offset, subscriptionData, messageFilter);
                            //把PullRequest掛起來
                            this.brokerController.getPullRequestHoldService().suspendPullRequest(topic, queueId, pullRequest);
                            response = null;
                            break;
                        }
    

掛起的請求,有一個服務執行緒會不停地檢查,看queue中是否有資料,或者超時。

  • PullRequestHoldService#run()
    @Override
    public void run() {
        log.info("{} service started", this.getServiceName());
        while (!this.isStopped()) {
            try {
                if (this.brokerController.getBrokerConfig().isLongPollingEnable()) {
                    this.waitForRunning(5 * 1000);
                } else {
                    this.waitForRunning(this.brokerController.getBrokerConfig().getShortPollingTimeMills());
                }

                long beginLockTimestamp = this.systemClock.now();
                //檢查hold住的請求
                this.checkHoldRequest();
                long costTime = this.systemClock.now() - beginLockTimestamp;
                if (costTime > 5 * 1000) {
                    log.info("[NOTIFYME] check hold request cost {} ms.", costTime);
                }
            } catch (Throwable e) {
                log.warn(this.getServiceName() + " service has exception. ", e);
            }
        }

        log.info("{} service end", this.getServiceName());
    }


參考:

[1]. 《RocketMQ實戰與原理解析》

[2]. 《RocketMQ技術內幕》

[3]. 面試被問到RocketMq,我懵了

[4]. 艾小仙《我要進大廠》

[5]. http://dreamcat.ink/java-interview/docs/knows/classify/dis/RocketMQ/

[6]. 《淺入淺出》-RocketMQ

[7].十二張圖,踹開訊息佇列的大門

[8].mq的那些破事兒,你不好奇嗎?

[9]. 訊息冪等(去重)如何解決?來看看這個方案!

[10] .七萬字,151張圖,通宵整理訊息佇列核心知識點總結!這次徹底掌握MQ!

[11].極客時間 《訊息佇列高手課》

[12].RocketMQ官網


✨面渣逆襲系列

在這裡插入圖片描述

相關文章