RocketMQ - 應用篇

KerryWu發表於2022-01-27

本文側重講解 RocketMQ 的實際應用,關於理論部分,在另外一篇文章中再做探討。在此不多說,直接進入實戰吧。

1. 配置

通常開發直接依賴 rocketmq-spring-boot-starter 即可,starter 中包含了所有所需的依賴,如:

  • rocketmq-client:封裝了客戶端的應用程式,還包含了netty的通訊服務。
  • rocketmq-acl:訪問許可權控制服務。

starter 還提供了很多現成封裝類,如:RocketMQTemplate.javaRocketMQListener.javaRocketMQUtil.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-clientrocketmq-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。而 它包含了 topictag。即私有轉換方法中的: 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個定時級別,每一個級別分別對應不同的延遲時間:

延遲級別延遲時間
11s
25s
310s
430s
51m
62m
73m
84m
95m
106m
117m
128m
139m
1410m
1520m
1630m
171h
182h
程式碼

傳送延遲訊息並沒有特殊的方法,而是基於普通發訊息的方法(如: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 的方案。

  1. 呼叫傳送事務訊息方法,正常傳送訊息。傳送事務的方法名為 syncSendInTransaction
  2. mq伺服器端成功接收到訊息後,訊息處於一個半接收的狀態,並響應給生產者客戶端。
  3. 生產者收到伺服器端成功接收的響應後,執行本地事務。本地事務寫在 executeLocalTransaction 方法裡面,返回結果為列舉 RocketMQLocalTransactionState,有:COMMIT、ROLLBACK、UNKNOWN 三種值。
  4. 伺服器端收到 COMMIT 狀態後,會把訊息下發給消費者
  5. 伺服器端收到 ROLLBACK 狀態後,會刪除掉當前半接收狀態的訊息,不再處理。
  6. 伺服器端收到 UNKNOWN 狀態,或者伺服器端超時未收到訊息,或者生產者未響應狀態,則將進行訊息補償機制。
訊息補償機制

這部分比較簡單,針對上述事務流程第6點的幾種情況,會觸發訊息回查。

  1. 當事務訊息出現 UNKNOWN、超時、未響應時,伺服器會主動呼叫生產者本地的回查方法 checkLocalTransaction,查詢本地事務執行情況,返回結果還是列舉值 RocketMQLocalTransactionState
  2. 伺服器接收到返回結果的處理流程和前面的正常流程一樣。
  3. 如果依然是 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 類中屬性值 retryTimesWhenSendFailedretryTimesWhenSendAsyncFailed ,也可以在 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 上手動重發訊息。

相關文章