7-RocketMQ拉取訊息

LZC發表於2021-07-07
public class Consumer {
    public static void main(String[] args) throws InterruptedException, MQClientException {
        // 例項化消費者
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("GroupNameDemo");
        // 設定NameServer的地址
        consumer.setNamesrvAddr("localhost:9876");
        // 訂閱一個或者多個Topic,以及Tag來過濾需要消費的訊息
        consumer.subscribe(
                "TopicDemo",
                "*"); // 多個用 || 分割,* 表示所有
        // 註冊回撥實現類來處理從broker拉取回來的訊息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
                // 標記該訊息已經被成功消費
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        // 啟動消費者例項
        consumer.start();
        System.out.printf("Consumer Started.%n");
    }
}

第一步:構建主題的訂閱關係DefaultMQPushConsumerImpl#subscribe(String topic, String subExpression),將需要訂閱的主題資訊存放到RebalanceImpl類中的subscriptionInner屬性:

protected final ConcurrentMap<String /* topic */, SubscriptionData> subscriptionInner =
        new ConcurrentHashMap<String, SubscriptionData>();

訂閱重試主題訊息。RocketMQ訊息重試是以消費組為單位,而不是主題,訊息重試主題名為%RETRY%+消費組名。消費者在啟動的時候會自動訂閱該主題,參與該主題的訊息佇列負載。

第二步:註冊回撥方法。當拉取到訊息時會呼叫這個方法來處理訊息。

第三步:啟動消費者。初始化MQClientInstanceRebalanceImple(訊息重新負載實現類)等。向MQClientInstance註冊消費者,並啟動MQClientInstance,在一個JVM中的所有消費者、生產者持有同一個MQClientInstanceMQClientInstance只會啟動一次。

訊息消費有兩種模式:廣播模式與叢集模式,廣播模式比較簡單,每一個消費者需要去拉取訂閱主題下所有消費佇列的訊息,本節主要基於叢集模式。在叢集模式下,同一個消費組內有多個訊息消費者,同一個主題存在多個消費佇列,那麼消費者如何進行訊息佇列負載呢?從上文啟動流程也知道,每一個消費組內維護一個執行緒池來消費訊息,那麼這些執行緒又是如何分工合作的呢?

訊息佇列負載,通常的做法是一個訊息佇列在同一時間只允許被同一個消費組中的一個訊息消費者消費,一個訊息消費者可以同時消費多個訊息佇列,那麼RocketMQ是如何實現的呢?

MQClientInstance#start的啟動流程中可以看出,RocketMQ使用一個單獨的執行緒PullMessageService來負責訊息的拉取。

PullMessageService

PullMessageService繼承了ServiceThreadServiceThread實現了Runnable介面,PullMessageServiceMQClientInstance中的屬性並跟隨MQClientInstance啟動。

檢視它的run方法

