RocketMQ進擊(七)盤一盤RocketMQ的重試機制


楔子:翻了帖子兩三天,硬是沒有找到哪個帖子能證明生產端的消息重試是確實重試了的。大多要麼是對概念、源碼說明了一下,或者把實現示例貼貼,但基本並沒有有效測試證明。想了想,還是自己來捋一捋這 RocketMQ 的消息重試機制。

由於 MQ 經常處於龐大的分佈式系統中,考慮到網絡波動、服務宕機、程序異常等因素,很可能會出現消息發送或者消費失敗的問題。因此,如果沒有消息重試,就有可能造成消息丟失,最終影響到系統某些業務或流程。所以,大部分消息中間件都對消息重試提供了很好的支持。RocketMQ 消息重試分爲兩種:Producer 發送重試 和 Consumer 消費重試

1. 生產端重試

也叫消息重投。一般由於網絡抖動等原因,Producer 向 Broker 發送消息時沒有成功,導致最終 Consumer 無法消費消息,此時 RocketMQ 會自動進行消息重試/重投。我們可以手動設置發送失敗時的重試次數。默認爲 2 次,但加上程序本身的 1 次發送,如果失敗,總共會發送 3 次,也就是 N + 1 次。N 爲 retryTimesWhenSendFailed。

1.2. 源碼分析

驗證前,我們先來擼一下源碼:

private SendResult sendDefaultImpl(Message msg, CommunicationMode communicationMode, SendCallback sendCallback, long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
    // ...源碼省略...
    // 獲取當前時間
    long beginTimestampFirst = System.currentTimeMillis();
    long beginTimestampPrev = beginTimestampFirst;
    // 去服務器看下有沒有主題消息
    TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic());
    if(topicPublishInfo != null && topicPublishInfo.ok()) {
        // ...源碼省略...
        // 通過這裏可以很明顯看出,如果是同步消息,則重試 變量值+1次;如果不是同步發送消息,那麼消息重試只有1次
        int timesTotal = communicationMode == CommunicationMode.SYNC?1 + this.defaultMQProducer.getRetryTimesWhenSendFailed():1;
        // 重試累計次數
        int times = 0;
        String[] brokersSent = new String[timesTotal];

        while(true) {
            label129: {
                String info;
                // 如果重試累計次數 小於 總重試次數閥值,則輪詢獲取服務器主題消息
                if(times < timesTotal) {
                    info = null == mq?null:mq.getBrokerName();
                    MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, info);
                    if(mqSelected != null) {
                        mq = mqSelected;
                        brokersSent[times] = mqSelected.getBrokerName();

                        long endTimestamp;
                        try {
                            beginTimestampPrev = System.currentTimeMillis();
                            if(times > 0) {
                                msg.setTopic(this.defaultMQProducer.withNamespace(msg.getTopic()));
                            }
                            // 消息投送耗時
                            long costTime = beginTimestampPrev - beginTimestampFirst;
                            // 如果 消息投送耗時 小於等於 超時時間,則向 Broker 進行消息重投;否則,超時
                            if(timeout >= costTime) {
                                // 調用sendKernelImpl開始發送消息
                                sendResult = this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout - costTime);
                                // ...源碼省略...
                                default:
                                    break label129;
                                }
                            }

                            // 設置超時
                            callTimeout = true;
                        } catch (RemotingException var26) {
                            // ...源碼省略...
                            // 當出現 RemotingException、MQClientException 和部分 MQBrokerException 時會重投
                            break label129;
                        } catch (MQClientException var27) {
                            // ...源碼省略...
                            // 當出現 RemotingException、MQClientException 和部分 MQBrokerException 時會重投
                            break label129;
                        } catch (MQBrokerException var28) {
                            // ...源碼省略...
                            // 當出現 RemotingException、MQClientException 和部分 MQBrokerException 時會重投
                            switch(var28.getResponseCode()) {
                            case 1:
                            case 14:
                            case 16:
                            case 17:
                            case 204:
                            case 205:
                                break label129;
                            default:
                                if(sendResult != null) {
                                    return sendResult;
                                } else {
                                    throw var28;
                                }
                            }
                        } catch (InterruptedException var29) {
                            // 源碼省略......
                            throw var29;
                        }
                    }
                }

                if(sendResult != null) {
                    return sendResult;
                }

                // 重試日誌
                info = String.format("Send [%d] times, still failed, cost [%d]ms, Topic: %s, BrokersSent: %s", new Object[]{Integer.valueOf(times), Long.valueOf(System.currentTimeMillis() - beginTimestampFirst), msg.getTopic(), Arrays.toString(brokersSent)});
                info = info + FAQUrl.suggestTodo("http://rocketmq.apache.org/docs/faq/");
                MQClientException mqClientException = new MQClientException(info, (Throwable)exception);
                // 如果是消息發送/重試/重投超時,則拋出異常。如果還有重試次數,該異常不會再對該消息進行重試
                if(callTimeout) {
                    throw new RemotingTooMuchRequestException("sendDefaultImpl call timeout");
                }

                // ...源碼省略...
                // 默認走 MQClientException 異常
                throw mqClientException;
            }
            // 重試次數累加
            ++times;
        }
    } else {
        // 如果沒有可用 Topic,且 NamesrvAddr 地址列表不爲空,則構建 MQClientException,沒有重試
        List<String> nsList = this.getmQClientFactory().getMQClientAPIImpl().getNameServerAddressList();
        if(null != nsList && !nsList.isEmpty()) {
            throw (new MQClientException("No route info of this topic, " + msg.getTopic() + FAQUrl.suggestTodo("http://rocketmq.apache.org/docs/faq/"), (Throwable)null)).setResponseCode(10005);
        } else {
            // 如果沒有可用 Topic,且 NamesrvAddr 地址列表爲空,則構建 MQClientException,沒有重試
            throw (new MQClientException("No name server address, please set it." + FAQUrl.suggestTodo("http://rocketmq.apache.org/docs/faq/"), (Throwable)null)).setResponseCode(10004);
        }
    }
}

