高併發非同步解耦利器:RocketMQ究竟強在哪裡?

IT宅發表於2021-11-22

image-20211017192453356

上篇文章訊息佇列那麼多,為什麼建議深入瞭解下RabbitMQ?我們講到了訊息佇列的發展史:

image-20211006220855697

並且詳細介紹了RabbitMQ,其功能也是挺強大的,那麼,為啥又要搞一個RocketMQ出來呢?是重複造輪子嗎?本文我們就帶大家來詳細探討RocketMQ究竟好在哪裡。

RocketMQ是一個分散式訊息中介軟體,具有低延遲、高效能和可靠性、萬億級別的容量和靈活的可擴充套件性。它是阿里巴巴於2012年開源的第三代分散式訊息中介軟體。

隨著阿里巴巴的電商業務不斷髮展,需要一款更高效能的訊息中介軟體,RocketMQ就是這個業務背景的產物。RocketMQ是一個分散式訊息中介軟體,具有低延遲、高效能和可靠性、萬億級別的容量和靈活的可擴充套件性,它是阿里巴巴於2012年開源的第三代分散式訊息中介軟體。RocketMQ經歷了多年雙十一的洗禮,在可用性、可靠性以及穩定性等方面都有出色的表現。值得一提的是,RocketMQ最初就是借鑑了Kafka進行改造開發而來的,所以熟悉Kafka的朋友,會發現RocketMQ的原理和Kafka有很多相似之處。

RocketMQ前身叫做MetaQ,在MeataQ釋出3.0版本的時候改名為RocketMQ,其本質上的設計思路和Kafka類似,因為最初就是基於Kafka改造而來,經過不斷的迭代與版本升級,2016年11月21日,阿里巴巴向Apache軟體基金會捐贈了RocketMQ 。近年來被越來越多的國內企業使用。

本文帶大家從以下幾個方面詳細瞭解RocketMQ:

  • RocketMQ如何保證訊息儲存的可靠性?
  • RocketMQ如何保證訊息佇列服務的高可用?
  • 如何構建一個高可用的RocketMQ雙主雙從最小叢集?
  • RocketMQ訊息是如何儲存的?
  • RocketMQ是如何保證存取訊息的效率的?
  • 如何實現基於Message Key的高效查詢?
  • 如何實現基於Message Id的高效查詢?
  • RocketMQ的Topic在叢集中是如何儲存的?
  • Broker自動建立Topic會有什麼問題?
  • RocketMQ如何保證訊息投遞的順序性?
  • RocketMQ如何保證訊息消費的順序性?
  • 實現分散式事務的手段有哪些?
  • RocketMQ如何實現事務訊息?
  • RocketMQ事務訊息是如何儲存的?

1. RocketMQ技術架構

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

image-20211017212148402

  • Producer:訊息生產者,支援叢集方式部署;
  • Consumer:訊息消費者,支援叢集方式部署,支援pull,push模式獲取訊息進行消費,支援叢集和廣播方式消費;
  • NameServer:Topic路由註冊中心,類似於Dubbo中的zookeeper,支援Broker的動態註冊與發現;
    • 提供心跳檢測機制,檢查Broker是否存活;
    • 接收Broker叢集的註冊資訊,作為路由資訊的基本資料;
    • NameServier各個例項不相互進行通訊,每個NameServer都儲存了一份完整的路由資訊,這與zookeeper有所區別,不用作複雜的節點資料同步與選主過程;
  • BrokerServer:主要負責訊息的儲存、投遞和查詢,以及服務高可用保證。BrokerServer包含以下幾個重要的子模組:
    • Remoting Module:整個Broker的實體,負責處理來自clients端的請求;
    • Client Manager:負責管理客戶端(Producer/Consumer)和維護Consumer的Topic訂閱資訊;
    • StoreService:提供方便簡單的API介面處理訊息儲存到物理硬碟和查詢功能;
    • HA Service:高可用服務,提供Master Broker 和 Slave Broker之間的資料同步功能;
    • Index Service:根據特定的Message key對投遞到Broker的訊息進行索引服務,以提供訊息的快速查詢。

image-20211017212222438

2. RocketMQ執行原理

RocketMQ執行原理如下圖所示:

image-20211017212356716

  • 首先,啟動每個NameServer節點,共同構成一個NameServer Cluster。NameServer啟動後,監聽埠,等待Broker、Producer、Consumer的連線;
  • 然後啟動Broker的主從節點,這個時候Broker會與所有的NameServer建立並保持長連線,定時傳送心跳包,把自己的資訊(IP+埠號)以及儲存的所有Topic資訊註冊到每個NameServer中。這樣NameServer叢集中就有Topic和Broker的對映關係了;
  • 收發訊息前,先建立Topic,建立Topic時需要指定該Topic要儲存在哪些Broker上,也可以在傳送訊息時自動建立Topic,每個Topic預設會分配4個Queue;
  • 啟動生產者,這個時候生產者會把資訊註冊到NameServer中,並且從NameServer獲取Broker伺服器,Queue等資訊;
  • 啟動消費者,這個時候消費者會把資訊註冊到NameServer中,並且從NameServer獲取Broker伺服器,Queue等資訊;
  • 生產者傳送訊息到Broker叢集中的時候,會從所有的Master節點的對應Topic中選擇一個Queue,然後與Queue所在的Broker建立長連線從而向Broker投遞訊息。訊息實際上是儲存在了CommitLog檔案中,而Queue檔案裡面儲存的實際是訊息在CommitLog中的儲存位置資訊;
  • 消費者從Broker叢集中消費訊息的時候,會通過特定的負載均衡演算法,繫結一個訊息佇列進行消費;
  • 消費者會定時(或者kill階段)把Queue的消費進度offset提交到Broker的consumerOffset.json檔案中記錄起來;
  • 主節點和從節點之間可以是同步或者非同步的進行資料複製,相關配置引數:
    • brokerRole,可選值:
      • ASYNC_MASTER:非同步複製方式(非同步雙寫),生產者寫入訊息到Master之後,無需等到訊息複製到Slave即可返回,訊息的複製由旁路執行緒進行非同步複製;
      • SYNC_MASTER:同步複製方式(同步雙寫),生產者寫入訊息到Master之後,需要等到Slave複製成功才可以返回。如果有多個Slave,只需要有一個Slave複製成功,併成功應答,就算複製成功了。這裡是否持久化到磁碟依賴於另一個引數:flushDiskType
      • SLAVE:從節點