// PullMessageService.java
@Override
public void run() {
    log.info(this.getServiceName() + " service started");
    // stopped 宣告為 volatile
    // 每執行一次業務邏輯檢測一下其執行狀態,
    // 可以通過其他執行緒將stopped設定為true從而停止該執行緒。
    while (!this.isStopped()) {
        try {
            // 從pullRequestQueue中獲取一個PullRequest訊息拉取任務,
            // 如果pullRequest Queue為空,則執行緒將阻塞,直到有拉取任務被放入。
            PullRequest pullRequest = this.pullRequestQueue.take();
            // 呼叫pullMessage方法進行訊息拉取
            this.pullMessage(pullRequest);
        } catch (InterruptedException ignored) {
        } catch (Exception e) {
            log.error("Pull Message Service Run Method exception", e);
        }
    }

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

這裡主要是從pullRequestQueue獲取任務,pullRequestQueue的型別是LinkedBlockingQueue<PullRequest>

任務的功能就是去拉取訊息。接下來需要搞清楚PullRequest是什麼時候被新增的。

PullMessageService提供延遲新增與立即新增2種方式將PullRequest放入到pullRequestQueue中:

PullMessageService#executePullRequestLater

PullMessageService#executePullRequestImmediately

檢視PullMessageService#executePullRequestImmediately方法的呼叫鏈可以發現,主要有兩個地方會呼叫,一個是在RocketMQ根據PullRequest拉取任務執行完一次訊息拉取任務後,又將PullRequest物件放入到pullRequestQueue,第二個是在RebalancceImpl中建立,RebalanceImpl實現了訊息佇列負載機制,也就是PullRequest物件真正建立的地方,具體建立邏輯下面再分析。

先來看看PullRequest類的資料結構

public class PullRequest {
    // 消費者組
    private String consumerGroup;
    // 待拉取消費佇列
    private MessageQueue messageQueue;
    // 訊息處理佇列,從Broker拉取到的訊息先存入Proccess Queue,
    // 然後再提交到消費者消費執行緒池消費。
    private ProcessQueue processQueue;
    // 待拉取的MessageQueue偏移
    private long nextOffset;
    // 是否被鎖定
    private boolean lockedFirst = false;
}

下面來檢視從pullRequestQueue佇列中拿到PullRequest資訊後是做了什麼

// PullMessageService#pullMessage
private void pullMessage(final PullRequest pullRequest) {
    final MQConsumerInner consumer = this.mQClientFactory.selectConsumer(pullRequest.getConsumerGroup());
    if (consumer != null) {
        DefaultMQPushConsumerImpl impl = (DefaultMQPushConsumerImpl) consumer;
        impl.pullMessage(pullRequest);
    } else {
        log.warn("No matched consumer for the PullRequest {}, drop it", pullRequest);
    }
}
  1. PullRequest中獲取ProcessQueue

    • 如果處理佇列當前狀態被丟棄,結束本次訊息拉取,如果沒有被丟棄,更新ProcessQueue的lastPullTimestamp為當前時間戳;

    • 如果消費者狀態異常,則將拉取任務延遲1s再次放入到PullMessageService的拉取任務佇列中

    • 如果當前消費者被掛起,則將拉取任務延遲1s再次放入到PullMessageService的拉取任務佇列中,結束本次訊息拉取。

final ProcessQueue processQueue = pullRequest.getProcessQueue();
if (processQueue.isDropped()) {
    log.info("the pull request[{}] is dropped.", pullRequest.toString());
    return;
}

pullRequest.getProcessQueue().setLastPullTimestamp(System.currentTimeMillis());

try {
    this.makeSureStateOK();
} catch (MQClientException e) {
    log.warn("pullMessage exception, consumer state not ok", e);
    this.executePullRequestLater(pullRequest, pullTimeDelayMillsWhenException);
    return;
}

if (this.isPause()) {
    log.warn("consumer was paused, execute pull request later. instanceName={}, group={}", this.defaultMQPushConsumer.getInstanceName(), this.defaultMQPushConsumer.getConsumerGroup());
    this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_SUSPEND);
    return;
}
  1. 進行訊息拉取流控。從訊息消費數量與消費間隔兩個維度進行控制。

    • 未訊息處理總數,如果ProcessQueue當前處理的訊息條數超過了1000將觸發流控,放棄本次拉取任務,並且該佇列的下一次拉取任務將在50毫秒後才加入到拉取任務佇列中,每觸發1000次流控後輸出提示語。
    • 未處理訊息大小,如果ProcessQueue當前處理的訊息大小超過100MB,放棄本次拉取任務,並且該佇列的下一次拉取任務將在50毫秒後才加入到拉取任務佇列中,每觸發1000次流控後輸出提示語。
// 獲取 ProcessQueue 中的訊息數量
long cachedMessageCount = processQueue.getMsgCount().get();
// 獲取 ProcessQueue 中快取的訊息大小
long cachedMessageSizeInMiB = processQueue.getMsgSize().get() / (1024 * 1024);