從 DefaultMQProducer 源碼分析可以看出:

生產者重試幾次?

  • 同步發送:默認 retryTimesWhenSendFailed 是 2次重試,所以除了正常調用 1 次外,發送消息如果失敗了會重試 2 次;超時異常不會重試
  • 異步發送:不會重試(調用總次數等於1)
  • 單向發送:oneway 沒有任何保證

什麼時候重試?

  • 當使用 RocketMQ 的 send() 方法發送消息時,出現 RemotingException、MQClientException 和部分 MQBrokerException 時會重投(見上面源碼分析)。需要注意的是消息重試/重投是發生在 RocketMQ 內部,我們所能干預的是重試次數等。
  • 在多條消息發送的 for 循環下的 try catch 可以實現服務降級,防止前一條消息的發送失敗阻斷後面的消息發送,但是起不到消息重試的作用,原因如上,消息重試/重投是發生在 RocketMQ 內部。

怎麼重試?
每次重試都會重新進行負載均衡(會考慮發送失敗的因素),使用 selectOneMessageQueue 重新選擇 MessageQueue,這樣增大發送消息成功的可能性。

隔多久重試?
立即重試,中間沒有單獨的間隔時間。見源碼 真死循環 while(true) 的 sendDefaultImpl 方法,裏面有個 label129 標記,只要上一次發送消息後被標記爲 label129,就會立馬進行下一次消息重投,沒有時間間隔。

 

1.3. 代碼示例

配置一個不存在的 nameServer 地址,實現一個設置重試次數 retryTimesWhenSendFailed 爲 2,但總共會重試/重投 3 次(因爲 N + 1)的消息生產者:

public class RetryMultiMqProducer {

