RocketMQ基礎概念剖析,並分析一下Producer的底層原始碼

detectiveHLH發表於2021-02-26

由於篇幅原因,本次的原始碼分析只限於Producer側的傳送訊息的核心邏輯,我會通過流程圖、程式碼註釋、文字講解的方式來對原始碼進行解釋,後續應該會專門開幾篇文章來做原始碼分析。

這篇部落格聊聊關於RocketMQ相關的東西,主要聊的點有RocketMQ的功能使用、RocketMQ的底層執行原理和部分核心邏輯的原始碼分析。至於我們為什麼要用MQ、使用MQ能夠為我們帶來哪些好處、MQ在社群有哪些實現、社群的各個MQ的優劣對比等等,我在之前的文章《訊息佇列雜談》已經聊過了,如果需要了解的話可以回過頭去看看。

基礎概念

Broker

首先我們要知道,使用RocketMQ時我們經歷了什麼。那就是生產者傳送一條訊息給RocketMQ,RocketMQ拿到這條訊息之後將其持久化儲存起來,然後消費者去找MQ消費這條訊息。

RocketMQ操作
RocketMQ操作

上圖中,RocketMQ被標識為了一個單點,但事實上肯定不是如此,對於可以隨時橫向擴充套件的服務來說,生產者向MQ生產訊息的數量也會隨之而變化,所以一個合格成熟的MQ必然是要能夠處理這種情況的;而且MQ自身需要做到高可用,否則一旦這個單點當機,那所有儲存在MQ中的訊息就全部丟失且無法找回了。

所以在實際的生產環境中,肯定是會部署一個MQ的叢集。而在RocketMQ中,這個“例項”有個專屬名詞,叫做Broker。並且,每個Broker都會部署一個Slave Broker,Master Broker會定時的向Slave Broker同步資料,形成一個Broker的主從架構

那麼問題來了,在微服務的架構中,部署的服務也存在多例項部署的情況,服務之間相互呼叫是通過註冊中心來獲取對應服務的例項列表的。

拿Spring Cloud舉例,服務通過Eureka註冊中心獲取到某個服務的全部例項,然後交給Ribbon,Ribbon聯動Eureka,從Eureka處獲取到服務例項的列表,然後通過負載均衡演算法選出一個例項,最後發起請求。

同理,此時MQ中存在多個Broker例項,那生產者如何得知MQ叢集中有多少Broker例項呢?自己應該連線哪個例項?

首先我們直接排除在程式碼裡Hard Code,具體原因我覺得應該不用再贅述了。RocketMQ是如何解決這個問題呢?這就是接下來我們要介紹的NameServer了。

NameServer

NameServer可以被簡單的理解為上一小節中提到的註冊中心,所有的Broker的在啟動的時候都會向NameServer進行註冊,將自己的資訊上報。這些資訊除了Broker的IP、埠相關資料,還有RocketMQ叢集的路由資訊,路由資訊後面再聊。

RocketMQ操作
RocketMQ操作

有了NameServer,客戶端啟動之後會和NameServer互動,獲取到當前RocketMQ叢集中所有的Broker資訊、路由資訊。這樣一來,生產者就知道自己需要連線的Broker資訊了,就可以進行訊息投遞。

那麼問題來了,如果在執行過程中,如果某個Broker突然當機,NameServer會如何處理?

這需要提到RocketMQ的這續約機制故障感知機制。Broker在完成向NameServer的註冊之後,會每隔30秒向NameServer傳送心跳進行續約;如果NameServer感知到了某個Broker超過了120秒都沒有傳送心跳,則會認為這個Broker不可用,將其從自己維護的資訊中移除。

這套機制,和Spring Cloud中的Eureka的實現如出一轍。Eureka中的Service在啟動之後也會向Eureka註冊自己,這樣一來其他的服務就可以向該服務發起請求,交換資料。Service每隔30秒會向Eureka傳送心跳續約,如果某個Service超過了90秒沒有傳送心跳,Eureka就會認為該服務當機,將其從Eureka維護的登錄檔中移除。