if (cachedMessageCount > this.defaultMQPushConsumer.getPullThresholdForQueue()) {
    this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
    if ((queueFlowControlTimes++ % 1000) == 0) {
        log.warn(
            "the cached message count exceeds the threshold {}, so do flow control, minOffset={}, maxOffset={}, count={}, size={} MiB, pullRequest={}, flowControlTimes={}",
            this.defaultMQPushConsumer.getPullThresholdForQueue(), processQueue.getMsgTreeMap().firstKey(), processQueue.getMsgTreeMap().lastKey(), cachedMessageCount, cachedMessageSizeInMiB, pullRequest, queueFlowControlTimes);
    }
    return;
}

if (cachedMessageSizeInMiB > this.defaultMQPushConsumer.getPullThresholdSizeForQueue()) {
    this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
    if ((queueFlowControlTimes++ % 1000) == 0) {
        log.warn(
            "the cached message size exceeds the threshold {} MiB, so do flow control, minOffset={}, maxOffset={}, count={}, size={} MiB, pullRequest={}, flowControlTimes={}",
            this.defaultMQPushConsumer.getPullThresholdSizeForQueue(), processQueue.getMsgTreeMap().firstKey(), processQueue.getMsgTreeMap().lastKey(), cachedMessageCount, cachedMessageSizeInMiB, pullRequest, queueFlowControlTimes);
    }
    return;
}
  1. ProcessQueue中佇列最大偏移量與最小偏離量的間距

    • 非順序訊息,預設情況不能超過2000,否則觸發流控,放棄本次拉取任務,並且該佇列的下一次拉取任務將在50毫秒後才加入到拉取任務佇列中

    • 順序訊息

      • 如果ProcessQueue是鎖定狀態,獲取服務端的消費偏移量offset

        pullRequest.setNextOffset(offset)

      • 如果ProcessQueue未鎖定狀態,放棄本次拉取任務,並且該佇列的下一次拉取任務將在50毫秒後才加入到拉取任務佇列中

if (!this.consumeOrderly) {
    if (processQueue.getMaxSpan() > this.defaultMQPushConsumer.getConsumeConcurrentlyMaxSpan()) {
        this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
        if ((queueMaxSpanFlowControlTimes++ % 1000) == 0) {
            log.warn(
                "the queue's messages, span too long, so do flow control, minOffset={}, maxOffset={}, maxSpan={}, pullRequest={}, flowControlTimes={}",
                processQueue.getMsgTreeMap().firstKey(), processQueue.getMsgTreeMap().lastKey(), processQueue.getMaxSpan(),
                pullRequest, queueMaxSpanFlowControlTimes);
        }
        return;
    }
} else {
    if (processQueue.isLocked()) {
        if (!pullRequest.isLockedFirst()) {
            final long offset = this.rebalanceImpl.computePullFromWhere(pullRequest.getMessageQueue());
            boolean brokerBusy = offset < pullRequest.getNextOffset();
            // 省略......
            pullRequest.setLockedFirst(true);
            pullRequest.setNextOffset(offset);
        }
    } else {
        this.executePullRequestLater(pullRequest, pullTimeDelayMillsWhenException);
        log.info("pull message later because not locked in broker, {}", pullRequest);
        return;
    }
}
  1. 拉取該主題訂閱資訊,如果為空,結束本次訊息拉取,關於該佇列的下一次拉取任務延遲3s。
final SubscriptionData subscriptionData = this.rebalanceImpl.getSubscriptionInner().get(pullRequest.getMessageQueue().getTopic());
if (null == subscriptionData) {
    this.executePullRequestLater(pullRequest, pullTimeDelayMillsWhenException);
    log.warn("find the consumer's subscription failed, {}", pullRequest);
    return;
}
  1. 呼叫PullAPIWrapper.pullKernelImpl方法後與服務端互動