    // Topic 爲 Message 所屬的一級分類,就像學校裏面的初中、高中
    private static final String MQ_CONFIG_TOPIC = "TOPIC_MEIWEI_SMS_NOTICE_TEST";
    // Tag 爲 Message 所屬的二級分類,比如初中可分爲初一、初二、初三;高中可分爲高一、高二、高三
    private static final String MQ_CONFIG_TAG_RETRY = "PID_MEIWEI_SMS_RETRY_PRODUCER";

    public static void main(String[] args) throws Exception {
        // 創建一個 producer 生產者
        DefaultMQProducer producer = new DefaultMQProducer("meiwei-producer-retry");
        // 指定 NameServer 地址列表,多個 nameServer 地址用半角分號隔開。此處應改爲實際 NameServer 地址
        // NameServer 的地址必須有,但也可以通過啓動參數指定、環境變量指定的方式設置,不一定要寫死在代碼裏
        producer.setNamesrvAddr("127.0.0.1:9876;127.0.0.1:9877");
        // 設置重試次數,默認情況下是2次重試
        // 雖然默認2次,但算上程序本身的1次,其實有3次機會,即如果本身的1次發送成功,2次重試機制就不重試了;如果本身的1次發送失敗,則再執行這2次重試機會
        producer.setRetryTimesWhenSendFailed(2);
        // 設置超時時長,默認情況下是3000毫秒,即3秒
        producer.setSendMsgTimeout(1000);
        // 在發送MQ消息前,必須調用 start 方法來啓動 Producer,只需調用一次即可
        producer.start();

        // 循環發送MQ測試消息
        String content = "";
        for (int i = 0; i < 5; i++) {
            try {
                content = "【MQ測試消息】測試消息 " + i;
                // 構建一條消息
                Message message = new Message(MQ_CONFIG_TOPIC, MQ_CONFIG_TAG_RETRY, content.getBytes(RemotingHelper.DEFAULT_CHARSET));
                // 發送消息,發送消息到一個 Broker。默認以同步方式發送
                SendResult sendResult = producer.send(message);

                // 消息發送成功
                System.out.printf("Send MQ message success! Topic: %s, Tag: %s, MsgId: %s, Message: %s %n",
                        message.getTopic(), message.getTags(), sendResult.getMsgId(), new String(message.getBody()));
            } catch (Exception e) {
                // 只有當出現 RemotingException、MQClientException 和部分 MQBrokerException 時會重投
                System.out.printf(new Date() + ", 異常信息:%s %n", e);
                Thread.sleep(1000);
            }
        }

        // 在發送完消息之後,銷燬 Producer 對象。如果不銷燬也沒有問題
        producer.shutdown();
    }
}

1.4. 驗證結果

正常開啓 RocketMQ 服務,啓動生產者:

從生產者輸出的日誌可以看到,後面的 4 條消息各重試了 3 次:

org.apache.rocketmq.client.exception.MQClientException: Send [3] times, still failed, cost [1]ms, Topic: TOPIC_MEIWEI_SMS_NOTICE_TEST, BrokersSent: [YYW-SH-PC-1454, YYW-SH-PC-1454, YYW-SH-PC-1454]

再看看 RocketMQ 日誌。如果是 Windows 安裝的 RocketMQ,且使用的是默認日誌配置,則可以在路徑 C:\Users\yourname\logs\rocketmqlogs 下查看 rocketmq_client.log

日誌文件 rocketmq_client.log 輸出了源碼中的日誌:sendKernelImpl exception, resend at once, InvokeID

 

2. 消費端重試

Consumer 消費消息失敗後,要提供一種重試機制,令消息再消費一次。Consumer 消費消息失敗通常可以認爲有以下幾種情況:

  1. 由於消息本身的原因,例如反序列化失敗,消息數據本身無法處理(例如話費充值,當前消息的手機號被註銷,無法充值)等。這種錯誤通常需要跳過這條消息,再消費其它消息,而這條失敗的消息即使立刻重試消費,99%也不成功,所以最好提供一種定時重試機制,即過10秒後再重試。
  2. 由於依賴的下游應用服務不可用,例如db連接不可用,外系統網絡不可達等。遇到這種錯誤,即使跳過當前失敗的消息,消費其他消息同樣也會報錯。這種情況建議應用 sleep 30s,再消費下一條消息,這樣可以減輕 Broker 重試消息的壓力。

