RocketMQ Consumer如何獲取並維護消費進度?

背景

Cosumer消息消費流程比較複雜,比較重要的有下面幾個模塊:維護消費進度,查找消息,消息過濾,負載均衡,消息處理,回發確認等。限於篇幅,這篇文章主要介紹Consumer是如何獲取並維護消費進度。由於以上幾個步驟都是緊密相連的,可能會出現互相穿插的情況。

消費進度文件

我們之前的文章RocketMQ 如何構建ConsumerQueue的?中講過,通過一個服務線程異步的從CommitLog中獲取已經寫入的消息,然後將消息位置,大小,tagsCode等關鍵信息保存至選好的ConsumerQueue中。ConsumerQueue是一個索引文件,保存了某個topic下面的消息在CommitLog中的位置等信息。我們消費消息,先從這個ConsumerQueue中讀取消息位置,然後再去CommitLog中取消息,這是典型的空間換時間的做法。與此同時,我們需要記錄下我們讀取到哪裏了,下次讀取的時候就從上次結束的地方繼續往前讀,所以我們還需要一個保存消費進度的文件。在RocketMQ中這個文件就是ConsumserOffset.json,在store/config目錄下,如下所示:
在這裏插入圖片描述
consumerOffset.json中的內容類似於這樣:
在這裏插入圖片描述
其中test_url@sub_localtest是主鍵,規則是topic@consumerGroup,內容就是每個ConsumerQueue的消費進度。例如0號Queue下個應該消費2599,1號Queue下個應該消費2602,6號Queue下個應該消費102。這個進度是按1累加的,對應於ConsumerQueue的固定的20個字節。
現在我們做個實驗,首先通過Producer發送一條消息(代碼很簡單,就不貼了),發送結果如下所示:

SendResult [sendStatus=SEND_OK, msgId=C0A84D05091058644D4664E1427C0000, offsetMsgId=C0A84D0000002A9F00000000001ED9BA, messageQueue=MessageQueue [topic=test_url, brokerName=broker-a, queueId=6], queueOffset=102]

從結果中可以看到,消息保存在了broker-a的MesssageQueue-6中的102號位置。然後我們啓動Consumer,消費這條消息:

2020-01-20 14:08:04.230 INFO  [MessageThread_3] c.c.r.example.simplest.Consumer - 收到消息:topic=test_url,msgId=C0A84D05091058644D4664E1427C0000

消息成功被消費了,然後我們再去看看ConsumerOffset.json文件內容:
在這裏插入圖片描述
看到沒?上次截圖中,6號Queue的消費進度是102,這次變成了103,已經成功消費了一條消息。

現在我們就要開始深究了:在Consumer消費消息時,這個消費進度是如何獲取並維護的呢?

從何處開始消費?

Consumer在通過調用

consumer.start();

啓動的時候,會加載消費進度,如下所示:

//DefaultMQPushConsumerImpl.start()方法
if (this.defaultMQPushConsumer.getOffsetStore() != null) {
     this.offsetStore = this.defaultMQPushConsumer.getOffsetStore(); // 如果本地有直接加載
} else {
	switch (this.defaultMQPushConsumer.getMessageModel()) {
                        case BROADCASTING:
                            this.offsetStore = new LocalFileOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup());
                            break;
                        case CLUSTERING:
                            this.offsetStore = new RemoteBrokerOffsetStore(this.mQClientFactory, this.defaultMQPushConsumer.getConsumerGroup()); // 集羣模式下生成一個RemoteBrokerOffsetSotre,消費進度就保存在broker端
                            break;
                        default:
                            break;
                    }
                    this.defaultMQPushConsumer.setOffsetStore(this.offsetStore);
                }
                this.offsetStore.load(); // 加載本地進度

上面提到了offsetStore對象,這是一個接口,如下所示:

public interface OffsetStore {
    void load() throws MQClientException;
    void updateOffset(final MessageQueue mq, final long offset, final boolean increaseOnly);
    long readOffset(final MessageQueue mq, final ReadOffsetType type);
    void persistAll(final Set<MessageQueue> mqs);
    void persist(final MessageQueue mq);
    void removeOffset(MessageQueue mq);
    Map<MessageQueue, Long> cloneOffsetTable(String topic);
    void updateConsumeOffsetToBroker(MessageQueue mq, long offset, boolean isOneway) throws RemotingException,
        MQBrokerException, InterruptedException, MQClientException;
}

OffsetStore提供了操作消費進度的方法,例如:加載消費進度,讀取消費進度,更新消費進度等等。在集羣消費模式下,消費進度並沒有持久化在Consumer端,而是保存在了遠程Broker端,例如上面使用的是RemoteBrokerOffsetStore類:

public class RemoteBrokerOffsetStore implements OffsetStore {
    private final static InternalLogger log = ClientLogger.getLog();
    private final MQClientInstance mQClientFactory; // 客戶端實例
    private final String groupName; // 集羣名稱
    private ConcurrentMap<MessageQueue, AtomicLong> offsetTable =
        new ConcurrentHashMap<MessageQueue, AtomicLong>(); // 每個Queue的消費進度
}

因爲消費進度保存在broker端,用於加載本地消費進度的load方法在RemoteBrokerOffsetStore中是空的,不做任何事,真正讀取消費進度是通過readOffset方法實現的。
目前爲止我們知道了,消費進度是存在broker端的一個consumerOffset.json文件中的,通過readOffset方法讀取這個文件就知道了從哪裏開始消費。

負載均衡簡單介紹

在深入瞭解讀取消費進度的readOffset方法前,需要簡單瞭解何時會調用這個方法。
我們文章開頭提到Consumer消費消息時的模塊,其中包括了負載均衡這個模塊。一個topic有多個消費隊列,而同一組group下面也可能有多個Consumer訂閱了這個topic,於是這些隊列需要按照一定策略分配給同一個組下面的Consumer消費,例如下面的平均分配:
在這裏插入圖片描述
在上圖中,TOPIC_A有5個隊列,有2個Consumer訂閱了,如果按照平均分配的話,那就是Consumer1消費其中3個隊列,Consumer2消費其中2個隊列。這個就是負載均衡。
一個Consumer分配到了幾個消息隊列,就會相應的創建幾個消息處理隊列(ProcessQueue,消費消息時會用到),並且此時會生成一個拉取消息的請求(PullRequest,請求消息時會用到),這個請求不是真正的發往broker端的獲取消息的請求,而是保存在一個阻塞隊列裏面,然後由專門的拉取消息的服務線程讀取它並組裝獲取消息請求,發送給broker端(這麼做當然是爲了獲得異步的好處)。這個請求PullRequest裏面就臨時保存着下個消費進度,如下所示:
在這裏插入圖片描述
只有這裏纔會生成一個PullRequest,後續該consumerGroup下該Queue的所有拉取消息都是重複使用該PullRequest,只是更新了其中的nextOffset等參數。PullRequest類如下所示:

public class PullRequest {
    private String consumerGroup; // 消費分組
    private MessageQueue messageQueue; // 消費隊列
    private ProcessQueue processQueue; // 消息處理隊列
    private long nextOffset; // 消費進度
    private boolean lockedFirst = false; // 是否鎖住
}

計算消費進度