3. RocketMQ叢集

本節我們來看看一個雙主雙從的RocketMQ是如何搭建的。

叢集配置引數說明:

在討論叢集前,我們需要了解兩個關鍵的叢集配置引數:brokerRoleflushDiskType。brokerRole在前一節已經介紹了,而flushDiskType則是刷盤方式的配置,主要有:

  • ASYNC_FLUSH: 非同步刷盤
  • SYNC_FLUSH: 同步刷盤

3.1 如何保證訊息儲存的可靠性?

brokerRole確定了主從同步是非同步的還是同步的,flushDiskType確定了資料刷盤的方式是同步的還是非同步的。

如果業務場景對訊息丟失容忍度很低,可以採用SYNC_MASTER + ASYNC_FLUSH的方式,這樣只有master和slave在刷盤前同時掛掉,訊息才會丟失,也就是說即使有一臺機器出故障,仍然能保證資料不丟

如果業務場景對訊息丟失容忍度比較高,則可以採用ASYNC_MASTER + ASYNC_FLUSH的方式,這樣可以儘可能的提高訊息的吞吐量。

3.2 如何保證訊息佇列服務的高可用?

消費端的高可用

Master Broker支援讀和寫,Slave Broker只支援讀。

當Master不可用的時候,Consumer會自動切換到Slave進行讀,也就是說,當Master節點的機器出現故障後,Consumer仍然可以從Slave節點讀取訊息,不影響消費端的消費程式。

生產端的高可用

叢集配置引數說明:

  • brokerName: broker的名稱,需要把Master和Slave節點配置成相同的名稱,表示他們的主從關係,相同的brokerName的一組broker,組成一個broker組;
  • brokerId: broker的id,0表示Master節點的id,大於0表示Slave節點的id。

在RocketMQ中,機器的主從節點關係是提前配置好的,沒有類似Kafka的Master動態選主功能。

如果一個Master當機了,要讓生產端程式繼續可以生產訊息,您需要部署多個Master節點,組成多個broker組。這樣在建立Topic的時候,就可以把Topic的不同訊息佇列分佈在多個broker組中,即使某一個broker組的Master節點不可用了,其他組的Master節點仍然可用,保證了Producer可以繼續傳送訊息。

3.3 如何構建一個高可用的RocketMQ雙主雙從最小叢集?

為了儘可能的保證訊息不丟失,並且保證生產者和消費者的可用性,我們可以構建一個雙主雙從的叢集,搭建的架構圖如下所示:

image-20211017212427244

部署架構說明:

  • 兩個Broker組,保證了其中一個Broker組的Master節點掛掉之後,另一個Master節點仍然可以接受某一個Topic的訊息投遞;
  • 主從同步採用SYNC_MASTER,保證了生產者寫入訊息到Master之後,需要等到Slave也複製成功,才返回訊息投遞成功。這樣即使主節點或者從節點掛掉了,也不會導致丟資料;
  • 由於主節點有了從節點做備份,所以,落盤策略可以使用ASYNC_FLUSH,從而儘可能的提高訊息的吞吐量;
  • 如果只提供兩臺伺服器,要部署這個叢集的情況下,可以把Broker Master1和Broker Slave2部署在一臺機器,Broker Master2和Broker Slave1部署在一臺機器。

關鍵配置引數

以下是關鍵的配置引數:

Broker Master1

# NameServer地址
namesrvAddr=192.168.1.100:9876;192.168.1.101:9876
# 叢集名稱
brokerClusterName=itzhai-com-cluster
# brokerIP地址
brokerIP1=192.168.1.100
# broker通訊埠
listenPort=10911
# broker名稱
brokerName=broker‐1
# 0表示主節點
brokerId=0
# 2點進行訊息刪除
deleteWhen=02
# 訊息在磁碟上保留48小時
fileReservedTime=48
# 主從同步複製
brokerRole=SYNC_MASTER
# 非同步刷盤
flushDiskType=ASYNC_FLUSH
# 自動建立Topic
autoCreateTopicEnable=true
# 訊息儲存根目錄
storePathRootDir=/data/rocketmq/store‐m

Broker Slave1

# NameServer地址
namesrvAddr=192.168.1.100:9876;192.168.1.101:9876
# 叢集名稱
brokerClusterName=itzhai-com-cluster
# brokerIP地址
brokerIP1=192.168.1.101
# broker通訊埠
listenPort=10911
# broker名稱
brokerName=broker‐1 
# 非0表示從節點
brokerId=1
# 2點進行訊息刪除
deleteWhen=02
# 訊息在磁碟上保留48小時
fileReservedTime=48
# 從節點
brokerRole=SLAVE
# 非同步刷盤
flushDiskType=ASYNC_FLUSH
# 自動建立Topic
autoCreateTopicEnable=true 
# 訊息儲存根目錄
storePathRootDir=/data/rocketmq/store‐s