上面圖中我聊到了多例項部署,這個多例項部署和微服務中的多例項部署還不太一樣,微服務中,所有的服務都是無狀態的,可以橫向的擴充套件,而在RocketMQ中,每個Broker所存的資料可能都不一樣。

我們來看一下RocketMQ的簡單使用。

Message msg = new Message(
  "TopicTest",
  "TagA",
  ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)
);
SendResult sendResult = producer.send(msg);

可以看到,Message的第一個引數,為當前這條訊息指定了一個Topic,那Topic又是什麼呢?

Topic

Topic是對傳送到RocketMQ中的訊息的邏輯分類,例如我們的訂單系統、積分系統、倉儲系統都會用到這個MQ,為了對其進行區分,我們就可以為不同的系統建立不同的Topic。

那為什麼說是邏輯分割槽呢?因為RocketMQ在真實儲存中,並不是一個Broker就儲存一個Topic的資料,道理很簡單,如果當前這個Broker當機,甚至極端情況磁碟壞了,那這個Topic的資料就會永久丟失。

所以在真實儲存中,訊息是分散式的儲存在多個Broker上的,這這些分散在多個Broker上的儲存介質叫MessageQueue,如果你熟悉Kafka的底層原理,就知道這個跟Kafka中的Partition是同類的實現。

Message Queue儲存
Message Queue儲存

通過上圖可以看出,同一個Topic的資料,被分成了好幾份,分別儲存在不同的Broker上,那RocketMQ為什麼要這麼實現?

首先,一個Topic中如果只有一個Queue,那麼消費者在消費時的速度必然受到影響;而如果一個Topic有很多個Queue,那麼Consumer就可以將消費操作同時進行,從而扛住更多的併發。

除此之外,單臺機器的資源是有限的。一個Topic的訊息量可能會非常之巨大,一臺機器的磁碟很快就會被塞滿。所以RocketMQ將一個Topic的資料分攤給了多臺機器,進行分散儲存。其本質上就是一個資料分片儲存的一種機制。

所以我們知道了,傳送到某個Topic的資料是分散式的儲存在多個Broker中的MessageQueue上的。

Broker訊息儲存原理

那Producer傳送到Broker中的訊息,到底是以什麼方式儲存的呢?答案是Commit Log,Broker收到訊息,會將該訊息採用順序寫入的方式,追加到磁碟上的Commit Log檔案中,每個Commit Log大小為1G,如果寫滿了1G則會新建一個Commit Log繼續寫,Commit Log檔案的特點是順序寫、隨機讀。

Topic詳情
Topic詳情

這就是最底層的儲存的方式,那麼問題來了,Consumer來取訊息的時候,Broker是如何從這一堆的Commit Log中找到相應的資料呢?眾所周知,一提到磁碟的I/O操作,就會聯想到耗時這兩個字,而RocketMQ的一大特點就是高吞吐,看似很矛盾,RocketMQ是如何做的呢?

答案是ConsumeQueue,Broker在寫入Commit Log的同時,還會將當前這條訊息在Commit Log中的Offset、訊息的Size和對應的Tag的Hash寫入到ConsumeQueue檔案中。每個Message Queue會有相對應的ConsumeQueue檔案儲存在磁碟上。

和Commit Log一樣,一個ConsumeQueue包含了30W條訊息,每條訊息的大小為20位元組,所以每個ConsumeQueue檔案的大小約為5.72M;當其寫滿了之後,會再新建一個ConsumeQueue檔案繼續寫入。

ConsumeQueue是一種邏輯佇列,更是一種索引,讓Consumer來消費的時候可以快速的從磁碟檔案中定位到這條訊息。

看到這你可能會想,上面提到的Tag又是個什麼東西?

Tag

Tag,標籤,用於對同一個Topic內的訊息進行分類,為什麼還需要對Topic進行訊息型別劃分呢?