只有在消息模式爲 MessageModel.CLUSTERING 集羣模式時,Broker 纔會自動進行重試,廣播消息模式下不會自動進行重試。消費者消費消息後,需要給 Broker 返回消費狀態。以 MessageListenerConcurrently 監聽器爲例,Consumer 消費完成後需要返回 ConsumeConcurrentlyStatus 消費狀態。

RocketMQ 會爲每個消費組都設置一個 Topic 名稱爲 “%RETRY%+consumerGroup” 的重試隊列(這裏需要注意的是,這個 Topic 的重試隊列是針對消費組,而不是針對每個 Topic 設置的),用於暫時保存因爲各種異常而導致 Consumer 端無法消費的消息。考慮到異常恢復起來需要一些時間,會爲重試隊列設置多個重試級別,每個重試級別都有與之對應的重新投遞延時,重試次數越多投遞延時就越大。RocketMQ 對於重試消息的處理是先保存至 Topic 名稱爲 “SCHEDULE_TOPIC_XXXX” 的延遲隊列中,後臺定時任務按照對應的時間進行 Delay 後重新保存至 “%RETRY%+consumerGroup” 的重試隊列中。

2.1. 源碼分析

ConsumeConcurrentlyStatus 有 消費成功 和 消費失敗 兩種狀態:

public enum ConsumeConcurrentlyStatus {
    // 消費成功
    CONSUME_SUCCESS,
    // 消費失敗,需要稍後重新消費
    RECONSUME_LATER;

    private ConsumeConcurrentlyStatus() {
    }
}

Consumer 端的重試包括兩種情況:

  1. 異常重試:由於 Consumer 端邏輯出現了異常,導致返回了 RECONSUME_LATER 狀態,那麼 Broker 就會在一段時間後嘗試重試。
  2. 超時重試:如果 Consumer 端處理時間過長,或者由於某些原因線程掛起,導致遲遲沒有返回消費狀態,Broker 就會認爲 Consumer 消費超時,此時會發起超時重試。

因此,如果 Consumer 端正常消費成功,一定要返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS 狀態。

2.2. 生產者

public class RetryExceptionMqProducer {

    private static final String MQ_CONFIG_TOPIC = "TOPIC_MEIWEI_SMS_NOTICE_TEST";
    private static final String MQ_CONFIG_TAG_RETRY = "PID_MEIWEI_SMS_RETRY_EXCEPTION";

    public static void main(String[] args) throws Exception {
        DefaultMQProducer producer = new DefaultMQProducer("meiwei-producer-retry");
        producer.setNamesrvAddr("127.0.0.1:9876");
        producer.start();

        String content = "【MQ測試消息】測試消息 ";
        Message message = new Message(MQ_CONFIG_TOPIC, MQ_CONFIG_TAG_RETRY, content.getBytes(RemotingHelper.DEFAULT_CHARSET));

        // 發送消息,發送消息到一個 Broker。默認以同步方式發送
        SendResult sendResult = producer.send(message);
        System.out.printf("Send MQ message success! Topic: %s, Tag: %s, MsgId: %s, Message: %s %n",
                message.getTopic(), message.getTags(), sendResult.getMsgId(), new String(message.getBody()));

        producer.shutdown();
    }
}

2.3. 異常重試

實現一個設置了最大重試次數 maxReconsumeTimes 爲 4,但業務異常中有重試閥值 3,滿足閥值條件,則返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS 不再重試的消費者:

public class Retry4ExceptionMqConsumer {
    // Message 所屬的 Topic 一級分類,須要與提供者的頻道保持一致才能消費到消息內容
    private static final String MQ_CONFIG_TOPIC = "TOPIC_MEIWEI_SMS_NOTICE_TEST";
    private static final String MQ_CONFIG_TAG_PUSH = "PID_MEIWEI_SMS_RETRY_EXCEPTION";

    public static void main(String[] args) throws Exception {
        // 聲明並初始化一個 consumer
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("meiwei-consumer-retry-exception");
        // 同樣也要設置 NameServer 地址,須要與提供者的地址列表保持一致
        consumer.setNamesrvAddr("127.0.0.1:9876");
        // 設置 consumer 所訂閱的 Topic 和 Tag,*代表全部的 Tag
        consumer.subscribe(MQ_CONFIG_TOPIC, MQ_CONFIG_TAG_PUSH);
        // 設置最大重試數次
        consumer.setMaxReconsumeTimes(4);

        // 註冊一個監聽器,主要進行消息消費的邏輯處理
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                // 獲取消息
                MessageExt msg = list.get(0);

                try {
                    // 獲取重試次數
                    int reconsumeTimes = msg.getReconsumeTimes() + 1;
                    System.out.printf(new Date() + ",第 %s 次輪詢消費 %n", reconsumeTimes);

                    // 模擬業務邏輯。此處爲超過最大重試次數,自動標記消息消費成功
                    if (reconsumeTimes >= 3) {
                        System.out.printf(new Date() + ",超過最大重試次數,自動標記消息消費成功 Topic: %s, Tags: %s, Message: %s %n",
                                msg.getTopic(), msg.getTags(), new String(msg.getBody()));
                        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                    }

                    // 模擬異常發生
                    int num = 1 / 0;
                    System.out.printf(new Date() + ",第 %s 次正常消費 %n", reconsumeTimes);

                    // 返回消費狀態
                    // CONSUME_SUCCESS 消費成功
                    // RECONSUME_LATER 消費失敗,需要稍後重新消費
                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                } catch (Exception e) {
                    // 獲取重試次數
                    int reconsumeTimes = msg.getReconsumeTimes() + 1;
                    System.out.printf(new Date() + ",第 %s 次重試消費,異常信息:%s %n", reconsumeTimes, e);
                    // 每次重試時間間隔遵循延時等級遞增:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
                    return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                }
            }
        });

        // 調用 start() 方法啓動 consumer
        consumer.start();
        System.out.println("Retry Consumer Started.");
    }
}

2.3.1 測試結果

2.3.2 實驗總結

  1. 如果 maxReconsumeTimes 的指定重試次數 大於 業務重試最大重試閥值 reconsumeTimes,則完成業務邏輯處理,返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS 後,不再重試;若此處沒有返回 CONSUME_SUCCESS,則還會繼續重試,到指定重試次數截止,即便沒有返回 CONSUME_SUCCESS
  2. 如果 maxReconsumeTimes 的指定重試次數 小於 業務重試最大重試閥值 reconsumeTimes,則重試完指定重試次數後不再重試,即便沒有返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS;若沒有設定業務重試最大重試閥值校驗也同理
  3. 如果沒有設置 maxReconsumeTimes 的指定重試次數,也沒有設定業務重試最大重試閥值處返回 CONSUME_SUCCESS,則會一直髮起重試;如果重試 16 次還是沒有返回 CONSUME_SUCCESS 成功狀態,就會認爲消息消費不了,丟進死信隊列
  4. 以上重試時間間隔遵循延時等級逐次遞增:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h;這種“時間衰減策略”進行消息的重複投遞,即重試次數越多,消息消費成功的可能性越小
  5. 重試期間,即便關閉然後再次找開當前消費者,也能繼續收到重試消息/進度狀態
  6. 默認重試次數:Producer 生產端重試默認是 2 次,而 Consumer 消費端重試默認是 16 次
  7. 失效情況:Producer 生產端在異步發送情況下重試失效;而 Consumer 消費端在廣播消費模式下重試失效

 