Broker Master2

# NameServer地址
namesrvAddr=192.168.1.100:9876;192.168.1.101:9876
# 叢集名稱
brokerClusterName=itzhai-com-cluster
# brokerIP地址
brokerIP1=192.168.1.102
# broker通訊埠
listenPort=10911
# broker名稱
brokerName=broker‐2
# 0表示主節點
brokerId=0
# 2點進行訊息刪除
deleteWhen=02
# 訊息在磁碟上保留48小時
fileReservedTime=48
# 主從同步複製
brokerRole=SYNC_MASTER
# 非同步刷盤
flushDiskType=ASYNC_FLUSH
# 自動建立Topic
autoCreateTopicEnable=true
# 訊息儲存根目錄
storePathRootDir=/data/rocketmq/store‐m

Broker Slave2

# NameServer地址
namesrvAddr=192.168.1.100:9876;192.168.1.101:9876
# 叢集名稱
brokerClusterName=itzhai-com-cluster
# brokerIP地址
brokerIP1=192.168.1.103
# broker通訊埠
listenPort=10911
# broker名稱
brokerName=broker‐2
# 非0表示從節點
brokerId=1
# 2點進行訊息刪除
deleteWhen=02
# 訊息在磁碟上保留48小時
fileReservedTime=48
# 從節點
brokerRole=SLAVE
# 非同步刷盤
flushDiskType=ASYNC_FLUSH
# 自動建立Topic
autoCreateTopicEnable=true
# 訊息儲存根目錄
storePathRootDir=/data/rocketmq/store‐s

寫了那麼多頂層架構圖,不寫寫底層內幕,就不是IT宅(itzhai.com)的文章風格,接下來,我們就來看看底層儲存架構。

4. RocketMQ儲存架構

我們在broker.conf檔案中配置了訊息儲存的根目錄:

# 訊息儲存根目錄
storePathRootDir=/data/rocketmq/store‐m

進入這個目錄,我們可以發現如下的目錄結構:

image-20211017212520062

其中:

  • abort:該檔案在broker啟動時建立,關閉時刪除,如果broker異常退出,則檔案會存在,在下次啟動時會走修復流程;
  • checkpoint:檢查點,主要存放以下內容:
    • physicMsgTimestamp:commitlog檔案最後一次落盤時間;
    • logicsMsgTimestamp:consumequeue最後一次落盤時間;
    • indexMsgTimestamp:索引檔案最後一次落盤時間;
  • commitlog:存放訊息的完整內容,所有的topic訊息都會通過檔案追加的形式寫入到該檔案中;
  • config:訊息佇列的配置檔案,包括了topic配置,消費的偏移量等資訊。其中consumerOffset.json檔案存放訊息佇列消費的進度;
  • consumequeue:topic的邏輯佇列,在訊息存放到commitlog之後,會把訊息的存放位置記錄到這裡,只有記錄到這裡的訊息,才能被消費者消費;
  • index:訊息索引檔案,通過Message Key查詢訊息時,是通過該檔案進行檢索查詢的。

4.1 RocketMQ訊息是如何儲存的

下面我們來看看關鍵的commitlog以及consumequeue:

image-20211017212554757

訊息投遞到Broker之後,是先把實際的訊息內容存放到CommitLog中的,然後再把訊息寫入到對應主題的ConsumeQueue中。其中:

CommitLog訊息的物理儲存檔案,儲存實際的訊息內容。每個Broker上面的CommitLog被該Broker上所有的ConsumeQueue共享。

單個檔案大小預設為1G,檔名長度為20位,左邊補零,剩餘為起始偏移量。預分配好空間,訊息順序寫入日誌檔案。當檔案滿了,則寫入下一個檔案,下一個檔案的檔名基於檔案第一條訊息的偏移量進行命名;

ConsumeQueue訊息的邏輯佇列,相當於CommitLog的索引檔案。RocketMQ是基於Topic主題訂閱模式實現的,每個Topic下會建立若干個邏輯上的訊息佇列ConsumeQueue,在訊息寫入到CommitLog之後,通過Broker的後臺服務執行緒(ReputMessageService)不停地分發請求並非同步構建ConsumeQueue和IndexFile(索引檔案,後面介紹),然後把每個ConsumeQueue需要的訊息記錄到各個ConsumeQueue中

image-20211017212636906

ConsumeQueue主要記錄8個位元組的commitLogOffset(訊息在CommitLog中的物理偏移量), 4個位元組的msgSize(訊息大小), 8個位元組的TagHashcode,每個元素固定20個位元組。

image-20211017212701949

ConsumeQueue相當於CommitLog檔案的索引,可以通過ConsumeQueue快速從很大的CommitLog檔案中快速定位到需要的訊息。

ConsumeQueue的儲存結構

主題訊息佇列:在consumequeue目錄下,按照topic的維度儲存訊息佇列。

重試訊息佇列:如果topic中的訊息消費失敗,則會把訊息發到重試佇列,重新佇列按照消費端的GroupName來分組,命名規則:%RETRY%ConsumerGroupName

死信訊息佇列:如果topic中的訊息消費失敗,並且超過了指定重試次數之後,則會把訊息發到死信佇列,死信佇列按照消費端的GroupName來分組,命名規則:%DLQ%ConsumerGroupName

假設我們現在有一個topic:itzhai-test,消費分組:itzhai_consumer_group,當訊息消費失敗之後,我們檢視consumequeue目錄,會發現多處了一個重試佇列:

image-20211017212858807

