訊息中介軟體—RocketMQ訊息消費(三)(訊息消費重試)

neuyu發表於2021-09-09

摘要:如果Consumer端消費訊息失敗,那麼RocketMQ是如何對失敗的異常情況進行處理?
前面兩篇RocketMQ訊息消費(一)/(二)篇,主要從Push/Pull兩種消費模式的簡要流程、長輪詢機制和Consumer端負載均衡這幾點內容出發,介紹了RocketMQ訊息消費的正常流程和細節內容,本篇內容將主要介紹Consumer端消費失敗的異常流程。
這裡先回顧往期RocketMQ技術分享的篇幅:
(1)訊息中介軟體—RocketMQ的RPC通訊(一)
(2)訊息中介軟體—RocketMQ的RPC通訊(二)
(3)訊息中介軟體—RocketMQ訊息傳送
(4)訊息中介軟體—RocketMQ訊息消費(一)
(5)訊息中介軟體—RocketMQ訊息消費(二)(push模式實現)

一、其他MQ中介軟體消費端可靠性的保障

在業務開發中,大家一定都遇到過業務工程因為各類異常(可能是業務工程本身的異常、JVM記憶體異常或者系統所在的虛擬機器當機等),而導致MQ中介軟體傳送過來的業務訊息消費失敗而無法再次消費該訊息的情況。目前,很多MQ訊息中介軟體都有相應的機制和方法來保證Consumer端消費訊息的可靠性。下面先來看看RabbitMQ和Kafka這兩款MQ訊息中介軟體是如何來保證消費者端訊息處理的可靠性的呢?

1.1 簡談RabbitMQ的手動訊息確認ACK機制

RabbitMQ提供了訊息確認機制。消費者在訂閱佇列時,可以在程式碼中手動設定autoAck引數為false,這時RabbitMQ會等待消費者顯式地回覆確認訊號(即為顯式地呼叫channel.basicAck(envelope.getDeliveryTag(), false)方法)後才從叢集中的記憶體(或磁碟)節點上移除訊息,從而保證了這條訊息不會因為消費失敗而導致丟失。

1.2 簡析Kafka訊息消費的手動提交

在Kafka中,也可以採用上面那種的消費後的確認機制,透過在Consumer端設定“enable.auto.commit”屬性為false後,待業務工程正常處理完消費後,在程式碼中手動呼叫KafkaConsumer例項的commitSync()方法提交(ps:這裡指的是同步阻塞commit消費的偏移量,等待Broker端的返回響應,需要注意Broker端在對commit請求做出響應之前,消費端會處於阻塞狀態,從而限制訊息的處理效能和整體吞吐量),以確保訊息能夠正常被消費。如果在消費過程中,消費端突然Crash,這時候消費偏移量沒有commit,等正常恢復後依然還會處理剛剛未commit的訊息。

二、RocketMQ消費失敗後的消費重試機制

對比了另外兩款MQ中介軟體後,接下來進入正題,主要來說說RocketMQ在消費失敗後的是如何來保證訊息消費的可靠性?

2.1 重試佇列與死信佇列的概念

在介紹RocketMQ的消費重試機制之前,需要先來說下“重試佇列”和“死信佇列”兩個概念。
(1)重試佇列:如果Consumer端因為各種型別異常導致本次消費失敗,為防止該訊息丟失而需要將其重新回發給Broker端儲存,儲存這種因為異常無法正常消費而回發給MQ的訊息佇列稱之為重試佇列。RocketMQ會為每個消費組都設定一個Topic名稱為“%RETRY%+consumerGroup”的重試佇列(這裡需要注意的是,這個Topic的重試佇列是針對消費組,而不是針對每個Topic設定的),用於暫時儲存因為各種異常而導致Consumer端無法消費的訊息。考慮到異常恢復起來需要一些時間,會為重試佇列設定多個重試級別,每個重試級別都有與之對應的重新投遞延時,重試次數越多投遞延時就越大。RocketMQ對於重試訊息的處理是先儲存至Topic名稱為“SCHEDULE_TOPIC_XXXX”的延遲佇列中,後臺定時任務按照對應的時間進行Delay後重新儲存至“%RETRY%+consumerGroup”的重試佇列中(具體細節後面會詳細闡述)。
(2)死信佇列:由於有些原因導致Consumer端長時間的無法正常消費從Broker端Pull過來的業務訊息,為了確保訊息不會被無故的丟棄,那麼超過配置的“最大重試消費次數”後就會移入到這個死信佇列中。在RocketMQ中,SubscriptionGroupConfig配置常量預設地設定了兩個引數,一個是retryQueueNums為1(重試佇列數量為1個),另外一個是retryMaxTimes為16(最大重試消費的次數為16次)。Broker端透過校驗判斷,如果超過了最大重試消費次數則會將訊息移至這裡所說的死信佇列。這裡,RocketMQ會為每個消費組都設定一個Topic命名為“%DLQ%+consumerGroup"的死信佇列。一般在實際應用中,移入至死信佇列的訊息,需要人工干預處理;