舉一個極端的例子,某一個新的服務,需要去消費訂單系統的MQ,但是由於業務的特殊性,只需要去消費商品型別為數碼產品的訂單訊息,如果沒有Tag,那麼該Consumer就會去做判斷,該訂單訊息是否是數碼產品類,如果不是,則丟棄,如果是則進行消費。

這樣一來,Consumer側就執行了大量的無用功。引入了Tag之後,Producer在生產訊息的時候會給訂單打上Tag,Consumer進行消費的時候,可以配置只消費指定的Tag的訊息。這樣一來就不需要Consumer自己去做這個事情了,RocketMQ會幫我們實現這個過濾。

那其過濾的原理是什麼?首先在Broker側是通過訊息中儲存的Tag的Hash值進行過濾,然後Consumer側在去拉取訊息的時候還需要再過濾一次。

為什麼在Broker過濾了,還需要在Consumer側再過濾一次?因為Hash衝突,不同的Tag經過Hash演算法之後可能會得到一樣的值,所以Consumer側在拉取訊息的時候會通過字串進行二次過濾。

Producer傳送訊息原始碼分析

流程總覽

首先給出整個傳送訊息的大致流程,先熟悉這個流程看原始碼,會更加的清晰一點。

總體流程
總體流程

初始化Prodcuer

還是按照下面這個例子出發。

producer使用樣例
producer使用樣例

首先我們會初始化一個DefaultMQProducer,RocketMQ會給這個Producer一個預設的實現DefaultMQProducerImpl。然後producer.start()會啟動一個執行緒池。

合法性校驗

接下來就是比較核心的producer.send(msg)了,首先RocketMQ會呼叫checkMessage來檢測傳送的訊息是否合法。

send訊息
send訊息

這些檢測包含了待傳送的訊息是否為空,Topic是否為空、Topic是否包含了非法的字串、Topic的長度是否超過了最大限制127,然後會去檢查Body是否符合傳送要求,例如msg的Body是否為空、msg的Body是否超過了最大的限制等等,這裡訊息的Body最大不能超過4M。

檢查訊息合法性原始碼
檢查訊息合法性原始碼

呼叫傳送訊息

對於msg的Topic,RocketMQ會用NameSpace將其包裝一層,然後就會呼叫DefaultMQProducerImpl中的sendDefaultImpl預設實現,傳送訊息給Broker,預設的傳送訊息Timeout是3秒。

傳送訊息預設實現
傳送訊息預設實現

傳送訊息中,MQ會再次呼叫checkMessage對訊息的合法性再次進行檢查,然後就會去嘗試獲取Topic的詳細資訊。

所有的Topic的資訊都會存在一個叫topicPublishInfoTable的 ConcurrentHashMap中,這個Map中Key就是Topic的字串,而Value則是TopicPublishInfo

這個TopicPublishInfo中就包含了之前在基礎概念中提到的,從Broker中獲取到的相應的後設資料,其中就包含了關鍵的MessageQueue和叢集後設資料,其基礎的結構如下。

Topic詳情
Topic詳情

messageQueueList包含了該Topic下的所有的MessageQueue,每個MessageQueue的所屬Topic,每個MessageQueue所在的Broker的名稱以及專屬的queueId。

topicRouteData包含了該Topic下的所有的Queue、Broker相關的資料。

獲取Topic詳細資料

在最終傳送訊息前,需要獲取到Topic的詳情,例如像Broker地址這樣的資料,Producer中是通過tryToFindTopicPublishInfo方法獲取的,詳細的註釋我已經寫在了下圖中。

獲取topic詳情
獲取topic詳情

對於首次使用的Topic,在上面的Map肯定是不存在的。所以RocketMQ會將其加入到Map中去,並且呼叫方法updateTopicRouteInfoFromNameServer從NameServer處獲取該Topic的後設資料,將其一併寫入Map。初次之外,還會將路由資訊、Broker的詳細資訊分別放入topicRouteTablebrokerAddrTable中,這兩個都是Producer維護在記憶體中的ConcurrentHashMap。