我們可以在RocketMQ的控制檯看到這個重試訊息佇列的主題和訊息:

image-20210919111252088

image-20211017113351723

如果一直重試失敗,達到一定次數之後(預設是16次,重試時間:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h),就會把訊息投遞到死信佇列:

image-20211017212936328

4.2 RocketMQ是如何保證存取訊息的效率的

4.2.1 如何保證高效寫

每條訊息的長度是不固定的,為了提高寫入的效率,RocketMQ預先分配好1G空間的CommitLog檔案,採用順序寫的方式寫入訊息,大大的提高寫入的速度。

RocketMQ中訊息刷盤主要可以分為同步刷盤和非同步刷盤兩種,通過flushDiskType引數進行配置。如果需要提高寫訊息的效率,降低延遲,提高MQ的效能和吞吐量,並且不要求訊息資料儲存的高可靠性,可以把刷盤策略設定為非同步刷盤。

4.2.2 如何保證高效讀

為了提高讀取的效率,RocketMQ使用ConsumeQueue作為消費訊息的索引,使用IndexFile作為基於訊息key的查詢的索引。下面來詳細介紹下。

4.2.2.1 ConsumeQueue

讀取訊息是隨機讀的,為此,RocketMQ專門建立了ConsumeQueue索引檔案,每次先從ConsumeQueue中獲取需要的訊息的地址,訊息大小,然後從CommitLog檔案中根據地址直接讀取訊息內容。在讀取訊息內容的過程中,也儘量利用到了作業系統的頁快取機制,進一步加速讀取速度。

ConsumeQueue由於每個元素大小是固定的,因此可以像訪問陣列一樣訪問每個訊息元素。並且佔用空間很小,大部分的ConsumeQueue能夠被全部載入記憶體,所以這個索引查詢的速度很快。每個ConsumeQueue檔案由30w個元素組成,佔用空間在6M以內。每個檔案預設大小為600萬個位元組,當一個ConsumeQueue型別的檔案寫滿之後,則寫入下一個檔案。

4.2.2.2 IndexFile為什麼按照Message Key查詢效率高?

我們在RocketMQ的store目錄中可以發現有一個index目錄,這個是一個用於輔助提高查詢訊息效率的索引檔案。通過該索引檔案實現基於訊息key來查詢訊息的功能

物理儲存結構

IndexFile索引檔案物理儲存結構如下圖所示:

image-20211017213017099

  • Header:索引標頭檔案,40 bytes,包含以下資訊:
    • beginTimestamp:索引檔案中第一個索引訊息存入Broker的時間戳;
    • endTimestamp:索引檔案中最後一個索引訊息存入Broker的時間戳
    • beginPHYOffset:索引檔案中第一個索引訊息在CommitLog中的偏移量;
    • endPhyOffset:索引檔案中最後一個索引訊息在CommitLog中的偏移量;
    • hashSlotCount:構建索引使用的slot數量;
    • indexCount:索引的總數;
  • Slot Table:槽位表,類似於Redis的Slot,或者雜湊表的key,使用訊息的key的hashcode與slotNum取模可以得到具體的槽的位置。每個槽位佔4 bytes,一個IndexFile可以儲存500w個slot;
  • Index Linked List:訊息的索引內容,如果雜湊取模後發生槽位碰撞,則構建成連結串列,一個IndexFile可以儲存2000w個索引:
    • Key Hash:訊息的雜湊值;
    • Commit Log Offset:訊息在CommitLog中的偏移量;
    • Timestamp:訊息儲存的時間戳;
    • Next Index Offset:下一個索引的位置,如果訊息取模後發生槽位槽位碰撞,則通過此欄位把碰撞的訊息構成連結串列。

每個IndexFile檔案的大小:40b + 4b * 5000000 + 20b * 20000000 = 420000040b,約為400M。

邏輯儲存結構

IndexFile索引檔案的邏輯儲存結構如下圖所示:

image-20211017213111748

IndexFile邏輯上是基於雜湊表來實現的,Slot Table為雜湊鍵,Index Linked List中儲存的為雜湊值。

4.2.2.3 為什麼按照MessageId查詢效率高?

RocketMQ中的MessageId的長度總共有16位元組,其中包含了:訊息儲存主機地址(IP地址和埠),訊息Commit Log offset。

按照MessageId查詢訊息的流程:Client端從MessageId中解析出Broker的地址(IP地址和埠)和Commit Log的偏移地址後封裝成一個RPC請求後通過Remoting通訊層傳送(業務請求碼:VIEW_MESSAGE_BY_ID)。Broker端走的是QueryMessageProcessor,讀取訊息的過程用其中的 commitLog offset 和 size 去 commitLog 中找到真正的記錄並解析成一個完整的訊息返回

4.3 RocketMQ叢集是如何做資料分割槽的?

我們繼續看看在叢集模式下,RocketMQ的Topic資料是如何做分割槽的。IT宅(itzhai.com)提醒大家,實踐出真知。這裡我們部署兩個Master節點:

image-20211017113659072

4.3.1 RocketMQ的Topic在叢集中是如何儲存的

我們通過手動配置每個Broker中的Topic,以及ConsumeQueue數量,來實現Topic的資料分片,如,我們到叢集中手動配置這樣的Topic:

  • broker-a建立itzhai-com-test-1,4個佇列;
  • broker-b建立itzhai-com-test-1,2個佇列。

建立完成之後,Topic分片叢集分佈如下:

image-20211017182449434

即:

image-20211017182628084