2.1 Consumer端回發訊息至Broker端

在業務工程中的Consumer端(Push消費模式下),如果訊息能夠正常消費需要在註冊的訊息監聽回撥方法中返回CONSUME_SUCCESS的消費狀態,否則因為各類異常消費失敗則返回RECONSUME_LATER的消費狀態。消費狀態的列舉型別如下所示:

public enum ConsumeConcurrentlyStatus {    //業務方消費成功
    CONSUME_SUCCESS,    //業務方消費失敗,之後進行重新嘗試消費
    RECONSUME_LATER;
}

如果業務工程對訊息消費失敗了,那麼則會丟擲異常並且返回這裡的RECONSUME_LATER狀態。這裡,在消費訊息的服務執行緒—consumeMessageService中,將封裝好的訊息消費任務ConsumeRequest提交至執行緒池—consumeExecutor非同步執行。從訊息消費任務ConsumeRequest的run()方法中會執行業務工程中註冊的訊息監聽回撥方法,並在processConsumeResult方法中根據業務工程返回的狀態(CONSUME_SUCCESS或者RECONSUME_LATER)進行判斷和做對應的處理(下面講的都是在消費通訊模式為叢集模型下的,廣播模型下的比較簡單就不再分析了)。
(1)業務方正常消費(CONSUME_SUCCESS):正常情況下,設定ackIndex的值為consumeRequest.getMsgs().size() - 1,因此後面的遍歷consumeRequest.getMsgs()訊息集合條件不成立,不會呼叫回發消費失敗訊息至Broker端的方法—sendMessageBack(msg, context)。最後,更新消費的偏移量;
(2)業務方消費失敗(RECONSUME_LATER):異常情況下,設定ackIndex的值為-1,這時就會進入到遍歷consumeRequest.getMsgs()訊息集合的for迴圈中,執行回發訊息的方法—sendMessageBack(msg, context)。這裡,首先會根據brokerName得到Broker端的地址資訊,然後透過網路通訊的Remoting模組傳送RPC請求到指定的Broker上,如果上述過程失敗,則建立一條新的訊息重新傳送給Broker,此時新訊息的Topic為“%RETRY%+ConsumeGroupName”—重試佇列的主題。其中,在MQClientAPIImpl例項的consumerSendMessageBack()方法中封裝了ConsumerSendMsgBackRequestHeader的請求體,隨後完成回發消費失敗訊息的RPC通訊請求(業務請求碼為:CONSUMER_SEND_MSG_BACK)。倘若上面的回發訊息流程失敗,則會延遲5S後重新在Consumer端進行重新消費。與正常消費的情況一樣,在最後更新消費的偏移量;

2.3 Broker端對於回發訊息處理的主要流程

Broker端收到這條Consumer端回發過來的訊息後,透過業務請求碼(CONSUMER_SEND_MSG_BACK)匹配業務處理器—SendMessageProcessor來處理。在完成一系列的前置校驗(這裡主要是“消費分組是否存在”、“檢查Broker是否有寫入許可權”、“檢查重試佇列數是否大於0”等)後,嘗試獲取重試佇列的TopicConfig物件(如果是第一次無法獲取到,則呼叫createTopicInSendMessageBackMethod()方法進行建立)。根據回發過來的訊息偏移量嘗試從commitlog日誌檔案中查詢訊息內容,若不存在則返回異常錯誤。
然後,設定重試佇列的Topic—“%RETRY%+consumerGroup”至MessageExt的擴充套件屬性“RETRY_TOPIC”中,並對根據延遲級別delayLevel和最大重試消費次數maxReconsumeTimes進行判斷,如果超過最大重試消費次數(預設16次),則會建立死信佇列的TopicConfig物件(用於後面將回發過來的訊息移入死信佇列)。在構建完成需要落盤的MessageExtBrokerInner物件後,呼叫“commitLog.putMessage(msg)”方法做訊息持久化。這裡,需要注意的是,在putMessage(msg)的方法裡會使用“SCHEDULE_TOPIC_XXXX”和對應的延遲級別佇列Id分別替換MessageExtBrokerInner物件的Topic和QueueId屬性值,並將原來設定的重試佇列主題(“%RETRY%+consumerGroup”)的Topic和QueueId屬性值做一個備份分別存入擴充套件屬性properties的“REAL_TOPIC”和“REAL_QID”屬性中。看到這裡也就大致明白了,回發給Broker端的消費失敗的訊息並非直接儲存至重試佇列中,而是會先存至Topic為“SCHEDULE_TOPIC_XXXX”的定時延遲佇列中。