try {
    this.pullAPIWrapper.pullKernelImpl(
        pullRequest.getMessageQueue(), // 從哪個訊息消費佇列拉取訊息
        subExpression, // 訊息過濾表示式
        subscriptionData.getExpressionType(), // 訊息表示式型別,分為TAG、SQL92。
        subscriptionData.getSubVersion(),
        pullRequest.getNextOffset(), // 訊息拉取偏移量
        this.defaultMQPushConsumer.getPullBatchSize(), // 本次拉取最大訊息條數,預設32條
        sysFlag,
        commitOffsetValue,
        BROKER_SUSPEND_MAX_TIME_MILLIS,
        CONSUMER_TIMEOUT_MILLIS_WHEN_SUSPEND,
        CommunicationMode.ASYNC, // 訊息拉取模式,預設為非同步拉取
        pullCallback // 從Broker拉取到訊息後的回撥方法。
    );
} catch (Exception e) {
    log.error("pullKernelImpl exception", e);
    // 異常後,放棄本次拉取任務,並且該佇列的下一次拉取任務將在50毫秒後才加入到拉取任務佇列中
    this.executePullRequestLater(pullRequest, pullTimeDelayMillsWhenException);
}
  1. 首先將拉取到的訊息存入ProcessQueue,然後將拉取到的訊息提交到ConsumeMessageService中供消費者消費,該方法是一個非同步方法,也就是PullCallBack將訊息提交到ConsumeMessageService中就會立即返回,至於這些訊息如何消費,PullCallBack不關注。

    然後根據pullInterval引數,如果pullInterval>0,則等待pullInterval毫秒後將PullRequest物件放入到PullMessageService的pullRequestQueue中,該訊息佇列的下次拉取即將被啟用,達到持續訊息拉取,實現準實時拉取訊息的效果。

boolean dispatchToConsume = processQueue.putMessage(pullResult.getMsgFoundList());
DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
    pullResult.getMsgFoundList(),
    processQueue,
    pullRequest.getMessageQueue(),
    dispatchToConsume);

if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
    DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
                                                           DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
} else {
    DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
}

PullMessageService在啟動時由於LinkedBlockingQueue<PullRequest>pullRequestQueue中沒有PullRequest物件,故PullMessageService執行緒將阻塞。

問題1:PullRequest物件在什麼時候建立並加入到pullRequestQueue中以便喚醒PullMessageService執行緒。

問題2:叢集內多個消費者是如何負載主題下的多個消費佇列,並且如果有新的消費者加入時,訊息佇列又會如何重新分佈。

檢視RebalanceService類的run方法,RebalanceServiceMQClientInstance中的屬性並跟隨MQClientInstance啟動。