可以發現,RocketMQ是把Topic分片儲存到各個Broker節點中,然後在把Broker節點中的Topic繼續分片為若干等分的ConsumeQueue,從而提高訊息的吞吐量。ConsumeQueue是作為負載均衡資源分配的基本單元

這樣把Topic的訊息分割槽到了不同的Broker上,從而增加了訊息佇列的數量,從而能夠支援更塊的併發消費速度(只要有足夠的消費者)。

4.3.2 Broker自動建立Topic會有什麼問題?

假設設定為通過Broker自動建立Topic(autoCreateTopicEnable=true),並且Producer端設定Topic訊息佇列數量設定為4,也就是預設值:

producer.setDefaultTopicQueueNums(4);

嘗試往一個新的 topic itzhai-test-queue-1連續傳送10條訊息,傳送完畢之後,檢視Topic狀態:

image-20211017114900279

我們可以發現,在兩個broker上面都建立了itzhai-test-queue-a,並且每個broker上的訊息佇列數量都為4。怎麼回事,我配置的明明是期望建立4個佇列,為什麼加起來會變成了8個?如下圖所示:

image-20211017121546902

由於時間關係,本文我們不會帶大家從原始碼方面去解讀為啥會出現這種情況,接下來我們通過一種更加直觀的方式來驗證下這個問題:繼續做實驗。

我們繼續嘗試往一個新的 topic itzhai-test-queue-10傳送1條訊息,注意,這一次不做併發傳送了,只傳送一條,傳送完畢之後,檢視Topic狀態:

image-20211017183414630

可以發現,這次建立的訊息佇列數量又是對的了,並且都是在broker-a上面建立的。接下來,無論怎麼併發傳送訊息,訊息佇列的數量都不會繼續增加了。

其實這也是併發請求Broker,觸發自動建立Topic的bug。

為了更加嚴格的管理Topic的建立和分片配置,一般在生產環境都是配置為手動建立Topic,通過提交運維工單申請建立Topic以及Topic的資料分配。

接下來我們來看看RocketMQ的特性。更多其他技術的底層架構內幕分析,請訪問我的部落格IT宅(itzhai.com)或者關注Java架構雜談公眾號。

5. RocketMQ特性

5.1 生產端

5.1.1 訊息釋出

RocketMQ中定義瞭如下三種訊息通訊的方式:

public enum CommunicationMode {
    SYNC,
    ASYNC,
    ONEWAY,
}
  • SYNC:同步傳送,生產端會阻塞等待傳送結果;
    • 應用場景:這種方式應用場景非常廣泛,如重要業務事件通知。
  • ASYNC:非同步傳送,生產端呼叫傳送API之後,立刻返回,在拿到Broker的響應結果後,觸發對應的SendCallback回撥;
    • 應用場景:一般用於鏈路耗時較長,對 RT 較為敏感的業務場景;
  • ONEWAY:單向傳送,傳送方只負責傳送訊息,不等待伺服器回應且沒有回撥函式觸發,即只傳送請求不等待應答。 此方式傳送訊息的過程耗時非常短,一般在微秒級別;
    • 應用場景:適用於耗時非常短,對可靠性要求不高的場景,如日誌收集。

SYNC和ASYNC關注傳送結果,ONEWAY不關注傳送結果。傳送結果如下:

public enum SendStatus {
    SEND_OK,
    FLUSH_DISK_TIMEOUT,
    FLUSH_SLAVE_TIMEOUT,
    SLAVE_NOT_AVAILABLE,
}
  • SEND_OK:訊息傳送成功。SEND_OK並不意味著投遞是可靠的,要確保訊息不丟失,需要開啟SYNC_MASTER同步或者SYNC_FLUSH同步寫;
  • FLUSH_DISK_TIMEOUT:訊息傳送成功,但是刷盤超時。如果Broker的flushDiskType=SYNC_FLUSH,並且5秒內沒有完成訊息的刷盤,則會返回這個狀態;
  • FLUSH_SLAVE_TIMEOUT:訊息傳送成功,但是伺服器同步到Slave時超時。如果Broker的brokerRole=SYNC_MASTER,並且5秒內沒有完成同步,則會返回這個狀態;
  • SLAVE_NOT_AVAILABLE:訊息傳送成功,但是無可用的Slave節點。如果Broker的brokerRole=SYNC_MASTER,但是沒有發現SLAVE節點或者SLAVE節點掛掉了,那麼會返回這個狀態。

原始碼內容更精彩,歡迎大家進一步閱讀原始碼詳細瞭解訊息傳送的內幕:

  • 同步傳送:org.apache.rocketmq.client.producer.DefaultMQProducer#send(org.apache.rocketmq.common.message.Message)
  • 非同步傳送:org.apache.rocketmq.client.producer.DefaultMQProducer#send(org.apache.rocketmq.common.message.Message, org.apache.rocketmq.client.producer.SendCallback)
  • 單向傳送:org.apache.rocketmq.client.producer.DefaultMQProducer#sendOneway(org.apache.rocketmq.common.message.Message)

5.1.2 順序消費

訊息的有序性指的是一類訊息消費的時候,可以按照傳送順序來消費,比如:在Java架構雜談茶餐廳吃飯產生的訊息:進入餐廳、點餐、下單、上菜、付款,訊息要按照這個順序消費才有意義,但是多個顧客產生的訊息是可以並行消費的。順序消費又分為全域性順序消費和分割槽順序消費:

  • 全域性順序:同一個Topic下的訊息,所有訊息按照嚴格的FIFO順序進行釋出和消費。適用於:效能要求不高,所有訊息嚴格按照FIFO進行釋出和消費的場景;
  • 分割槽順序:同一個Topic下,根據訊息的特定業務ID進行sharding key分割槽,同一個分割槽內的訊息按照嚴格的FIFO順序進行釋出和消費。適用於:效能要求高,在同一個分割槽中嚴格按照FIFO進行釋出和消費的場景。

