本文側重講解 RocketMQ 的實際應用,關於理論部分,在另外一篇文章中再做探討。在此不多說,直接進入實戰吧。
1. 配置
通常開發直接依賴 rocketmq-spring-boot-starter
即可,starter 中包含了所有所需的依賴,如:
rocketmq-client
:封裝了客戶端的應用程式,還包含了netty的通訊服務。rocketmq-acl
:訪問許可權控制服務。
starter
還提供了很多現成封裝類,如:RocketMQTemplate.java
、RocketMQListener.java
、RocketMQUtil.java
等,在應用開發時會經常用到。
建議直接用上述的 rocketmq-spring-boot-starter
,見過有公司為了內部相容,自己封裝了一個服務替代官方的 starter
。但這個服務除了增加部分自定義程式外,其他的類和方法都是照拷貝 starter
的。
當後續 rocketmq-spring-boot-starter
升級了,或修復bug、或擴充功能,公司內部的服務就很難升級了,除非再從頭拷貝一遍。當公司內部沒有相應的體量,建議不要學大廠自己封裝基礎服務,否則容易騎虎難下。
pom依賴
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
寫文章時,starter 最新的版本是 2.2.0
,對應的 rocketmq-client
、rocketmq-acl
版本是 4.8.0
。
認準版本好很重要,因為 rocketmq-spring-boot-starter
一直在快速迭代中,很多類和方法,在新版本中都會改變,例如下文會提到的tag、訊息事務等,這是也是為什麼不建議公司內部封裝 starter。
application
# rocketmq 配置項,對應 RocketMQProperties 配置類
rocketmq:
name-server: 127.0.0.1:9876 # RocketMQ Namesrv
# Producer 配置項
producer:
group: koala-dev-event-centre-group # 生產者分組
send-message-timeout: 3000 # 傳送訊息超時時間,單位:毫秒。預設為 3000 。
compress-message-body-threshold: 4096 # 訊息壓縮閥值,當訊息體的大小超過該閥值後,進行訊息壓縮。預設為 4 * 1024B
max-message-size: 4194304 # 訊息體的最大允許大小。。預設為 4 * 1024 * 1024B
retry-times-when-send-failed: 2 # 同步傳送訊息時,失敗重試次數。預設為 2 次。
retry-times-when-send-async-failed: 2 # 非同步傳送訊息時,失敗重試次數。預設為 2 次。
retry-next-server: false # 傳送訊息給 Broker 時,如果傳送失敗,是否重試另外一臺 Broker 。預設為 false
access-key: # Access Key ,可閱讀 https://github.com/apache/rocketmq/blob/master/docs/cn/acl/user_guide.md 文件
secret-key: # Secret Key
enable-msg-trace: true # 是否開啟訊息軌跡功能。預設為 true 開啟。可閱讀 https://github.com/apache/rocketmq/blob/master/docs/cn/msg_trace/user_guide.md 文件
customized-trace-topic: RMQ_SYS_TRACE_TOPIC # 自定義訊息軌跡的 Topic 。預設為 RMQ_SYS_TRACE_TOPIC 。
# Consumer 配置項
consumer:
listeners: # 配置某個消費分組,是否監聽指定 Topic 。結構為 Map<消費者分組, <Topic, Boolean>> 。預設情況下,不配置表示監聽。
erbadagang-consumer-group:
topic1: false # 關閉 test-consumer-group 對 topic1 的監聽消費
rocketmq 配置很多,除了基礎有關server的配置以外,還有acl、producer、consumer等。但通常一個服務內會有多個consumer,建議在程式碼中實現。而producer如果只有一個,可以配置。
2.普通訊息傳送
有關 rocketmq 傳送訊息的原始碼,建議檢視 org.apache.rocketmq.client.producer.DefaultMQProducer
,類中的屬性大多對應於配置檔案中的引數。
2.1. 三種訊息傳送
這裡只討論普通的訊息傳送方式,區別於順序、事務、延遲/定時等訊息傳送方式,當前分為三種:
- 同步(sync): 同步傳送就是指 producer 傳送訊息後,會同步等待,在接收到 broker 響應結果後才繼續發下一條訊息。
- 非同步(async): 非同步傳送是指 producer 發出一條訊息後,不需要等待 broker 響應,就接著傳送下一條訊息的通訊方式。非同步傳送同樣可以對訊息的響應結果進行處理,需要在傳送訊息時實現非同步傳送回撥介面。
- 單方向(oneWay): 是一種單方向通訊方式,也就是說 producer 只負責傳送訊息,不等待 broker 發回響應結果,而且也沒有回撥函式觸發,這也就意味著 producer 只傳送請求不等待響應結果。
三種傳送方式對比
傳送方式 | 傳送TPS | 傳送結果響應 | 可靠性 | 使用場景 |
---|---|---|---|---|
同步 | 一般 | 有 | 高 | 重要的通知場景 |
非同步 | 快 | 有 | 高 | 比較注重 RT(響應時間)的場景 |
單方向 | 最快 | 無 | 低 | 可靠性要求並不高的場景 |
async 非同步執行的執行緒池配置
this.defaultAsyncSenderExecutor = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(), Runtime.getRuntime().availableProcessors(), 60000L, TimeUnit.MILLISECONDS, this.asyncSenderThreadPoolQueue, new ThreadFactory() {
private AtomicInteger threadIndex = new AtomicInteger(0);
public Thread newThread(Runnable r) {
return new Thread(r, "AsyncSenderExecutor_" + this.threadIndex.incrementAndGet());
}
});
三種傳送訊息程式碼
private String convertDestination(String topic, String tag) {
return StringUtils.isBlank(tag) ? topic : StringUtils.join(topic, ":", tag);
}
/**
* 同步傳送
*/
public SendResult syncSend(String topic, String tag, String content) {
String destination = this.convertDestination(topic, tag);
return rocketMQTemplate.syncSend(destination, content);
}
/**
* 非同步傳送
*/
public void asyncSend(String topic, String tag, String content, SendCallback sendCallback) {
String destination = this.convertDestination(topic, tag);
rocketMQTemplate.asyncSend(destination, content, sendCallback);
}
/**
* 單向傳送
*/
public void sendOneWay(String topic, String tag, String content) {
String destination = this.convertDestination(topic, tag);
rocketMQTemplate.sendOneWay(destination, content);
}
2.2. 批量傳送
批量訊息傳送是將同一主題的多條訊息一起打包傳送到訊息服務端,減少網路呼叫次數,提高網路傳輸效率。
當然,並不是在同一批次中傳送的訊息數量越多,效能就越好,判斷依據是單條訊息的長度,如果單條訊息內容比較長,則打包傳送多條訊息會影響其他執行緒傳送訊息的響應時間,並且單批次訊息傳送總長度不能超過DefaultMQProducer#maxMessageSize,即配置檔案中的rocketmq.producer.max-message-size
。
程式碼
/**
* 同步-批量傳送
*/
public SendResult syncBatchSend(String topic, String tag, List<String> contentList) {
String destination = this.convertDestination(topic, tag);
List<Message<String>> messageList = contentList.stream()
.map(content -> MessageBuilder.withPayload(content).build())
.collect(Collectors.toList());
return rocketMQTemplate.syncSend(destination, messageList);
}
4. 標籤tag
rocketmq中,topic與tag都是業務上用來歸類的標識,區分在於topic是一級分類,而tag可以理解為是二級分類。定義上:
- topic: 訊息主題,通過Topic對不同的業務訊息進行分類。
- tag: 訊息標籤,用來進一步區分某個Topic下的訊息分類,訊息從生產者發出即帶上的屬性。
實際業務中,什麼時候該用topic或tag呢?有以下幾種建議:
- 訊息型別是否一致: 如普通訊息、事務訊息、定時(延時)訊息、順序訊息,不同的訊息型別使用不同的Topic,無法通過Tag進行區分。
- 業務是否相關聯: 沒有直接關聯的訊息,如淘寶交易訊息,京東物流訊息使用不同的Topic進行區分;而同樣是天貓交易訊息,電器類訂單、女裝類訂單、化妝品類訂單的訊息可以用Tag進行區分。
- 訊息優先順序是否一致: 如同樣是物流訊息,盒馬必須小時內送達,天貓超市24小時內送達,淘寶物流則相對會慢一些,不同優先順序的訊息用不同的Topic進行區分。
- 訊息量級是否相當: 有些業務訊息雖然量小但是實時性要求高,如果跟某些萬億量級的訊息使用同一個Topic,則有可能會因為過長的等待時間而“餓死”,此時需要將不同量級的訊息進行拆分,使用不同的Topic。
舉個實際專案的例子吧:我剛剛做的一個專案叫共享中心,所有需要共享的資源都來自於各個業務服務方。建立共享資源、撤回共享資源等這類指令,我將其定義為不同的topic。而傳送給topic的訊息體中有“資源型別”的欄位,每個業務接入方其實只關心對應自己資源型別的訊息,那麼就將“資源型別”定義為tag,各個業務方的消費端只監聽自己所需的tag即可。
如果沒有tag的機制,消費端就得接收所有訊息,反序列化後只處理自己對應“資源型別”的訊息。有了tag機制,訊息在進入消費端途中就自動進行過濾分發。
與 RabbitMQ AMQP協議 比較
在使用tag機制後,第一時間讓我想到了 RabbitMQ 的 AMQP協議,很像交換機和佇列的機制。當使用tag之後,像扇形交換機;當沒使用tag之後,就像直連交換機。
- Exchange:訊息交換機,它指定訊息按什麼規則,路由到哪個佇列。
- Binding:繫結,它的作用就是把 Exchange 和 Queue 按照路由規則繫結起來。
- Queue:訊息佇列載體,每個訊息都會被投入到一個或多個佇列。
我想,二者設計的目的都是一樣的,就是讓生產者和消費者之間解耦,讓一個訊息,可以自由地流轉到不同的訊息端。RocketMQ 在這點,相較於 RabbitMQ 而言,提供的功能不夠豐富,但更實用、簡潔。
示例程式碼:生產者
通過前面示例程式碼中,最簡單的傳送同步訊息程式碼來看,最新 starter
中封裝的 RocketMQTemplate
類中,訊息傳送的目標是 String destination
。而 它包含了 topic
和 tag
。即私有轉換方法中的: destination = topic:tag
。
private final RocketMQTemplate rocketMQTemplate;
private String convertDestination(String topic, String tag) {
return StringUtils.isBlank(tag) ? topic : StringUtils.join(topic, ":", tag);
}
/**
* 同步傳送
*/
public SendResult syncSend(String topic, String tag, String content) {
String destination = this.convertDestination(topic, tag);
return rocketMQTemplate.syncSend(destination, content);
}
示例程式碼:消費者
@RocketMQMessageListener(consumerGroup = ShareRocketMqConstants.GROUP_PREFIX + ShareRocketMqConstants.TOPIC_SHARE_RSRC_TO_BIZ_CALLBACK,
topic = ShareRocketMqConstants.TOPIC_SHARE_RSRC_TO_BIZ_CALLBACK,
selectorExpression = "2||3||4",
consumeThreadMax = 3)
public class ShareRsrcMqConsumer implements RocketMQListener<RsrcToBiz4Mq> {
... ...
}
上述消費端程式碼中,申明只消費tag值為:2、3、4 的訊息,註解中核心有兩個屬性:
selectorType:
預設值就是 SelectorType.TAG,所以示例程式碼中沒有設定。selectorExpression:
對應的表示式。針對 SelectorType.TAG型別的,就需要設定 tag的表示式。預設值是*
,即所有tag都消費。如果想要指定消費多個tag,則用||
或符合來連線。
注意: 生產者端,發訊息只能指定一個tag。但消費者端,接收訊息可以指定多個tag。
5. 延遲/定時訊息
定時訊息是指訊息發到broker後,不能立刻被consumer消費,要到特定的時間點或者等待特定的時間後才能被消費。
原理
其實定時訊息實現原理比較簡單,如果一個topic對應的訊息在傳送端被設定為定時訊息,那麼會將該訊息先存放在topic為 SCHEDULE_TOPIC_XXXX
的訊息佇列中,並將原始訊息的資訊存放在commitLog檔案中,由於topic為SCHEDULE_TOPIC_XXXX,所以該訊息不會被立即訊息,然後通過定時掃描的方式,將到達延遲時間的訊息,轉換為正確的訊息,傳送到相應的佇列進行消費。
延遲級別
儘管 rocketmq 支援定時訊息,但是當前開源版本的 rocketmq 所支援的定時時間是有限的、不同級別的精度的時間,並不是任意無限制的定時時間。預設 Broker伺服器端有18個定時級別,每一個級別分別對應不同的延遲時間:
延遲級別 | 延遲時間 |
---|---|
1 | 1s |
2 | 5s |
3 | 10s |
4 | 30s |
5 | 1m |
6 | 2m |
7 | 3m |
8 | 4m |
9 | 5m |
10 | 6m |
11 | 7m |
12 | 8m |
13 | 9m |
14 | 10m |
15 | 20m |
16 | 30m |
17 | 1h |
18 | 2h |
程式碼
傳送延遲訊息並沒有特殊的方法,而是基於普通發訊息的方法(如:rocketMQTemplate.syncSend)做了過載,增加了一個傳入引數 int delayLevel
,預設值為 0,即立即傳送。
/**
* 同步延遲傳送
*
* @param delayLevel 延時等級:現在RocketMq並不支援任意時間的延時,需要設定幾個固定的延時等級,從1s到2h分別對應著等級 1 到 18
* 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
*/
public SendResult syncSendDelay(String topic, String tag, String content, long timeout, int delayLevel) {
String destination = this.convertDestination(topic, tag);
Message message = MessageBuilder.withPayload(content).build();
return rocketMQTemplate.syncSend(destination, message, timeout, delayLevel);
}
6. 順序訊息
順序訊息是一種對訊息傳送和消費順序有嚴格要求的訊息,對於一個指定的Topic,訊息嚴格按照先進先出(FIFO)的原則進行訊息釋出和消費,即先發布的訊息先消費,後釋出的訊息後消費。
RocketMQ目前只能保證同一個分割槽內的順序訊息,那麼佇列中的訊息必然是同一個分割槽的,因此實現下列場景的方式有:
- 分割槽有序: RocketMQ 支援同一個佇列分割槽內的順序訊息。另外某個 Topic 下,所有訊息根據 ShardingKey 進行分割槽,相同 ShardingKey 的訊息必須被髮送到同一個分割槽佇列。因此只要保證訊息按照同一 ShardingKey 傳送即可,然後保證 Consumer 同一個佇列單執行緒消費即可。
- 全域性有序: 當設定 Topic 下只有一個分割槽時,可以實現全域性有序。
全域性有序的效能太差,推薦使用分割槽有序。假設我們要通過mq處理訂單內的訊息。同一個 topic,通常我們只需要保證同一個訂單下的訊息順序釋出和消費即可,不同訂單下的訊息應該互不干擾。因此可以採用分割槽有序,將訂單號轉換為 ShardingKey,只要保證同一個訂單下的訊息都流轉到同一個佇列下,然後順序消費。
最常見將訂單號轉換為 ShardingKey 的方式就是 hashKey。
順便說一句,這和 RabbitMQ 的順序消費實現原理
一樣。RabbitMQ 的訊息,在同一個佇列內也是 FIFO 的,因此實現順序消費也只能將同類訊息傳遞到一個佇列中。
生產者
/**
* 同步順序傳送
*
* @param hashKey 根據 hashKey 和 佇列size() 取模,保證同一 hashKey 的訊息發往同一個佇列,以實現 同一hashKey下的訊息 順序傳送
* 因此 hashKey 建議取 業務上唯一識別符號,如:訂單號,只需保證同一訂單號下的訊息順序傳送
*/
public SendResult syncSendOrderly(String topic, String tag, String content, String hashKey) {
String destination = this.convertDestination(topic, tag);
Message message = MessageBuilder.withPayload(content).build();
return rocketMQTemplate.syncSendOrderly(destination, message, hashKey);
}
消費者
針對順序訊息的消費,程式碼也很容易,主要是 @RocketMQMessageListener
註解,通過設定了consumeMode = ConsumeMode.ORDERLY
,表示使用順序消費。
ConsumeMode 有兩種值:
- CONCURRENTLY:預設值,併發同時接收非同步傳遞的訊息。
- ORDERLY:順序消費時開啟,只開啟一個執行緒,同一時間只有序接收一個佇列的訊息。
@RocketMQMessageListener(topic = "xxx-topic",
consumerGroup = "xxxGroup",
consumeMode = ConsumeMode.ORDERLY)
public class OrderConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
... ...
}
}
提問:如果針對順序訊息的消費者,同時啟動了多個spring例項,會影響嗎?
這個問題當時想了好久,為了保證訊息按照順序消費,消費者是單執行緒消費的。可實際線上程式都不會是單節點,如果有多個spring例項,不是也可以理解成“多執行緒”處理了嗎?
首先,回顧幾個知識點吧:
- 順序消費只能針對 “叢集模式” 消費,即
messageModel = MessageModel.CLUSTERING
。 - 叢集模式下,多個消費者如何對訊息佇列進行負載呢?訊息佇列負載機制遵循一個通用的思想:一個訊息佇列同一時間只允許被一個消費者消費,一個消費者可以消費多個訊息佇列。
- 雖然消費者程式碼中, RocketMQ 監聽器像是mq中的“推模式”。但實際上,RocketMQ訊息推模式基於拉模式實現,在拉模式上包裝一層,一個拉取任務完成後開始下一個拉取任務。
順序訊息消費時,在每次拉取任務時,都會像broker申請鎖住該佇列。因此,就算有多個消費者例項同時在執行,針對單個佇列中的順序訊息,依然是順序消費的。
7. 事務訊息
這裡著重說明一下,RocketMQ 的事務機制,和我們通常說的通過MQ來實現最終一致性的分散式事務機制,不是一個事情。
RocketMQ 的事務機制,只體現在生產者,保障的是生產者本地的事務執行、發訊息,這兩個事務達成一致性。至於消費者收到訊息後的事務處理,並不在當前機制內。
正常事務的流程
正常事務的流程,遵循的是 2PC
的方案。
- 呼叫傳送事務訊息方法,正常傳送訊息。傳送事務的方法名為
syncSendInTransaction
。 - mq伺服器端成功接收到訊息後,訊息處於一個半接收的狀態,並響應給生產者客戶端。
- 生產者收到伺服器端成功接收的響應後,執行本地事務。本地事務寫在
executeLocalTransaction
方法裡面,返回結果為列舉RocketMQLocalTransactionState
,有:COMMIT、ROLLBACK、UNKNOWN
三種值。 - 伺服器端收到
COMMIT
狀態後,會把訊息下發給消費者 - 伺服器端收到
ROLLBACK
狀態後,會刪除掉當前半接收狀態的訊息,不再處理。 - 伺服器端收到
UNKNOWN
狀態,或者伺服器端超時未收到訊息,或者生產者未響應狀態,則將進行訊息補償機制。
訊息補償機制
這部分比較簡單,針對上述事務流程第6點的幾種情況,會觸發訊息回查。
- 當事務訊息出現
UNKNOWN
、超時、未響應時,伺服器會主動呼叫生產者本地的回查方法checkLocalTransaction
,查詢本地事務執行情況,返回結果還是列舉值RocketMQLocalTransactionState
。 - 伺服器接收到返回結果的處理流程和前面的正常流程一樣。
- 如果依然是
UNKNOWN
、超時、未響應,將繼續重試。如果超過最大重試次數後,依然無果,則視為ROLLBACK
,刪除當前訊息。
事務訊息相關的引數,基本在 org.apache.rocketmq.common.BrokerConfig
類中定義,例如以下幾個常用屬性的預設值:
transactionTimeOut = 6000L
:伺服器未收到事務本地訊息的超時時間為1分鐘。transactionCheckMax = 15
:訊息補償機制中的最大回查次數為15次。transactionCheckInterval = 6000L
:訊息補償機制中每次回查的時間間隔為1分鐘。
因為是 BrokerConfig
類中的屬性,因此如果不想用預設值,可以在 broker.conf
檔案中自定義修改。
程式碼:生產者傳送事務訊息
/**
* 事務傳送
*/
public TransactionSendResult syncSendInTransaction(String topic, String tag, String content) {
String destination = this.convertDestination(topic, tag);
String transactionId = UUID.randomUUID().toString();
Message message = MessageBuilder.withPayload(content)
.setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId)
.build();
return rocketMQTemplate.sendMessageInTransaction(destination, message, content);
}
程式碼:生產者定義事務本地方法
在生產者客戶端,通過事務監聽器,實現 RocketMQLocalTransactionListener
介面的兩個上述方法。
@RocketMQTransactionListener
public class LocalTransactionListener implements RocketMQLocalTransactionListener {
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
System.out.println("executeLocalTransaction: "+ LocalDateTime.now());
return RocketMQLocalTransactionState.UNKNOWN;
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
System.out.println("checkLocalTransaction: "+ LocalDateTime.now());
return RocketMQLocalTransactionState.COMMIT;
}
}
提問:如果spring專案中有多個事務訊息生產者,怎麼區分不同的RocketMQLocalTransactionListener
?
@RocketMQLocalTransactionListener
這個註解提供了屬性,可以區分不同的事務訊息生產者。在 stater 2.0.4 版本中,是提供 txProducerGroup 這個屬性指向一個訊息傳送者組,對映不同的事務訊息傳送邏輯。但好像有bug,在後續新的版本迭代中,去掉了這個屬性。
到了 2.1.1 版本,只能通過指定 rocketMQTemplateBeanName
來實現,即不同的事務訊息傳送時,就得定義不同的 RocketMQTemplate。挺麻煩的,期待這個功能在後續的迭代中完善好。
8. 重試佇列、死信佇列
RocketMQ 很多應多異常的保全機制,例如訊息重發的機制,這裡可以分兩類:
- 生產者重發: 在前面介紹三種傳送訊息方式時,針對同步、非同步傳送失敗時,都會再重發,相應重發次數分別對應
DefaultMQProducer
類中屬性值retryTimesWhenSendFailed
、retryTimesWhenSendAsyncFailed
,也可以在 properties 配置檔案中自定義設定。 - 消費者重發: 當訊息已經進入 broker 後,消費者接收失敗,broker 也會給消費者重發,以下衍生出本次的重試佇列、死信佇列。
重試佇列
如果消費者端因為各種型別異常導致本次消費失敗,為防止該訊息丟失而需要將其重新回發給broker端儲存,儲存這種因為異常無法正常消費而回發給mq的訊息佇列稱之為重試佇列。
RocketMQ 會為每個消費組都設定一個 topic 名稱為 “%RETRY%+consumerGroup”
的重試佇列(這裡需要注意的是,這個Topic的重試佇列是針對消費組,而不是針對每個Topic設定的)。
用於暫時儲存因為各種異常而導致消費者端無法消費的訊息。考慮到異常恢復起來需要一些時間,會為重試佇列設定多個重試級別,每個重試級別都有與之對應的重新投遞延時,重試次數越多投遞延時就越大。RocketMQ 對於重試訊息的處理是先儲存至 topic 名稱為“SCHEDULE_TOPIC_XXXX”
的延遲佇列中,後臺定時任務按照對應的時間進行Delay後重新儲存至“%RETRY%+consumerGroup”的重試佇列中。
死信佇列
由於有些原因導致消費者端長時間的無法正常消費從 broker 端 pull過來的業務訊息,為了確保訊息不會被無故的丟棄,那麼超過配置的“最大重試消費次數”後就會移入到這個死信佇列中。
在RocketMQ中,SubscriptionGroupConfig 配置常量預設地設定了兩個引數,一個是retryQueueNums為1(重試佇列數量為1個),另外一個是retryMaxTimes為16(最大重試消費的次數為16次)。Broker端通過校驗判斷,如果超過了最大重試消費次數則會將訊息移至這裡所說的死信佇列。這裡,RocketMQ會為每個消費組都設定一個 topic 命名為 “%DLQ%+consumerGroup"
的死信佇列。但如果一個消費者組未產生死信訊息,訊息佇列 RocketMQ 不會為其建立相應的死信佇列的。
因為死信佇列中的訊息是無法被消費的,它也證實了一部分訊息出現了意料之外的情況。因此一般在實際應用中,移入至死信佇列的訊息,需要人工干預處理。例如通過 console 檢視是否有私信佇列,當解決問題後,可在 console 上手動重發訊息。