public class RebalanceService extends ServiceThread {
    private static long waitInterval =
        Long.parseLong(System.getProperty(
            "rocketmq.client.rebalance.waitInterval", "20000"));
    @Override
    public void run() {
        log.info(this.getServiceName() + " service started");
        while (!this.isStopped()) {
            this.waitForRunning(waitInterval);
            this.mqClientFactory.doRebalance();
        }

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

RebalanceService執行緒預設每隔20s執行一次mqClientFactory.doRebalance()方法,可以使用Drocketmq.client.rebalance.waitInterval=interval來改變預設值。

// MQClientInstance#doRebalance
public class MQClientInstance {
    public void doRebalance() {
        for (Map.Entry<String, MQConsumerInner> entry : this.consumerTable.entrySet()) {
            MQConsumerInner impl = entry.getValue();
            if (impl != null) {
                try {
                      // 這裡面最終呼叫的是RebalanceImpl#doRebalance
                    impl.doRebalance();
                } catch (Throwable e) {
                    log.error("doRebalance exception", e);
                }
            }
        }
    }
}

MQClientIinstance遍歷已註冊的消費者,對消費者執行doRebalance()方法。

public abstract class RebalanceImpl  {
    public void doRebalance(final boolean isOrder) {
          // 獲取消費者所有的訂閱資訊
        Map<String, SubscriptionData> subTable = this.getSubscriptionInner();
        if (subTable != null) {
            for (final Map.Entry<String, SubscriptionData> entry : subTable.entrySet()) {
                final String topic = entry.getKey();
                try {
                      // 訊息佇列負載與重新分配
                    this.rebalanceByTopic(topic, isOrder);
                } catch (Throwable e) {
                    if (!topic.startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                        log.warn("rebalanceByTopic Exception", e);
                    }
                }
            }
        }

        this.truncateMessageQueueNotMyTopic();
    }
}

每個DefaultMQPushConsumerImpl都持有一個單獨的RebalanceImpl物件,該方法主要是遍歷訂閱資訊對每個主題的佇列進行重新負載。RebalanceImplMap<String, SubscriptionData> subTable在呼叫消費者DefaultMQPushConsumerImpl#subscribe方法時填充。如果訂閱資訊傳送變化,例如呼叫了unsubscribe方法,則需要將不關心的主題消費佇列從processQueueTable中移除。

接下來重點分析RebalanceImpl#rebalanceByTopic來分析RocketMQ是如何針對單個主題進行訊息佇列重新負載(以叢集模式)。

public abstract class RebalanceImpl {
    private void rebalanceByTopic(final String topic, final boolean isOrder) {
        switch (messageModel) {
            case BROADCASTING: {
                // 廣播模式
                break;
            }
            case CLUSTERING: {
                // 叢集模式
                // 第一步:
                // 獲取佇列資訊
                Set<MessageQueue> mqSet = this.topicSubscribeInfoTable.get(topic);
                // 獲取該消費組內當前所有的消費者客戶端ID
                List<String> cidAll = this.mQClientFactory.findConsumerIdList(topic, consumerGroup);
                // 省略......
                if (mqSet != null && cidAll != null) {
                    List<MessageQueue> mqAll = new ArrayList<MessageQueue>();
                    mqAll.addAll(mqSet);
                    // 第二步:
                    // 對訊息佇列排序
                    Collections.sort(mqAll);
                    // 對消費者排序
                    Collections.sort(cidAll);

                    // 第三步
                    // 佇列負載策略
                    AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;

                    List<MessageQueue> allocateResult = null;
                    try {
                        allocateResult = strategy.allocate(
                                this.consumerGroup,
                                this.mQClientFactory.getClientId(),
                                mqAll,
                                cidAll);
                    } catch (Throwable e) {
                        // 省略......
                        return;
                    }
                    Set<MessageQueue> allocateResultSet = new HashSet<MessageQueue>();
                    if (allocateResult != null) {
                        allocateResultSet.addAll(allocateResult);
                    }
                    boolean changed = this.updateProcessQueueTableInRebalance(topic, allocateResultSet, isOrder);
                  // 省略......
                }
                break;
            }
            default:
                break;
        }
    }
}

第一步:從主題訂閱資訊快取表中獲取主題的佇列資訊;傳送請求從Broker中獲取該消費組內當前所有的消費者客戶端ID,主題topic的佇列可能分佈在多個Broker上,那請求發往哪個Broker呢?RocketeMQ從主題的路由資訊表中隨機選擇一個Broker。Broker為什麼會存在消費組內所有消費者的資訊呢?我們不妨回憶一下消費者在啟動的時候會向MQClientInstance中註冊消費者,然後MQClientInstance會向所有的Broker傳送心跳包,心跳包中包含MQClientInstance的消費者資訊。如果mqSet、cidAll任意一個為空則忽略本次訊息佇列負載。

第二步:首先對cidAll, mqAll排序,這個很重要,同一個消費組內看到的檢視保持一致,確保同一個消費佇列不會被多個消費者分配。RocketMQ訊息佇列分配演算法介面。

第三步:佇列負載策略。

RocketMQ預設提供5種分配演算法。以AllocateMessageQueueAveragely為例:

如果現在有8個訊息消費佇列q1, q2, q3, q4, q5, q6, q7, q8,

有3個消費者c1, c2, c3,那麼根據該負載演算法,訊息佇列分配如下:

c1: q1, q2, q3

c2:q4, q5, q6

c3:q7, q8

第四步:呼叫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類裡面的該方法為空。

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

RocketMQ訊息拉取由PullMessageServiceRebalanceService共同協作完成

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章