疑問:上面說了RocketMQ的重試佇列的Topic是“%RETRY%+consumerGroup”,為啥這裡要儲存至Topic是“SCHEDULE_TOPIC_XXXX”的這個延遲佇列中呢?

在原始碼中搜尋下關鍵字—“SCHEDULE_TOPIC_XXXX”,會發現Broker端還存在著一個後臺服務執行緒—ScheduleMessageService(透過訊息儲存服務—DefaultMessageStore啟動),透過檢視原始碼可以知道其中有一個DeliverDelayedMessageTimerTask定時任務執行緒會根據Topic(“SCHEDULE_TOPIC_XXXX”)與QueueId,先查到邏輯消費佇列ConsumeQueue,然後根據偏移量,找到ConsumeQueue中的記憶體對映物件,從commitlog日誌中找到訊息物件MessageExt,並做一個訊息體的轉換(messageTimeup()方法,由定時延遲佇列訊息轉化為重試佇列的訊息),再次做持久化落盤,這時候才會真正的儲存至重試佇列中。看到這裡就可以解釋上面的疑問了,定時延遲佇列只是為了用於暫存的,然後延遲一段時間再將訊息移入至重試佇列中。RocketMQ設定不同的延時級別delayLevel,並且與定時延遲佇列相對應,具體原始碼如下:

    //省略
    private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";    /**
     * 定時延時訊息主題的佇列與延遲等級對應關係
     * @param delayLevel
     * @return
     */
    public static int delayLevel2QueueId(final int delayLevel) {        return delayLevel - 1;
    }

2.4 Consumer端消費重試機制

每個Consumer例項在啟動的時候就預設訂閱了該消費組的重試佇列主題,DefaultMQPushConsumerImpl的copySubscription()方法中的相關程式碼如下:

private void copySubscription() throws MQClientException {            //省略其他程式碼...
            switch (this.defaultMQPushConsumer.getMessageModel()) {                case BROADCASTING:                    break;                case CLUSTERING://如果訊息消費模式為叢集模式,還需要為該消費組對應一個重試主題
                    final String retryTopic = MixAll.getRetryTopic(this.defaultMQPushConsumer.getConsumerGroup());
                    SubscriptionData subscriptionData = FilterAPI.buildSubscriptionData(this.defaultMQPushConsumer.getConsumerGroup(),
                        retryTopic, SubscriptionData.SUB_ALL);                    this.rebalanceImpl.getSubscriptionInner().put(retryTopic, subscriptionData);                    break;                default:                    break;
            }            //省略其他程式碼...
      }

因此,這裡也就清楚了,Consumer端會一直訂閱該重試佇列主題的訊息,向Broker端傳送如下的拉取訊息的PullRequest請求,以嘗試重新再次消費重試佇列中積壓的訊息。

PullRequest [consumerGroup=CID_JODIE_1, messageQueue=MessageQueue [topic=%RETRY%CID_JODIE_1, brokerName=HQSKCJJIDRRD6KC, queueId=0], nextOffset=51]

最後,給出一張RocketMQ訊息重試機制的框圖(ps:這裡只是描述了訊息消費失敗後重試拉取的部分重要過程):


圖片描述

RocketMQ訊息重試機制.jpg

三、總結

RocketMQ的訊息消費(三)(訊息消費重試)篇幅就先分析到這裡了。關於RocketMQ訊息消費的內容比較多也比較複雜,需要讀者結合原始碼並多次debug(可以透過分別在Consumer端和Broker端的部分重要方法中列印重要物件中的各個屬性值的方式,來仔細研究下其中的過程),才可以對其有一個較為深刻的理解。限於筆者的才疏學淺,對本文內容可能還有理解不到位的地方,如有闡述不合理之處還望留言一起探討。



作者:癲狂俠
連結:


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4550/viewspace-2815326/,如需轉載,請註明出處,否則將追究法律責任。

相關文章