楔子:翻了帖子兩三天,硬是沒有找到哪個帖子能證明生產端的消息重試是確實重試了的。大多要麼是對概念、源碼說明了一下,或者把實現示例貼貼,但基本並沒有有效測試證明。想了想,還是自己來捋一捋這 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 消費消息失敗通常可以認爲有以下幾種情況:
- 由於消息本身的原因,例如反序列化失敗,消息數據本身無法處理(例如話費充值,當前消息的手機號被註銷,無法充值)等。這種錯誤通常需要跳過這條消息,再消費其它消息,而這條失敗的消息即使立刻重試消費,99%也不成功,所以最好提供一種定時重試機制,即過10秒後再重試。
- 由於依賴的下游應用服務不可用,例如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 端的重試包括兩種情況:
- 異常重試:由於 Consumer 端邏輯出現了異常,導致返回了 RECONSUME_LATER 狀態,那麼 Broker 就會在一段時間後嘗試重試。
- 超時重試:如果 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 實驗總結
- 如果 maxReconsumeTimes 的指定重試次數 大於 業務重試最大重試閥值 reconsumeTimes,則完成業務邏輯處理,返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS 後,不再重試;若此處沒有返回 CONSUME_SUCCESS,則還會繼續重試,到指定重試次數截止,即便沒有返回 CONSUME_SUCCESS
- 如果 maxReconsumeTimes 的指定重試次數 小於 業務重試最大重試閥值 reconsumeTimes,則重試完指定重試次數後不再重試,即便沒有返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS;若沒有設定業務重試最大重試閥值校驗也同理
- 如果沒有設置 maxReconsumeTimes 的指定重試次數,也沒有設定業務重試最大重試閥值處返回 CONSUME_SUCCESS,則會一直髮起重試;如果重試 16 次還是沒有返回 CONSUME_SUCCESS 成功狀態,就會認爲消息消費不了,丟進死信隊列
- 以上重試時間間隔遵循延時等級逐次遞增:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h;這種“時間衰減策略”進行消息的重複投遞,即重試次數越多,消息消費成功的可能性越小
- 重試期間,即便關閉然後再次找開當前消費者,也能繼續收到重試消息/進度狀態
- 默認重試次數:Producer 生產端重試默認是 2 次,而 Consumer 消費端重試默認是 16 次
- 失效情況: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 實驗總結
- 重試期間,關閉當前消費者,再開啓該消費者,合理區間內該消費者也能再次收到重試消息或者消費的進度狀態
- 如果設置了指定最大重試次數,但有業務重試次數閥值校驗中返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS 後,便不再重試
RocketMQ進擊物語:
RocketMQ進擊(零)RocketMQ這個大水池子
RocketMQ進擊(一)Windows環境下安裝部署Apache RocketMQ
RocketMQ進擊(二)一個默認生產者,兩種消費方式,三類普通消息詳解分析
RocketMQ進擊(三)順序消息與高速公路收費站
RocketMQ進擊(四)定時消息(延時隊列)
RocketMQ進擊(五)集羣消費模式與廣播消費模式
RocketMQ進擊(六)磕一磕RocketMQ的事務消息