獲取到了Topic的詳細資訊之後,接下來會確認一個傳送的重試次數timesTotal,假設timesTotal為N,那麼傳送訊息如果失敗就會重試N次。不過當且僅當傳送失敗的時候才會進行重試,其餘的case都不會,例如超時、或者沒有選擇到合適的MessageQueue。

這個重試的次數timesTotal受到引數communicationMode的影響;CommunicationMode有三個值,分別是SYNCASYNCONEWAY。RocketMQ預設的實現中,選擇了SYNC同步。

計算重試次數
計算重試次數

通過程式碼我們可以看到,如果是communicationModeSYNC的話,timesTotal的值為1+retryTimesWhenSendFailed,而retryTimesWhenSendFailed的值預設為2,代表在訊息傳送失敗之後的重試次數。

這樣一來,如果我們選擇了SYNC的方式,Producer在傳送訊息的時候預設的重試次數就為3。不過當且僅當傳送失敗的時候才會進行重試,其餘的case都不會。

MessageQueue選擇機制

我們之前聊過,一個Topic的資料是分片儲存在一個或者多個Broker上的,底層的儲存介質為MessageQueue,之前的圖中,我們沒有給出Producer是如何選擇具體傳送到哪個MessageQueue,這裡我們通過原始碼來看一下。

Producer中是通過selectOneMessageQueue來進行的Message Queue選擇,該方法通過Topic的詳細後設資料和上次選擇的MessageQueue所在的Broker,來決定下一個的選擇。

核心的選擇邏輯

核心的選擇邏輯是什麼呢?用大白話來說,就是選出一個index,然後將其和當前Topic的MessageQueue數量取模。這個index在首次選擇的時候,肯定是沒有的, RocketMQ會搞一個隨機數出來。然後在該值的基礎上+1,因為為了通用,在外層看來,這個index上次已經用過了,所以每次獲取你都直接幫我+1就好了。

核心的選擇機制
核心的選擇機制

上圖就是MessageQueue最核心的、最底層的原則機制了。但是由於實際的業務情況十分複雜, RocketMQ在實現中還額外的做了很多的事情。

傳送故障延遲下的選擇邏輯

在實際的選擇過程中,會判斷當前是否啟用了傳送延遲故障,這個由變數sendLatencyFaultEnable的值決定,其預設值是false,也就是預設是不開啟的,從程式碼裡我暫時沒找到其開啟的位置。

不過我們可以聊聊開啟之後,會發生什麼。它同樣會開啟for迴圈,次數為MessageQueue的數量,計算拿到確定的Queue之後,會通過記憶體的一張表faultItemTable去判斷當前這個Broker是否可用,該表是每次傳送訊息的時候都會去更新它。

如果當前沒有可用的Broker,則會觸發其兜底的邏輯,再選擇一個MessageQueue出來。

選擇queue的原始碼
選擇queue的原始碼

常規的選擇邏輯

如果當前傳送故障延遲沒有啟用,則會走常規邏輯,同樣的會去for迴圈計算,迴圈中取到了MessageQueue之後會去判斷是否和上次選擇的MessageQueue屬於同一個Broker,如果是同一個Broker,則會重新選擇,直到選擇到不屬於同一個Broker的MessageQueue,或者直到迴圈結束。這也是為了將訊息均勻的分發儲存,防止資料傾斜。

常規邏輯下的選擇邏輯
常規邏輯下的選擇邏輯

訊息傳送

最後就會呼叫Netty相關的元件,將訊息傳送出去了。

EOF

關於RocketMQ中的一些基礎的概念,和RocketMQ的Producer傳送訊息的原始碼就先分析到這裡,後續看緣分再分享其他部分的原始碼吧。

好了以上就是本篇部落格的全部內容了,如果你覺得這篇文章對你有幫助,還麻煩點個贊關個注分個享留個言

歡迎微信搜尋關注【SH的全棧筆記】,檢視更多相關文章

相關文章