一般情況下,生產者是會以輪訓的方式把訊息傳送到Topic的訊息佇列中的:

image-20211017213242909

在同一個Queue裡面,訊息的順序性是可以得到保證的,但是如果一個Topic有多個Queue,以輪訓的方式投遞訊息,那麼就會導致訊息亂序了。

為了保證訊息的順序性,需要把保持順序性的訊息投遞到同一個Queue中。

5.1.2.1 如何保證訊息投遞的順序性

RocketMQ提供了MessageQueueSelector介面,可以用來實現自定義的選擇投遞的訊息佇列的演算法:

for (int i = 0; i < orderList.size(); i++) {
    String content = "Hello itzhai.com. Java架構雜談," + new Date();
    Message msg = new Message("topic-itzhai-com", tags[i % tags.length], "KEY" + i,
            content.getBytes(RemotingHelper.DEFAULT_CHARSET));
    SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
        @Override
        public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
            Long orderId = (Long) arg;
            // 訂單號與訊息佇列個數取模,保證讓同一個訂單號的訊息落入同一個訊息佇列
            long index = orderId % mqs.size();
            return mqs.get((int) index);
        }
    }, orderList.get(i).getOrderId());
    System.out.printf("content: %s, sendResult: %s%n", content, sendResult);
}

如上圖,我們實現了MessageQueueSelector介面,並在實現的select方法裡面,指定了選擇訊息佇列的演算法:訂單號與訊息佇列個數取模,保證讓同一個訂單號的訊息落入同一個訊息佇列

image-20211017213318790

有個異常場景需要考慮:假設某一個Master節點掛掉了,導致Topic的訊息佇列數量發生了變化,那麼繼續使用以上的選擇演算法,就會導致在這個過程中同一個訂單的訊息會分散到不同的訊息佇列裡面,最終導致訊息不能順序消費。

為了避免這種情況,只能選擇犧牲failover特性了。

現在投遞到訊息佇列中的訊息保證了順序,那如何保證消費也是順序的呢?

5.1.2.2 如何保證訊息消費的順序性?

RocketMQ中提供了MessageListenerOrderly,該物件用於有順序收非同步傳遞的訊息,一個佇列對應一個消費執行緒,使用方法如下:

consumer.registerMessageListener(new MessageListenerOrderly() {
    // 消費次數,用於輔助模擬各種消費結果
    AtomicLong consumeTimes = new AtomicLong(0);

    @Override
    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
        context.setAutoCommit(true);
        System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
        this.consumeTimes.incrementAndGet();
        if ((this.consumeTimes.get() % 2) == 0) {
            return ConsumeOrderlyStatus.SUCCESS;
        } else if ((this.consumeTimes.get() % 3) == 0) {
            return ConsumeOrderlyStatus.ROLLBACK;
        } else if ((this.consumeTimes.get() % 4) == 0) {
            return ConsumeOrderlyStatus.COMMIT;
        } else if ((this.consumeTimes.get() % 5) == 0) {
            context.setSuspendCurrentQueueTimeMillis(3000);
            return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
        }

        return ConsumeOrderlyStatus.SUCCESS;
    }
});

如果您使用的是MessageListenerConcurrently,表示併發消費,為了保證訊息消費的順序性,需要設定為單執行緒模式。

使用MessageListenerOrderly的問題:如果遇到某條訊息消費失敗,並且無法跳過,那麼訊息佇列的消費進度就會停滯。

5.1.3 延遲佇列(定時訊息)

定時消費是指訊息傳送到Broker之後不會立即被消費,而是等待特定的時間之後才投遞到Topic中。定時訊息會暫存在名為SCHEDULE_TOPIC_XXXX的topic中,並根據delayTimeLevel存入特定的queue,queueId=delayTimeLevel-1,一個queue只存相同延遲的訊息,保證具有相同延遲的訊息能夠順序消費。比如,我們設定1秒後把訊息投遞到topic-itzhai-comtopic,則儲存的檔案目錄如下所示:

image-20211017213559746

Broker會排程地消費SCHEDULE_TOPIC_XXXX,將訊息寫入真實的topic。

定時訊息的副作用:定時訊息會在第一次寫入Topic和排程寫入實際的topic都會進行計數,因此傳送數量,tps都會變高。

使用延遲佇列的場景:提交了訂單之後,如果等待超過約定的時間還未支付,則把訂單設定為超時狀態。

RocketMQ提供了以下幾個固定的延遲級別:

public class MessageStoreConfig {
    ...
    // 10個level,level:1~18
    private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
    ...
}

level = 0 表示不使用延遲訊息。

另外,訊息消費失敗也會進入延遲佇列,訊息傳送時間與設定的延遲級別和重試次數有關

以下是傳送延遲訊息的程式碼:

public class ScheduledMessageProducer {

    public static void main(String[] args) throws Exception {
        DefaultMQProducer producer = new DefaultMQProducer("TestProducerGroup");
        producer.start();
        int totalMessagesToSend = 100;
        for (int i = 0; i < totalMessagesToSend; i++) {
            Message message = new Message("TestTopic", ("Hello scheduled message " + i).getBytes());
            // 指定該訊息在10秒後被消費者消費
            message.setDelayTimeLevel(3);
            producer.send(message);
        }
        producer.shutdown();
    }
}

5.1.4 資料完整性與事務訊息