2.4. 超時重試

這裏的超時重試並非真正意義上的超時,它是說獲取消息後,因爲某種原因沒有給 RocketMQ 返回消費的狀態,即沒有return ConsumeConcurrentlyStatus.CONSUME_SUCCESS 或 return ConsumeConcurrentlyStatus.RECONSUME_LATER。這種情況 MQ 會無限制的發送消息給消費端,因爲 RocketMQ 會認爲該消息沒有發送,所以會一直髮送。

2.4.1 代碼示例

public class Retry4TimeoutMqConsumer {
    // Message 所屬的 Topic 一級分類,須要與提供者的頻道保持一致才能消費到消息內容
    private static final String MQ_CONFIG_TOPIC = "TOPIC_MEIWEI_SMS_NOTICE_TEST";
    private static final String MQ_CONFIG_TAG_PUSH = "PID_MEIWEI_SMS_RETRY_TIMEOUT";

    public static void main(String[] args) throws Exception {
        // 創建一個 consumer 消費者
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("meiwei-consumer-retry-timeout");
        // 同樣也要設置 NameServer 地址,須要與提供者的地址列表保持一致
        consumer.setNamesrvAddr("127.0.0.1:9876");
        // 設置 consumer 所訂閱的 Topic 和 Tag,*代表全部的 Tag
        consumer.subscribe(MQ_CONFIG_TOPIC, MQ_CONFIG_TAG_PUSH);
        // 設置消費超時時間(默認值15L,爲15分鐘)
        consumer.setConsumeTimeout(1L);
        // 設置最大重試數次
        consumer.setMaxReconsumeTimes(2);

        // 註冊一個監聽器,主要進行消息消費的邏輯處理
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                // 獲取消息
                MessageExt msg = list.get(0);
                try {
                    // 獲取重試次數
                    int reconsumeTimes = msg.getReconsumeTimes() + 1;
                    if (reconsumeTimes == 1) {
                        // 模擬操作:設置一個大於上面已經設置的消費超時時間 來驗證超時重試場景(setConsumeTimeout(1L))
                        System.out.println("---------- 服務暫停 ---------- " + new Date());
                        Thread.sleep(1000 * 60 * 2);
                    } else {
                        System.out.println("---------- 重試消費 ---------- " + new Date());
                    }

                    System.out.printf(new Date() + " 第 %s 次重試消費:Topic: %s, Tags: %s, MsgId: %s, Message: %s %n",
                            reconsumeTimes, msg.getTopic(), msg.getTags(), msg.getMsgId(), new String(msg.getBody()));

                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                } catch (Exception e) {
                    System.out.printf(new Date() + ",異常信息:%s %n", e);
                    return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                }
            }
        });

        // 調用 start() 方法啓動 consumer
        consumer.start();
        System.out.println("Retry Timeout Consumer Started.");
    }
}

2.4.2 測試結果

測試期間,當控制檯輸出日誌 “服務暫停” 後,關閉當前消費者:

再次開啓該消費者:

2.4.3 實驗總結

  1. 重試期間,關閉當前消費者,再開啓該消費者,合理區間內該消費者也能再次收到重試消息或者消費的進度狀態
  2. 如果設置了指定最大重試次數,但有業務重試次數閥值校驗中返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS 後,便不再重試

RocketMQ進擊物語:
RocketMQ進擊(零)RocketMQ這個大水池子
RocketMQ進擊(一)Windows環境下安裝部署Apache RocketMQ
RocketMQ進擊(二)一個默認生產者,兩種消費方式,三類普通消息詳解分析
RocketMQ進擊(三)順序消息與高速公路收費站
RocketMQ進擊(四)定時消息(延時隊列)
RocketMQ進擊(五)集羣消費模式與廣播消費模式
RocketMQ進擊(六)磕一磕RocketMQ的事務消息

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章