在生成PullRequest時,就會計算從何處開始消費(computPullFromWhere)。RocketMQ默認的消費起始點是CONSUME_FROM_LAST_OFFSET(從上個offset開始消費),因此在computPullFromWhere方法中,走到的是這個case:

 case CONSUME_FROM_LAST_OFFSET: {
                long lastOffset = offsetStore.readOffset(mq, ReadOffsetType.READ_FROM_STORE);// 讀取偏移
                if (lastOffset >= 0) { // 正常偏移
                    result = lastOffset;
                }
                // First start,no offset
                else if (-1 == lastOffset) { // 初次啓動,broker沒有偏移,readOffset返回-1
                    if (mq.getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                        result = 0L; // 消息重試
                    } else {
                        try {
                            result = this.mQClientFactory.getMQAdminImpl().maxOffset(mq);// 獲取該消息隊列消費的最大偏移
                        } catch (MQClientException e) {
                            result = -1;
                        }
                    }
                } else {
                    result = -1; // 異常情況readOffset返回-2,這裏再返-1
                }
                break;
            }

這裏就走到了我們上面提到的readOffset方法了!

readOffset

readOffse方法t的入參是當前分配到的messageQueue和 固定的ReadOffsetType.READ_FROM_STORE,意思就從遠程Broker讀取該MessageQueue的消費進度。因此走到的是READ_FROM_STORE這個分支,如下所示:

case READ_FROM_STORE: {
                    try {
                        long brokerOffset = this.fetchConsumeOffsetFromBroker(mq);// 從broker獲取消費進度
                        AtomicLong offset = new AtomicLong(brokerOffset);
                        this.updateOffset(mq, offset.get(), false); // 更新消費進度,更新的是RemoteBrokerOffsetStore.offsetTable這個表
                        return brokerOffset;
                    }
                    // No offset in broker
                    catch (MQBrokerException e) {
                        return -1;
                    }
                    //Other exceptions
                    catch (Exception e) {
                        log.warn("fetchConsumeOffsetFromBroker exception, " + mq, e);
                        return -2;
                    }
                }

fetchConsumerOffsetFromBroker就是往該MessageQueue所在的broker發送獲取消費進度的請求了,底層通訊之前的文章已經講過了,這裏就不在贅述了。在Broker端,消費進度保存在ConsumerOffsetManager裏面:
在這裏插入圖片描述
key是Topic@ConsumerGroup,value存的是queueId和offset的映射關係。查找消費進度的代碼如下:

long offset =
            this.brokerController.getConsumerOffsetManager().queryOffset(
                requestHeader.getConsumerGroup(), requestHeader.getTopic(), requestHeader.getQueueId());

從key來看,topic的消費進度是按照ConsumerGroup來區分的,不同的ConsumerGroup下該MessageQueue的消費進度互不影響,這一點也很好理解。

消息獲取與更新消費進度

到目前爲止,我們知道了每個MessageQueue的消費進度存在對應的Broker端,在負載均衡服務對每個Topic做負載均衡的時候,創建了PullRequest,並讀取了消費進度offset。然後將PullRequest放入了一個阻塞隊列中(pullRequestQueue),供專門的拉取消息線程服務(PullMessageService)讀取,然後發起真正的拉取消息請求。這裏更新消費進度與獲取消息密切相關,因此會涉及一些獲取消息的內容。

PullMessageSevice是一個服務線程,專門用於拉取消息,其run方法如下所示:

@Override
    public void run() {
        log.info(this.getServiceName() + " service started");

        while (!this.isStopped()) {
            try {
                PullRequest pullRequest = this.pullRequestQueue.take(); // 從阻塞隊列中獲取一個PullsRequest
                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中取出PullRequest,然後就是開始調用pullMessage方法獲取消息了。調用最終會走到DefaultMQPushConsumerImpl的pullMessage方法中來,代碼很多並且如何拉取消息不是本次重點內容,我們這裏只貼最後的發送請求部分,理解的其中的參數,也就明白了消息獲取請求。

try {
            this.pullAPIWrapper.pullKernelImpl(
                pullRequest.getMessageQueue(), //消息隊列
                subExpression,// 訂閱表達式,例如"TAG_A"
                subscriptionData.getExpressionType(), // 表達式類型,例如"TAG"
                subscriptionData.getSubVersion(), // 版本號
                pullRequest.getNextOffset(), // 下個消息進度
                this.defaultMQPushConsumer.getPullBatchSize(), // 一次性拉取多少條,默認32
                sysFlag,// 一些標誌位集合,暫時不關心
                commitOffsetValue, //
                BROKER_SUSPEND_MAX_TIME_MILLIS,// 長輪詢時被hold住時間,默認15秒
                CONSUMER_TIMEOUT_MILLIS_WHEN_SUSPEND,// 調用超時時間,默認30秒
                CommunicationMode.ASYNC, // 異步通訊
                pullCallback// 回調
            );
        } catch (Exception e) {
            log.error("pullKernelImpl exception", e);
            this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION);
        }
Consumer端更新消費進度

由於發送消息獲取請求是異步操作,返回處理在pullCallback裏面,因此我們可以大膽猜測,Consumer端消費進度的更新也肯定在這裏面。確實,在pullCallback裏面會將PullRequest的nextOffset更新:
在這裏插入圖片描述

broker端更新消費進度

由於消息處理不是本次重點內容,所以Broker端對獲取消息的處理,我們不打算深入,僅僅需要知道Broker端獲取消息後,會計算出一個nextBeginOffset,它就是下個消費進度,然後會返回到Consumer端去供Consumer端更新進度,如下所示:

nextBeginOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE);

其次,獲取完消息後,broker端也會更新消費進度,如下所示:

boolean storeOffsetEnable = brokerAllowSuspend; // brokerAllowSuspend默認是true,如果沒有消息就會hold住請求
        storeOffsetEnable = storeOffsetEnable && hasCommitOffsetFlag; // 拉取消息時如果允許提交消費進度,commitOffsetFlag就有
        storeOffsetEnable = storeOffsetEnable 
            && this.brokerController.getMessageStoreConfig().getBrokerRole() != BrokerRole.SLAVE;// 所以Broker master節點默認情況下這個是true
        if (storeOffsetEnable) {
            this.brokerController.getConsumerOffsetManager().commitOffset(RemotingHelper.parseChannelRemoteAddr(channel),
                requestHeader.getConsumerGroup(), requestHeader.getTopic(), requestHeader.getQueueId(), requestHeader.getCommitOffset()); // 保存消費進度,寫入offsetTable
        }

消費進度持久化

Broker更新消費進度,僅僅是更新了offsetTable這個表,並沒有涉及到ConsumerOffset.json這個文件。其實,在Broker初始化時,會啓動一項定時任務,定期保存tableOffset到ConsumerOffset.json文件中,如下所示:

this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
                @Override
                public void run() {
                    try {
                        BrokerController.this.consumerOffsetManager.persist(); // 保存文件
                    } catch (Throwable e) {
                        log.error("schedule persist consumerOffset error.", e);
                    }
                }
            }, 1000 * 10, this.brokerConfig.getFlushConsumerOffsetInterval(), TimeUnit.MILLISECONDS);

flushConsumerOffsetIntervval默認是5s,也就是每隔5保存一次消費進度到文件中。保存的過程是先將原來的文件存到ConsumerOffset.json.bak文件中,然後將新的內容存入ConsumerOffset.json文件。
至此,ConsumerQueue的消費進度維護就算完成了。

小結

RocketMQ每個Topic的消息,在各自的ConsumerGroup下,每個MessageQueue的消費進度是存在broker端的一個consumerOffset.json文件中。Consumer端啓動時,會創建PullRequest請求,此時會向Broker發送獲取下個消費進度的請求,Broker讀取下個消費進度並返回給Consumer端。然後Consumer通過單獨的服務線程讀取PullRequest並據此拉取消息,Broker端獲取到消息後,會及時更新消費進度。此外還有一個單獨的定時任務定期保存消費進度到文件,並備份原文件。

發佈了379 篇原創文章 · 獲贊 87 · 訪問量 59萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章