通過訊息對系統進行解耦之後,勢必會遇到分散式系統資料完整性的問題。

5.1.4.1 實現分散式事務的手段有哪些?

我們可以通過以下手段解決分散式系統資料最終一致性問題:

  • 資料庫層面的2PC(Two-phase commit protocol),二階段提交,同步阻塞,效率低下,存在協調者單點故障問題,極端情況下存在資料不一致的風險。對應技術上的XA、JTA/JTS。這是分散式環境下事務處理的典型模式;
  • 資料庫層面的3PC,三階段提交,引入了參與者超時機制,增加了預提交階段,使得故障恢復之後協調者的決策複雜度降低,但整體的互動過程變得更長了,效能有所下降,仍舊會存在資料不一致的問題;
  • 業務層面的TCC ,Try - Confirm - Cancel。對業務的侵入較大,和業務緊耦合,對於每一個操作都需要定義三個動作分別對應:Try - Confirm - Cancel,將資源層的兩階段提交協議轉換到業務層,成為業務模型中的一部分;
  • 本地訊息表;
  • 事務訊息;

RocketMQ事務訊息(Transactional Message)則是通過事務訊息來實現分散式事務的最終一致性。下面看看RocketMQ是如何實現事務訊息的。

5.1.4.2 RocketMQ如何實現事務訊息?

如下圖:

image-20211017213817767

事務訊息有兩個流程:

  1. 事務訊息傳送及提交:
    1. 傳送half訊息;
    2. 服務端響應half訊息寫入結果;
    3. 根據half訊息的傳送結果執行本地事務。如果傳送失敗,此時half訊息對業務不可見,本地事務不執行;
    4. 根據本地事務狀態執行Commit或者Rollback。Commit操作會觸發生成ConsumeQueue索引,此時訊息對消費者可見
  2. 補償流程:
    5. 對於沒有Commit/Rollback的事務訊息,會處於pending狀態,這對這些訊息,MQ Server發起一次回查;
    6. Producer收到回查訊息,檢查回查訊息對應的本地事務的轉塔體;
    7. 根據本地事務狀態,重新執行Commit或者Rollback。

補償階段主要用於解決訊息的Commit或者Rollback發生超時或者失敗的情況。

half訊息:並不是傳送了一半的訊息,而是指訊息已經傳送到了MQ Server,但是該訊息未收到生產者的二次確認,此時該訊息暫時不能投遞到具體的ConsumeQueue中,這種狀態的訊息稱為half訊息。

5.1.4.3 RocketMQ事務訊息是如何儲存的?

傳送到MQ Server的half訊息對消費者是不可見的,為此,RocketMQ會先把half訊息的Topic和Queue資訊儲存到訊息的屬性中,然後把該half訊息投遞到一個專門的處理事務訊息的佇列中:RMQ_SYS_TRANS_HALF_TOPIC,由於消費者沒有訂閱該Topic,所以無法訊息half型別的訊息。

image-20211017213932431

生產者執行Commit half訊息的時候,會儲存一條專門的Op訊息,用於標識事務訊息已確定的狀態,如果一條事務訊息還沒有對應的Op訊息,說明這個事務的狀態還無法確定。RocketMQ會開啟一個定時任務,對於pending狀態的訊息,會先向生產者傳送回查事務狀態請求,根據事務狀態來決定是否提交或者回滾訊息。

當訊息被標記為Commit狀態之後,會把half訊息的Topic和Queue相關屬性還原為原來的值,最終構建實際的消費索引(ConsumeQueue)。

RocketMQ並不會無休止的嘗試訊息事務狀態回查,預設查詢15次,超過了15次還是無法獲取事務狀態,RocketMQ預設回滾該訊息。並列印錯誤日誌,可以通過重寫AbstractTransactionalMessageCheckListener類修改這個行為。

可以通過Broker的配置引數:transactionCheckMax來修改此值。

5.1.5 訊息重投

如果訊息釋出方式是同步傳送會重投,如果是非同步傳送會重試。

訊息重投可以儘可能保證訊息投遞成功,但是可能會造成訊息重複。

什麼情況會造成重複消費訊息?

  • 出現訊息量大,網路抖動的時候;
  • 生產者主動重發;
  • 消費負載發生變化。

可以使用的訊息重試策略:

  • retryTimesWhenSendFailed:設定同步傳送失敗的重投次數,預設為2。所以生產者最多會嘗試傳送retryTimesWhenSendFailed+1次。
    • 為了最大程度保證訊息不丟失,重投的時候會嘗試向其他broker傳送訊息;
    • 超過重投次數,丟擲異常,讓客戶端自行處理;
    • 觸發重投的異常:RemotingException、MQClientException和部分MQBrokerException;
  • retryTimesWhenSendAsyncFailed:設定非同步傳送失敗重試次數,非同步重試不會選擇其他Broker,不保證訊息不丟失;
  • retryAnotherBrokerWhenNotStoreOK:訊息刷盤(主或備)超時或slave不可用(返回狀態非SEND_OK),是否嘗試傳送到其他broker,預設false。重要的訊息可以開啟此選項。

oneway釋出方式不支援重投。

5.1.6 批量訊息

為了提高系統的吞吐量,提高傳送效率,可以使用批量傳送訊息。

批量傳送訊息的限制:

  • 同一批批量訊息的topic,waitStoreMsgOK屬性必須保持一致;
  • 批量訊息不支援延遲佇列;
  • 批量訊息一次課傳送的上限是4MB。

傳送批量訊息的例子:

String topic = "itzhai-test-topic";
List<Message> messages = new ArrayList<>();
messages.add(new Message(topic, "TagA", "OrderID001", "Hello world itzhai.com 0".getBytes()));
messages.add(new Message(topic, "TagA", "OrderID002", "Hello world itzhai.com 1".getBytes()));
messages.add(new Message(topic, "TagA", "OrderID003", "Hello world itzhai.com 2".getBytes()));
producer.send(messages);

如果傳送的訊息比較多,會增加複雜性,為此,可以對大訊息進行拆分。以下是拆分的例子:

public class ListSplitter implements Iterator<List<Message>> { 
    // 限制最大大小
    private final int SIZE_LIMIT = 1024 * 1024 * 4;
    private final List<Message> messages;
    private int currIndex;
    public ListSplitter(List<Message> messages) { 
        this.messages = messages;
    }
    @Override public boolean hasNext() {
        return currIndex < messages.size(); 
    }
    @Override public List<Message> next() { 
        int startIndex = getStartIndex();
        int nextIndex = startIndex;
        int totalSize = 0;
        for (; nextIndex < messages.size(); nextIndex++) {
            Message message = messages.get(nextIndex); 
            int tmpSize = calcMessageSize(message);
            if (tmpSize + totalSize > SIZE_LIMIT) {
                break; 
            } else {
                totalSize += tmpSize; 
            }
        }
        List<Message> subList = messages.subList(startIndex, nextIndex); 
        currIndex = nextIndex;
        return subList;
    }
    private int getStartIndex() {
        Message currMessage = messages.get(currIndex); 
        int tmpSize = calcMessageSize(currMessage); 
        while(tmpSize > SIZE_LIMIT) {
            currIndex += 1;
            Message message = messages.get(curIndex); 
            tmpSize = calcMessageSize(message);
        }
        return currIndex; 
    }
    private int calcMessageSize(Message message) {
        int tmpSize = message.getTopic().length() + message.getBody().length(); 
        Map<String, String> properties = message.getProperties();
        for (Map.Entry<String, String> entry : properties.entrySet()) {
            tmpSize += entry.getKey().length() + entry.getValue().length(); 
        }
        tmpSize = tmpSize + 20; // Increase the log overhead by 20 bytes
        return tmpSize; 
    }
}

// then you could split the large list into small ones:
ListSplitter splitter = new ListSplitter(messages);
while (splitter.hasNext()) {
   try {
       List<Message>  listItem = splitter.next();
       producer.send(listItem);
   } catch (Exception e) {
       e.printStackTrace();
       // handle the error
   }
}

5.1.7 訊息過濾

RocketMQ的消費者可以根據Tag進行訊息過濾來獲取自己感興趣的訊息,也支援自定義屬性過濾。

Tags是Topic下的次級訊息型別/二級型別(注:Tags也支援TagA || TagB這樣的表示式),可以在同一個Topic下基於Tags進行訊息過濾。

訊息過濾是在Broker端實現的,減少了對Consumer無用訊息的網路傳輸,缺點是增加了Broker負擔,實現相對複雜。

5.2 消費端

5.2.1 消費模型

消費端有兩週消費模型:叢集消費和廣播消費。

叢集消費

叢集消費模式下,相同Consumer Group的每個Consumer例項平均分攤訊息。

廣播消費

廣播消費模式下,相同Consumer Group的每個Consumer例項都接收全量的訊息。

5.2.2 訊息重試

RocketMQ會為每個消費組都設定一個Topic名稱為%RETRY%consumerGroupName的重試佇列(這裡需要注意的是,這個Topic的重試佇列是針對消費組,而不是針對每個Topic設定的),用於暫時儲存因為各種異常而導致Consumer端無法消費的訊息。

考慮到異常恢復起來需要一些時間,會為重試佇列設定多個重試級別,每個重試級別都有與之對應的重新投遞延時,重試次數越多投遞延時就越大。

RocketMQ對於重試訊息的處理是先儲存至Topic名稱為SCHEDULE_TOPIC_XXXX的延遲佇列中,後臺定時任務按照對應的時間進行Delay後重新儲存至%RETRY%consumerGroupName的重試佇列中。

比如,我們設定1秒後把訊息投遞到topic-itzhai-comtopic,則儲存的檔案目錄如下所示:

image-20211017213559746

5.2.3 死信佇列

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

RocketMQ將這種正常情況下無法被消費的訊息稱為死信訊息(Dead-Letter Message),將儲存死信訊息的特殊佇列稱為死信佇列(Dead-Letter Queue)

在RocketMQ中,可以通過使用console控制檯對死信佇列中的訊息進行重發來使得消費者例項再次進行消費


由於RocketMQ是使用Java寫的,所以它的程式碼特別適合拿來閱讀消遣,我們繼續來看看RocketMQ的原始碼結構...

不不,還是算了,一下子又到週末晚上了,時間差不多了,今天就寫到這裡了。有空再聊。


我精心整理了一份Redis寶典給大家,涵蓋了Redis的方方面面,面試官懂的裡面有,面試官不懂的裡面也有,有了它,不怕面試官連環問,就怕面試官一上來就問你Redis的Redo Log是幹啥的?畢竟這種問題我也不會。

image-20211007142531823

Java架構雜談公眾號傳送Redis關鍵字獲取pdf檔案:

image-20211010220323135

本文作者: arthinking

部落格連結: https://www.itzhai.com/articles/deep-understanding-of-rocketmq.html

高併發非同步解耦利器:RocketMQ究竟強在哪裡?

版權宣告: 版權歸作者所有,未經許可不得轉載,侵權必究!聯絡作者請加公眾號。

References

apache/rocketmq. Retrieved from https://github.com/apache/rocketmq

相關文章