RocketMQ架構原理解析(一):整體架構
RocketMQ架構原理解析(二):訊息儲存(CommitLog)
RocketMQ架構原理解析(三):訊息索引(ConsumeQueue & IndexFile)
RocketMQ架構原理解析(四):訊息生產端(Producer)
一、概述
如果你曾經使用過RocketMQ,那麼一定對以下傳送訊息的程式碼不陌生
DefaultMQProducer producer = new DefaultMQProducer("producerGroup");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.start();
Message message = new Message(topic, new byte[] {'hello, world'});
producer.send(message);
寥寥幾行程式碼,便是本文要論述的全部。阿里有句土話,叫“把複雜留給自己,把簡單交給別人”用在這裡可能最合適不過了,這5行程式碼中,最重要的是producer.start()
及producer.send()
,也就是producer啟動及訊息傳送
二、Producer啟動
對應程式碼producer.start()
其實僅僅一行程式碼,在produer端的後臺啟動了多個執行緒來協同工作,接下來我們逐一闡述
2.1、Netty
我們都知道,RocketMQ是一個叢集部署、跨網路的產品,除了producer、consumer需要網路傳輸外,資料還需要在叢集中流轉。所以一個高效、可靠的網路元件是必不可少的。而RocketMQ選擇了netty
使用netty首先需要考慮的便是業務上的資料粘包問題,netty提供了一些較為常用的解決方案,如:固定長度(比如每次傳送的訊息長度均為1024byte)、固定分隔符(比如每次傳送的訊息長度均為1024byte)等。而RocketMQ使用的則是最為通用的head、body分離方式,即head儲存訊息的長度,body儲存真正的訊息資料,具體實現可參見類o.a.r.r.n.NettyRemotingClient
而訊息收發這塊,RocketMQ將所有的訊息都收斂到同一個協議類o.a.r.r.p.RemotingCommand
中,即訊息傳送、接收都會將其封裝在該類中,這樣做的好處是不言而喻的,即統一規範,減輕網路協議適配不同的訊息型別帶來的負擔
其中較為重要的2個 ChannelHanlder 如下
org.apache.rocketmq.remoting.netty.NettyEncoder
- 訊息編碼,向 broker 或 nameServer 傳送訊息時使用,將
RemotingCommand
轉換為byte[]
形式
- 訊息編碼,向 broker 或 nameServer 傳送訊息時使用,將
org.apache.rocketmq.remoting.netty.NettyDecoder
- 訊息解碼,將
byte[]
轉換為RemotingCommand
物件,接收 broker 返回的訊息時,進行解碼操作
- 訊息解碼,將
2.2、訊息格式
訊息格式是什麼概念?在《訊息儲存》章節不是已經闡述過訊息格式了嗎?其實這是兩個概念,《訊息儲存》章節是訊息真正落盤時候的儲存格式,本小節的訊息格式是指訊息以什麼樣的形態交給netty從而在網路上進行傳輸
訊息格式由MsgHeader及MsgBody組成,而訊息的長度、標記、版本等重要引數都放在 header 中,body 中僅僅儲存資料,沒有額外欄位;我們主要看一下 header 的資料格式
而站在 netty 視角來看,不論是 msgHeader 還是 msgBody,都屬於 netty 網路訊息的body部分,所以我們可以簡單畫一張 netty 視角的訊息格式
2.2.1、Msg Header的自動適配
上文得知,RocketMQ將所有的訊息型別、收發都收斂到類RemotingCommand
中,但RocketMQ訊息型別眾多,除了常見的訊息傳送、接收外,還有通過msgID查詢訊息、msgKey查詢訊息、獲取broker配置、清理不再使用的topic等等,用一個類適配如此多的型別,具體是如何實現的呢?當新增、修改一種型別又該怎麼應對呢?
翻看原始碼便發現,RemotingCommand
的訊息頭定義為一個介面org.apache.rocketmq.remoting.CommandCustomHeader
,不同型別的請求都實現這個介面,並在自己的子類中定義成員變數;那RemotingCommand
的訊息頭又是如何自動解析呢?
public void makeCustomHeaderToNet() {
if (this.customHeader != null) {
Field[] fields = getClazzFields(customHeader.getClass());
if (null == this.extFields) {
this.extFields = new HashMap<String, String>();
}
for (Field field : fields) {
if (!Modifier.isStatic(field.getModifiers())) {
String name = field.getName();
if (!name.startsWith("this")) {
Object value = null;
try {
field.setAccessible(true);
value = field.get(this.customHeader);
} catch (Exception e) {
log.error("Failed to access field [{}]", name, e);
}
if (value != null) {
this.extFields.put(name, value.toString());
}
}
}
}
}
}
答案就是反射,通過反射獲取子類的全部成員屬性,並放入變數extFields
中,makeCustomHeaderToNet()
通過犧牲少量效能的方式,換取了程式極大的靈活性與擴充套件性,當新增請求型別時,僅需要編寫新請求的encode、decode,不用修改其他型別請求的程式碼
2.3、Topic路由資訊
2.3.1、Topic建立
傳送訊息的前置是需要建立一個topic,建立topic的admin命令如下
updateTopic -b <> -t <> -r <> -w <> -p <> -o <> -u <> -s <>
例如:
updateTopic -b 127.0.0.1:10911 -t testTopic -r 8 -w 8 -p 6 -o false -u false -s false
簡單介紹下每個引數的作用
-b
broker 地址,表示 topic 所在 Broker,只支援單臺Broker,地址為ip:port-c
cluster 地址,表示 topic 所在 cluster,會向 cluster 中所有的 broker 傳送請求-t
topic 名稱-r
可讀佇列數(預設為 8,後文還會展開)-w
可寫佇列數(預設為 8,後文還會展開)-p
指定新topic的讀寫許可權 (W=2|R=4|WR=6)2表示當前topic僅可寫入資料,4表示僅可讀,6表示可讀可寫-o
set topic's order(true|false)-u
is unit topic (true|false)-s
has unit sub (true|false)
建立流程為 admin -> broker -> nameServer
如果執行命令updateTopic -b 127.0.0.1:8899 -t testTopic -r 8 -w 8
意味著會在127.0.0.1:8899對應的broker下建立一個topic,這個topic的讀寫佇列都是 8
那如果是這樣的場景呢:叢集A有3個master節點,當執行命令updateTopic -c clusterName -t testTopic -r 8 -w 8
後,站在叢集A角度來看,當前topic總共建立了多少個寫佇列?其實 RocketMQ 接到這條命令後,會向3個 broker 分別傳送建立 topic 的命令,這樣每個broker上都會有8個讀佇列,8個寫佇列,所以站在叢集的視角,這個 topic 總共會有 24 個讀佇列,24 個寫佇列
2.3.2、writeQueueNum VS readQueueNum
首選需要明確的是,讀、寫佇列,這兩個概念是 RocketMQ 獨有的,而 kafka 中只有一個partition的概念,不區分讀寫。一般情況下,這兩個值建議設定為相等;我們分別看一下 client 端對它們的處理 (均在類MQClientInstance.java
中)
producer端
for (int i = 0; i < qd.getWriteQueueNums(); i++) {
MessageQueue mq = new MessageQueue(topic, qd.getBrokerName(), i);
info.getMessageQueueList().add(mq);
}
consumer端
for (int i = 0; i < qd.getReadQueueNums(); i++) {
MessageQueue mq = new MessageQueue(topic, qd.getBrokerName(), i);
mqList.add(mq);
}
如果2個佇列設定不相等,例如我們設定6個寫佇列,4個讀佇列的話:
這樣,4、5號佇列中的資料一定不會被消費。
writeQueueNum > readQueueNum
- 大於 readQueueNum 部分的佇列永遠不會被消費
writeQueueNum < readQueueNum
- 所有佇列中的資料都會被消費,但部分讀佇列資料一直是空的
這樣設計有什麼好處呢?其實是更精細的控制了讀寫操作,例如當我們要遷移 broker 時,可以首先將寫入佇列設定為0,將客戶端引流至其他 broker 節點,等讀佇列資料也處理完畢後,再關閉 read 操作
2.3.3、路由資料格式
topic的路由資料如何由Admin發起建立,再被各個broker響應,繼而被nameServer統一組織建立的流程我們暫且不討論,為防止發散,我們直接從producer從nameServer獲取路由資料開始。從nameServer獲取到的路由資料格式如下
public class TopicRouteData extends RemotingSerializable {
private String orderTopicConf;
private List<QueueData> queueDatas;
private List<BrokerData> brokerDatas;
private HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;
}
而存放路由資料的結構是queueDatas
及brokerDatas
public class QueueData implements Comparable<QueueData> {
private String brokerName;
private int readQueueNums;
private int writeQueueNums;
}
public class BrokerData implements Comparable<BrokerData> {
private String cluster;
private String brokerName;
private HashMap<Long/* brokerId */, String/* broker address */> brokerAddrs;
}
在此,簡單闡述一下RocketMQ的cluster、brokerName、brokerId的概念
上圖描述了一個cluster下有3個broker,每個broker又有1個master,2個slave組成;這也就是為什麼類BrokerData
中有HashMap<Long, String> brokerAddrs
變數的原因,因為可能同一個brokerName下由多個節點組成。注:master節點的編號始終為0
2.3.4、Topic路由資訊何時發生變化
這些路由資訊什麼時候發生變化呢?我們舉例說明
舉例1:某叢集有3臺 master,分別向其中的2臺傳送了建立topic的命令,此時所有的clent端都知道這個topic的資料在這兩個broker上;這個時候通過admin向第3臺broker傳送建立topic命令,nameServer的路由資訊便發生了變更,等client端30秒輪訓後,便可以更新到最新的topic路由資訊
舉例2:某叢集有3臺 master,topic分別在3臺broker上都建立了,此時某臺broker當機,nameServer將其摘除,等待30秒輪詢後,client拿到最新路由資訊
思考:client 端路由資訊的變化是依託於30秒的輪詢,如果路由資訊已經發生變化,且輪詢未發生,client端拿著舊的topic路由資訊訪問叢集,一定會有短暫報錯,此處也是待優化的點
2.3.5、定時更新Topic路由資訊
RocketMQ會每隔30秒更新topic的路由資訊
此處簡單留意一下TopicRouteData
及TopicPublishInfo
,其實TopicPublishInfo
是由TopicRouteData
變種而來,多了一個messageQueueList
的屬性,在producer端,該屬性為寫入佇列,即某個topic所有的可寫入的佇列集合
此處丟擲一個問題,如果producer只想某個topic傳送了一條訊息,後續再沒有傳送過,這種設計會帶來哪些問題?如果這種場景頻繁發生呢?
2.4、與Broker心跳
主要分為兩部分:
- 1、清空無效broker
- 2、向有效的broker傳送心跳
2.4.1、清空無效的broker
由上節得知,RocketMQ會獲取所有已經註冊的topic所在的broker資訊,並將這些資訊儲存在變數brokerAddrTable
中,brokerAddrTable
的儲存結構如下
ConcurrentMap<String, HashMap<Long, String>> brokerAddrTable ;
- key: brokerName,例如一個master帶2個slave都屬於同一個brokerName
- val:
HashMap<Long, String>
,key為brokerId(其中master的brokerId固定為0),val為ip地址
如何判斷某個broker有效無效呢?判斷依據便是MQClientInstance#topicRouteTable
,這個變數是上節中從nameServer中同步過來的,如果brokerAddrTable
中有broker A、B、C,而topicRouteTable
只有A、B的話,那麼就需要從brokerAddrTable
中刪除C。
需要注意的是,在整個check及替換過程中都新增了獨佔鎖lockNamesrv
,而上節中維護更新topic路由資訊也是指定的該鎖
2.4.2、傳送心跳資料
此處目的僅為與broker保持網路心跳,如果連線失敗或發生異常,僅會列印日誌,並不會有額外操作
三、訊息傳送
訊息傳送比較重要的是2點內容
- 傳送資料的負載均衡問題;RocketMQ預設採用的是輪訓的方式
- 訊息傳送的方式;分同步、非同步、單向
3.1、負載均衡
預設的傳送策略為輪詢的方式
不過RocketMQ也支援了比較靈活的佇列選擇,可以使用MessageQueueSelector
producer.send(zeroMsg, (mqs, msg, arg) -> {
int index = msg.getKeys().hashCode() % mqs.size();
return mqs.get(index);
}, 1000);
上例便是將msgKey取模,這樣同樣msgKey的訊息必定會落在同一個佇列中
3.2、訊息傳送的3種方式
RocketMQ的rpc元件採用的是netty,而netty的網路請求設計是完全非同步的,所以一個請求避免一定可以拆成以下3個步驟
- a、客戶端傳送請求到伺服器(由於完全非同步,所以請求資料可能只放在了socket緩衝區,並沒有出網路卡)
- b、伺服器端處理請求(此過程不涉及網路開銷,不過通常也是比較耗時的)
- c、伺服器向客戶端返回應答(請求的response)
3.2.1、同步傳送訊息
SendResult result = producer.send(zeroMsg);
此過程比較好理解,即完成a、b、c所有步驟後才會返回,耗時也是 a + b + c 的總和
3.2.2、非同步傳送訊息
通常在業務中傳送訊息的程式碼如下:
SendCallback sendCallback = new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
// doSomeThing;
}
@Override
public void onException(Throwable e) {
// doSomeThing;
}
};
producer.send(zeroMsg, sendCallback);
而RocketMQ處理非同步訊息的邏輯是,直接啟動一個執行緒,而最終的結果非同步回撥SendCallback
ExecutorService executor = this.getAsyncSenderExecutor();
try {
executor.submit(new Runnable() {
@Override
public void run() {
try {
sendDefaultImpl(msg, CommunicationMode.ASYNC, sendCallback, timeout - costTime);
} catch (Exception e) {
sendCallback.onException(e);
}
}
});
} catch (RejectedExecutionException e) {
throw new MQClientException("executor rejected ", e);
}
3.2.2、單向傳送訊息
producer.sendOneway(zeroMsg);
此模式與sync
模式類似,都要經過producer端在資料傳送前的資料組裝工作,不過在將資料交給netty,netty呼叫作業系統函式將資料放入socket緩衝區後,所有的過程便已結束。什麼場景會用到此模式呢?比如對可靠性要求並不高,但要求耗時非常短的場景,比如日誌收集等
三個請求哪個更快呢?如果單論一個請求的話,肯定是
async
非同步的方式最快,因為它直接把工作交給另外一個執行緒去完成,主執行緒直接返回了;但不論是async
還是sync
,它們都是需要將 a、b、c 3個步驟都走完的,所以總開銷並不會減少。但oneWay
因為只需將資料放入socket緩衝區後,client 端就直接返回了,少了監聽並解析 server 端 response 的過程,所以可以得到最好的效能
四、總結
本章闡述了producer端相對重要的一些功能點,感覺比較核心的還是佇列相關的概念;但RocketMQ發展迭代了這麼多年,也涵蓋了很多及細小的特性,本文不能窮盡,比如“訊息的壓縮”、“規避傳送延遲較長的broker”、“超時異常”等等,這些功能點獨立且零碎,讀原始碼時可以帶著問題跟進,這樣針對性強,效率也會高很多