0. 前言
RocketMQ的消息投遞分爲兩種:一種是生產者
往MQ Broker中投遞;另外一種則是MQ broker 往消費者
投遞(這種投遞
的說法是從消息傳遞的角度闡述的,實際上底層是消費者
從MQ broker 中Pull拉取的)。本文將從模型的角度來闡述這兩種機制。
1. RocketMQ的消息模型
RocketMQ 的消息模型整體並不複雜,如下圖所示:
一個Topic(消息主題)
可能對應多個實際的消息隊列(MessgeQueue)
在底層實現上,爲了提高MQ的可用性和靈活性,一個Topic在實際存儲的過程中,採用了多隊列的方式,具體形式如上圖所示。每個消息隊列在使用中應當保證先入先出(FIFO,First In First Out)的方式進行消費。
那麼,基於這種模型,就會引申出兩個問題:
-
生產者 在發送相同Topic的消息時,消息體應當被放置到哪一個消息隊列(MessageQueue)中?
-
消費者 在消費消息時,應當從哪些消息隊列中拉取消息?
2. 生產者(Producer)投遞消息的策略
2.1 默認投遞方式:基於Queue隊列
輪詢算法投遞
默認情況下,採用了最簡單的輪詢算法,這種算法有個很好的特性就是,保證每一個Queue隊列
的消息投遞數量儘可能均勻,算法如下圖所示:
/**
* 根據 TopicPublishInfo Topic發佈信息對象中維護的index,每次選擇隊列時,都會遞增
* 然後根據 index % queueSize 進行取餘,達到輪詢的效果
*
*/
public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName) {
return tpInfo.selectOneMessageQueue(lastBrokerName);
}
/**
* TopicPublishInfo Topic發佈信息對象中
*/
public class TopicPublishInfo {
//基於線程上下文的計數遞增,用於輪詢目的
private volatile ThreadLocalIndex sendWhichQueue = new ThreadLocalIndex();
public MessageQueue selectOneMessageQueue(final String lastBrokerName) {
if (lastBrokerName == null) {
return selectOneMessageQueue();
} else {
int index = this.sendWhichQueue.getAndIncrement();
for (int i = 0; i < this.messageQueueList.size(); i++) {
//輪詢計算
int pos = Math.abs(index++) % this.messageQueueList.size();
if (pos < 0)
pos = 0;
MessageQueue mq = this.messageQueueList.get(pos);
if (!mq.getBrokerName().equals(lastBrokerName)) {
return mq;
}
}
return selectOneMessageQueue();
}
}
public MessageQueue selectOneMessageQueue() {
int index = this.sendWhichQueue.getAndIncrement();
int pos = Math.abs(index) % this.messageQueueList.size();
if (pos < 0)
pos = 0;
return this.messageQueueList.get(pos);
}
}
2.2 默認投遞方式的增強:基於Queue隊列
輪詢算法和消息投遞延遲最小
的策略投遞
默認的投遞方式比較簡單,但是也暴露了一個問題,就是有些Queue隊列
可能由於自身數量積壓等原因,可能在投遞的過程比較長,對於這樣的Queue隊列
會影響後續投遞的效果。
基於這種現象,RocketMQ在每發送一個MQ消息後,都會統計一下消息投遞的時間延遲
,根據這個時間延遲
,可以知道往哪些Queue隊列
投遞的速度快。
在這種場景下,會優先使用消息投遞延遲最小
的策略,如果沒有生效,再使用Queue隊列輪詢
的方式。
public class MQFaultStrategy {
/**
* 根據 TopicPublishInfo 內部維護的index,在每次操作時,都會遞增,
* 然後根據 index % queueList.size(),使用了輪詢的基礎算法
*
*/
public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName) {
if (this.sendLatencyFaultEnable) {
try {
// 從queueid 爲 0 開始,依次驗證broker 是否有效,如果有效
int index = tpInfo.getSendWhichQueue().getAndIncrement();
for (int i = 0; i < tpInfo.getMessageQueueList().size(); i++) {
//基於index和隊列數量取餘,確定位置
int pos = Math.abs(index++) % tpInfo.getMessageQueueList().size();
if (pos < 0)
pos = 0;
MessageQueue mq = tpInfo.getMessageQueueList().get(pos);
if (latencyFaultTolerance.isAvailable(mq.getBrokerName())) {
if (null == lastBrokerName || mq.getBrokerName().equals(lastBrokerName))
return mq;
}
}
// 從延遲容錯broker列表中挑選一個容錯性最好的一個 broker
final String notBestBroker = latencyFaultTolerance.pickOneAtLeast();
int writeQueueNums = tpInfo.getQueueIdByBroker(notBestBroker);
if (writeQueueNums > 0) {
// 取餘挑選其中一個隊列
final MessageQueue mq = tpInfo.selectOneMessageQueue();
if (notBestBroker != null) {
mq.setBrokerName(notBestBroker);
mq.setQueueId(tpInfo.getSendWhichQueue().getAndIncrement() % writeQueueNums);
}
return mq;
} else {
latencyFaultTolerance.remove(notBestBroker);
}
} catch (Exception e) {
log.error("Error occurred when selecting message queue", e);
}
// 取餘挑選其中一個隊列
return tpInfo.selectOneMessageQueue();
}
return tpInfo.selectOneMessageQueue(lastBrokerName);
}
}
2.3 順序消息的投遞方式
上述兩種投遞方式屬於對消息投遞的時序性沒有要求的場景,這種投遞的速度和效率比較高。而在有些場景下,需要保證同類型消息投遞和消費的順序性。
例如,假設現在有TOPIC TOPIC_SALE_ORDER
,該 Topic下有4個Queue隊列
,該Topic用於傳遞訂單的狀態變遷,假設訂單有狀態:未支付
、已支付
、發貨中(處理中)
、發貨成功
、發貨失敗
。
在時序上,生產者從時序上可以生成如下幾個消息:訂單T0000001:未支付
--> 訂單T0000001:已支付
--> 訂單T0000001:發貨中(處理中)
--> 訂單T0000001:發貨失敗
消息發送到MQ中之後,可能由於輪詢投遞的原因,消息在MQ的存儲可能如下:
這種情況下,我們希望消費者
消費消息的順序和我們發送是一致的,然而,有上述MQ的投遞和消費機制,我們無法保證順序是正確的,對於順序異常的消息,消費者
即使有一定的狀態容錯,也不能完全處理好這麼多種隨機出現組合情況。
基於上述的情況,RockeMQ
採用了這種實現方案:對於相同訂單號的消息,通過一定的策略,將其放置在一個 queue隊列中
,然後消費者
再採用一定的策略(一個線程獨立處理一個queue
,保證處理消息的順序性),能夠保證消費的順序性
至於消費者是如何保證消費的順序行的,後續再詳細展開,我們先看生產者
是如何能將相同訂單號的消息發送到同一個queue隊列
的:
生產者在消息投遞的過程中,使用了 MessageQueueSelector
作爲隊列選擇的策略接口,其定義如下:
package org.apache.rocketmq.client.producer;
import java.util.List;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageQueue;
public interface MessageQueueSelector {
/**
* 根據消息體和參數,從一批消息隊列中挑選出一個合適的消息隊列
* @param mqs 待選擇的MQ隊列選擇列表
* @param msg 待發送的消息體
* @param arg 附加參數
* @return 選擇後的隊列
*/
MessageQueue select(final List<MessageQueue> mqs, final Message msg, final Object arg);
}
相應地,目前RocketMQ提供瞭如下幾種實現:
默認實現:
投遞策略 | 策略實現類 | 說明 |
---|---|---|
隨機分配策略 | SelectMessageQueueByRandom | 使用了簡單的隨機數選擇算法 |
基於Hash分配策略 | SelectMessageQueueByHash | 根據附加參數的Hash值,按照消息隊列列表的大小取餘數,得到消息隊列的index |
基於機器機房位置分配策略 | SelectMessageQueueByMachineRoom | 開源的版本沒有具體的實現,基本的目的應該是機器的就近原則分配 |
現在大概看下策略的代碼實現:
public class SelectMessageQueueByHash implements MessageQueueSelector {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
int value = arg.hashCode();
if (value < 0) {
value = Math.abs(value);
}
value = value % mqs.size();
return mqs.get(value);
}
}
實際的操作代碼樣例如下,通過訂單號作爲hash運算對象,就能保證相同訂單號的消息能夠落在相同的queue隊列上
。
rocketMQTemplate.asyncSendOrderly(saleOrderTopic + ":" + tag, msg,saleOrderId /*傳入訂單號作爲hash運算對象*/, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("SALE ORDER NOTIFICATION SUCCESS:{}",sendResult.getMsgId());
}
@Override
public void onException(Throwable throwable) {
//exception happens
}
});
3. 如何爲消費者分配queue隊列
?
RocketMQ對於消費者消費消息有兩種形式:
-
BROADCASTING
:廣播式消費,這種模式下,一個消息會被通知到每一個消費者
-
CLUSTERING
: 集羣式消費,這種模式下,一個消息最多隻會被投遞到一個消費者
上進行消費
模式如下:
廣播式
的消息模式比較簡單,下面我們介紹下集羣式
。對於使用了消費模式爲MessageModel.CLUSTERING
進行消費時,需要保證一個消息
在整個集羣中只需要被消費一次。實際上,在RoketMQ底層,消息指定分配給消費者的實現,是通過queue隊列
分配給消費者
的方式完成的:也就是說,消息
分配的單位是消息所在的queue隊列
。即:
將
queue隊列
指定給特定的消費者
後,queue隊列
內的所有消息將會被指定到消費者
進行消費。
RocketMQ定義了策略接口AllocateMessageQueueStrategy
,對於給定的消費者分組
,和消息隊列列表
、消費者列表
,當前消費者
應當被分配到哪些queue隊列
,定義如下:
/**
* 爲消費者分配queue的策略算法接口
*/
public interface AllocateMessageQueueStrategy {
/**
* Allocating by consumer id
*
* @param consumerGroup 當前 consumer羣組
* @param currentCID 當前consumer id
* @param mqAll 當前topic的所有queue實例引用
* @param cidAll 當前 consumer羣組下所有的consumer id set集合
* @return 根據策略給當前consumer分配的queue列表
*/
List<MessageQueue> allocate(
final String consumerGroup,
final String currentCID,
final List<MessageQueue> mqAll,
final List<String> cidAll
);
/**
* 算法名稱
*
* @return The strategy name
*/
String getName();
}
相應地,RocketMQ提供瞭如下幾種實現:
算法名稱 | 含義 |
---|---|
AllocateMessageQueueAveragely |
平均分配算法 |
AllocateMessageQueueAveragelyByCircle |
基於環形平均分配算法 |
AllocateMachineRoomNearby |
基於機房臨近原則算法 |
AllocateMessageQueueByMachineRoom |
基於機房分配算法 |
AllocateMessageQueueConsistentHash |
基於一致性hash算法 |
AllocateMessageQueueByConfig |
基於配置分配算法 |
爲了講述清楚上述算法的基本原理,我們先假設一個例子,下面所有的算法將基於這個例子講解。
假設當前同一個topic下有queue隊列
10
個,消費者共有4
個,如下圖所示:
下面依次介紹其原理:
3.1. AllocateMessageQueueAveragely
- 平均分配算法
這裏所謂的平均分配算法,並不是指的嚴格意義上的完全平均,如上面的例子中,10個queue,而消費者只有4個,無法是整除關係,除了整除之外的多出來的queue,將依次根據消費者的順序均攤。
按照上述例子來看,10/4=2
,即表示每個消費者
平均均攤2個queue;而10%4=2
,即除了均攤之外,多出來2個queue
還沒有分配,那麼,根據消費者的順序consumer-1
、consumer-2
、consumer-3
、consumer-4
,則多出來的2個queue
將分別給consumer-1
和consumer-2
。最終,分攤關係如下:consumer-1
:3個
;consumer-2
:3個
;consumer-3
:2個
;consumer-4
:2個
,如下圖所示:
其代碼實現非常簡單:
public class AllocateMessageQueueAveragely implements AllocateMessageQueueStrategy {
private final InternalLogger log = ClientLogger.getLog();
@Override
public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll,
List<String> cidAll) {
if (currentCID == null || currentCID.length() < 1) {
throw new IllegalArgumentException("currentCID is empty");
}
if (mqAll == null || mqAll.isEmpty()) {
throw new IllegalArgumentException("mqAll is null or mqAll empty");
}
if (cidAll == null || cidAll.isEmpty()) {
throw new IllegalArgumentException("cidAll is null or cidAll empty");
}
List<MessageQueue> result = new ArrayList<MessageQueue>();
if (!cidAll.contains(currentCID)) {
log.info("[BUG] ConsumerGroup: {} The consumerId: {} not in cidAll: {}",
consumerGroup,
currentCID,
cidAll);
return result;
}
int index = cidAll.indexOf(currentCID);
int mod = mqAll.size() % cidAll.size();
int averageSize =
mqAll.size() <= cidAll.size() ? 1 : (mod > 0 && index < mod ? mqAll.size() / cidAll.size()
+ 1 : mqAll.size() / cidAll.size());
int startIndex = (mod > 0 && index < mod) ? index * averageSize : index * averageSize + mod;
int range = Math.min(averageSize, mqAll.size() - startIndex);
for (int i = 0; i < range; i++) {
result.add(mqAll.get((startIndex + i) % mqAll.size()));
}
return result;
}
@Override
public String getName() {
return "AVG";
}
}
3.2 AllocateMessageQueueAveragelyByCircle
-基於環形平均算法
環形平均算法,是指根據消費者的順序,依次在由queue隊列
組成的環形圖中逐個分配。具體流程如下所示:
這種算法最終分配的結果是:consumer-1
: #0,#4,#8consumer-2
: #1, #5, # 9consumer-3
: #2,#6consumer-4
: #3,#7
其代碼實現如下所示:
/**
* Cycle average Hashing queue algorithm
*/
public class AllocateMessageQueueAveragelyByCircle implements AllocateMessageQueueStrategy {
private final InternalLogger log = ClientLogger.getLog();
@Override
public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll,
List<String> cidAll) {
if (currentCID == null || currentCID.length() < 1) {
throw new IllegalArgumentException("currentCID is empty");
}
if (mqAll == null || mqAll.isEmpty()) {
throw new IllegalArgumentException("mqAll is null or mqAll empty");
}
if (cidAll == null || cidAll.isEmpty()) {
throw new IllegalArgumentException("cidAll is null or cidAll empty");
}
List<MessageQueue> result = new ArrayList<MessageQueue>();
if (!cidAll.contains(currentCID)) {
log.info("[BUG] ConsumerGroup: {} The consumerId: {} not in cidAll: {}",
consumerGroup,
currentCID,
cidAll);
return result;
}
int index = cidAll.indexOf(currentCID);
for (int i = index; i < mqAll.size(); i++) {
if (i % cidAll.size() == index) {
result.add(mqAll.get(i));
}
}
return result;
}
@Override
public String getName() {
return "AVG_BY_CIRCLE";
}
}
3.3 AllocateMachineRoomNearby
-基於機房臨近原則算法
該算法使用了裝飾者設計模式
,對分配策略進行了增強。一般在生產環境,如果是微服務架構下,RocketMQ集羣的部署可能是在不同的機房中部署,其基本結構可能如下圖所示:
對於跨機房的場景,會存在網絡、穩定性和隔離心的原因,該算法會根據queue
的部署機房位置和消費者consumer
的位置,過濾出當前消費者consumer
相同機房的queue隊列
,然後再結合上述的算法,如基於平均分配算法在queue隊列
子集的基礎上再挑選。相關代碼實現如下:
@Override
public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll,
List<String> cidAll) {
//省略部分代碼
List<MessageQueue> result = new ArrayList<MessageQueue>();
//將MQ按照 機房進行分組
Map<String/*machine room */, List<MessageQueue>> mr2Mq = new TreeMap<String, List<MessageQueue>>();
for (MessageQueue mq : mqAll) {
String brokerMachineRoom = machineRoomResolver.brokerDeployIn(mq);
if (StringUtils.isNoneEmpty(brokerMachineRoom)) {
if (mr2Mq.get(brokerMachineRoom) == null) {
mr2Mq.put(brokerMachineRoom, new ArrayList<MessageQueue>());
}
mr2Mq.get(brokerMachineRoom).add(mq);
} else {
throw new IllegalArgumentException("Machine room is null for mq " + mq);
}
}
//將消費者 按照機房進行分組
Map<String/*machine room */, List<String/*clientId*/>> mr2c = new TreeMap<String, List<String>>();
for (String cid : cidAll) {
String consumerMachineRoom = machineRoomResolver.consumerDeployIn(cid);
if (StringUtils.isNoneEmpty(consumerMachineRoom)) {
if (mr2c.get(consumerMachineRoom) == null) {
mr2c.put(consumerMachineRoom, new ArrayList<String>());
}
mr2c.get(consumerMachineRoom).add(cid);
} else {
throw new IllegalArgumentException("Machine room is null for consumer id " + cid);
}
}
List<MessageQueue> allocateResults = new ArrayList<MessageQueue>();
//1.過濾出當前機房內的MQ隊列子集,在此基礎上使用分配算法挑選
String currentMachineRoom = machineRoomResolver.consumerDeployIn(currentCID);
List<MessageQueue> mqInThisMachineRoom = mr2Mq.remove(currentMachineRoom);
List<String> consumerInThisMachineRoom = mr2c.get(currentMachineRoom);
if (mqInThisMachineRoom != null && !mqInThisMachineRoom.isEmpty()) {
allocateResults.addAll(allocateMessageQueueStrategy.allocate(consumerGroup, currentCID, mqInThisMachineRoom, consumerInThisMachineRoom));
}
//2.不在同一機房,按照一般策略進行操作
for (String machineRoom : mr2Mq.keySet()) {
if (!mr2c.containsKey(machineRoom)) { // no alive consumer in the corresponding machine room, so all consumers share these queues
allocateResults.addAll(allocateMessageQueueStrategy.allocate(consumerGroup, currentCID, mr2Mq.get(machineRoom), cidAll));
}
}
return allocateResults;
}
3.4 AllocateMessageQueueByMachineRoom
- 基於機房分配算法
該算法適用於屬於同一個機房內部的消息,去分配queue。這種方式非常明確,基於上面的機房臨近分配算法
的場景,這種更徹底,直接指定基於機房消費的策略。這種方式具有強約定性,比如broker
名稱按照機房的名稱進行拼接,在算法中通過約定解析進行分配。
其代碼實現如下:
/**
* Computer room Hashing queue algorithm, such as Alipay logic room
*/
public class AllocateMessageQueueByMachineRoom implements AllocateMessageQueueStrategy {
private Set<String> consumeridcs;
@Override
public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll,
List<String> cidAll) {
List<MessageQueue> result = new ArrayList<MessageQueue>();
int currentIndex = cidAll.indexOf(currentCID);
if (currentIndex < 0) {
return result;
}
List<MessageQueue> premqAll = new ArrayList<MessageQueue>();
for (MessageQueue mq : mqAll) {
String[] temp = mq.getBrokerName().split("@");
if (temp.length == 2 && consumeridcs.contains(temp[0])) {
premqAll.add(mq);
}
}
int mod = premqAll.size() / cidAll.size();
int rem = premqAll.size() % cidAll.size();
int startIndex = mod * currentIndex;
int endIndex = startIndex + mod;
for (int i = startIndex; i < endIndex; i++) {
result.add(mqAll.get(i));
}
if (rem > currentIndex) {
result.add(premqAll.get(currentIndex + mod * cidAll.size()));
}
return result;
}
@Override
public String getName() {
return "MACHINE_ROOM";
}
public Set<String> getConsumeridcs() {
return consumeridcs;
}
public void setConsumeridcs(Set<String> consumeridcs) {
this.consumeridcs = consumeridcs;
}
3.5 AllocateMessageQueueConsistentHash
基於一致性hash算法
使用這種算法,會將consumer消費者
作爲Node節點構造成一個hash環,然後queue隊列
通過這個hash環來決定被分配給哪個consumer消費者
。
其基本模式如下:
什麼是一致性hash 算法 ?
一致性hash算法用於在分佈式系統中,保證數據的一致性而提出的一種基於hash環實現的算法,限於文章篇幅,不在這裏展開描述,有興趣的同學可以參考下 別人的博文:一致性哈希算法原理
算法實現上也不復雜,如下圖所示:
public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll,
List<String> cidAll) {
//省略部分代碼
List<MessageQueue> result = new ArrayList<MessageQueue>();
if (!cidAll.contains(currentCID)) {
log.info("[BUG] ConsumerGroup: {} The consumerId: {} not in cidAll: {}",
consumerGroup,
currentCID,
cidAll);
return result;
}
Collection<ClientNode> cidNodes = new ArrayList<ClientNode>();
for (String cid : cidAll) {
cidNodes.add(new ClientNode(cid));
}
//使用consumer id 構造hash環
final ConsistentHashRouter<ClientNode> router; //for building hash ring
if (customHashFunction != null) {
router = new ConsistentHashRouter<ClientNode>(cidNodes, virtualNodeCnt, customHashFunction);
} else {
router = new ConsistentHashRouter<ClientNode>(cidNodes, virtualNodeCnt);
}
//依次爲 隊列分配 consumer
List<MessageQueue> results = new ArrayList<MessageQueue>();
for (MessageQueue mq : mqAll) {
ClientNode clientNode = router.routeNode(mq.toString());
if (clientNode != null && currentCID.equals(clientNode.getKey())) {
results.add(mq);
}
}
return results;
}
3.6 AllocateMessageQueueByConfig
--基於配置分配算法
這種算法單純基於配置的,非常簡單,實際使用中可能用途不大。代碼如下:
public class AllocateMessageQueueByConfig implements AllocateMessageQueueStrategy {
private List<MessageQueue> messageQueueList;
@Override
public List<MessageQueue> allocate(String consumerGroup, String currentCID, List<MessageQueue> mqAll,
List<String> cidAll) {
return this.messageQueueList;
}
@Override
public String getName() {
return "CONFIG";
}
public List<MessageQueue> getMessageQueueList() {
return messageQueueList;
}
public void setMessageQueueList(List<MessageQueue> messageQueueList) {
this.messageQueueList = messageQueueList;
}
}
3.7 消費者如何指定分配算法?
默認情況下,消費者使用的是AllocateMessageQueueAveragely
算法,也可以自己指定:
public class DefaultMQPushConsumer{
/**
* Default constructor.
*/
public DefaultMQPushConsumer() {
this(MixAll.DEFAULT_CONSUMER_GROUP, null, new AllocateMessageQueueAveragely());
}
/**
* Constructor specifying consumer group, RPC hook and message queue allocating algorithm.
*
* @param consumerGroup Consume queue.
* @param rpcHook RPC hook to execute before each remoting command.
* @param allocateMessageQueueStrategy message queue allocating algorithm.
*/
public DefaultMQPushConsumer(final String consumerGroup, RPCHook rpcHook,
AllocateMessageQueueStrategy allocateMessageQueueStrategy) {
this.consumerGroup = consumerGroup;
this.allocateMessageQueueStrategy = allocateMessageQueueStrategy;
defaultMQPushConsumerImpl = new DefaultMQPushConsumerImpl(this, rpcHook);
}
}
4. 結束語
以上是從設計上簡單介紹了RocketMQ的投遞機制,如果想了解詳細的設計原理,可關注下方的我的公衆賬號,會同步更新,謝謝支持 ~
作者水平有限,歡迎留言指正吐槽~