精華推薦 | 【深入淺出RocketMQ原理及實戰】「效能原理挖掘系列」透徹剖析貫穿RocketMQ的事務性訊息的底層原理並在分析其實際開發場景

洛神灬殤發表於2022-12-17

什麼是事務訊息

事務訊息(Transactional Message)是指應用本地事務和傳送訊息操作可以被定義到全域性事務中,要麼同時成功,要麼同時失敗。RocketMQ的事務訊息提供類似 X/Open XA 的分佈事務功能,透過事務訊息能達到分散式事務的最終一致。

事務訊息所對應的場景

在一些對資料一致性有強需求的場景,可以用 Apache RocketMQ 事務訊息來解決,從而保證上下游資料的一致性。

以秒殺購物商城的商品下單交易場景為例,使用者支付訂單這一核心操作的同時會涉及到下游物流發貨、庫存變更、購物車狀態清空等多個子系統的變更。

事務性業務的處理分支包括:

  1. 主分支訂單系統狀態更新:由未支付變更為支付成功。
  2. 呼叫第三方物流系統狀態新增:新增待發貨物流記錄,建立訂單物流記錄。
  3. 積分系統狀態變更:變更使用者積分,更新使用者積分表。
  4. 購物車系統狀態變更:清空購物車,更新使用者購物車記錄。

RocketMQ的事務訊息

Apache RocketMQ在4.3.0版的時候已經支援分散式事務訊息,這裡RocketMQ採用了2PC的思想來實現了提交事務訊息,同時增加一個補償邏輯來處理二階段超時或者失敗的訊息。

RocketMQ事務訊息流程

針對於事務訊息的總體運作流程,主要分為兩個部分:正常事務訊息的傳送及提交、事務訊息的補償流程。

事務訊息傳送及提交基本流程概要(後面會詳細分析原理)
事務訊息傳送步驟如下
  1. 訊息傳送者:生產者將半事務訊息傳送至RocketMQ Broker。
  2. Broker服務端:RocketMQ Broker 將訊息持久化成功之後,向生產者返回 Ack 確認訊息已經傳送成功,此時訊息暫不能投遞,為半事務訊息。
  3. 業務系統:生產者開始執行本地事務邏輯。
  4. 生產者根據本地事務執行結果向服務端提交二次確認結果(Commit或是Rollback)。
    • 如果本地操作成功,Commit操作生成訊息索引,訊息對消費者可見
    • 如果本地操作失敗,此時對應的half訊息對業務不可見,本地邏輯不執行,Rollback均進行回滾。
服務端收到確認結果後處理邏輯如下
  • 確認結果為Commit:服務端將半事務訊息標記為可投遞,並投遞給消費者。
  • 確認結果為Rollback:服務端將回滾事務,不會將半事務訊息投遞給消費者。
訊息出現異常情況的補償流程如下

在斷網或者是生產者應用重啟的特殊情況下,若服務端未收到傳送者提交的二次確認結果(Commit/Rollback)或服務端收到的二次確認結果為Unknown未知狀態,經過固定時間後,服務端將對訊息生產者即生產者叢集中任一生產者例項發起訊息回查。

注意:服務端僅僅會按照引數嘗試指定次數,超過次數後事務會強制回滾,因此未決事務的回查時效性非常關鍵,需要按照業務的實際風險來設定

事務訊息回查步驟如下
  • 生產者收到訊息回查後,需要檢查對應訊息的本地事務執行的最終結果。
  • 生產者根據檢查得到的本地事務的最終狀態再次提交二次確認,服務端仍按照步驟4對半事務訊息進行處理。
補償總結
  1. 對沒有Commit/Rollback的事務訊息(pending狀態的訊息),Broker服務端會發起一次“回查”。
  2. 生產者Producer收到回查訊息,檢查回查訊息對應的本地事務的狀態。
  3. 生產者根據本地事務狀態,重新Commit或者Rollback。

補償階段用於解決訊息Commit或者Rollback發生超時或者失敗的情況

RocketMQ事務訊息實現原理

事務訊息在一階段對使用者不可見

在RocketMQ事務訊息的主要流程中,一階段的訊息如何對使用者不可見。

實現技術要點一:事務訊息相對普通訊息最大的特點就是一階段傳送的訊息對使用者是不可見的。如何做到寫入訊息但是對使用者不可見呢?

RocketMQ事務訊息的做法是:如果訊息是half訊息,將備份原訊息的Topic與訊息消費佇列,然後,改變Topic為RMQ_SYS_TRANS_HALF_TOPIC。

由於消費組未訂閱該主題,故消費端無法消費half型別的訊息,然後RocketMQ會開啟一個定時任務,從Topic為RMQ_SYS_TRANS_HALF_TOPIC中拉取訊息進行消費,根據生產者組獲取一個服務提供者傳送回查事務狀態請求,根據事務狀態來決定是提交或回滾訊息。

RocketMQ中,訊息在服務端的儲存結構如下,每條訊息都會有對應的索引資訊,Consumer透過ConsumeQueue這個二級索引來讀取訊息實體內容,其流程如下:

RocketMQ的底層實現原理

  1. 寫入的如果事務訊息,對訊息的Topic和Queue等屬性進行替換,同時將原來的Topic和Queue資訊儲存到訊息的屬性中,正因為訊息主題被替換,故訊息並不會轉發到該原主題的訊息消費佇列。
  2. 由於沒有直接傳送到目標的topic的佇列裡面,故此消費者無法感知訊息的存在,不會消費,其實改變訊息主題是RocketMQ的常用“套路”,回想一下延時訊息的實現機制。

傳送一個半事務訊息

半事務訊息是指暫不能投遞的訊息,生產者已經成功地將訊息傳送到了 Broker,但是Broker未收到生產者對該訊息的二次確認,此時該訊息被標記成“暫不能投遞(pending)”狀態,如果傳送成功則執行本地事務,並根據本地事務執行成功與否,向Broker半事務訊息狀態(commit或者rollback),半事務訊息只有commit狀態才會真正向下游投遞。

Commit和Rollback操作以及Op訊息的底層實現原理

Rollback的情況,對於Rollback,本身一階段的訊息對使用者是不可見的,其實不需要真正撤銷訊息(實際上RocketMQ也無法去真正的刪除一條訊息,因為是順序寫檔案的)。

但是區別於這條訊息沒有確定狀態(Pending狀態,事務懸而未決),需要一個操作來標識這條訊息的最終狀態。RocketMQ事務訊息方案中引入了Op訊息的概念,用Op訊息標識事務訊息已經確定的狀態(Commit或者Rollback)。

如果一條事務訊息沒有對應的Op訊息,說明這個事務的狀態還無法確定(可能是二階段失敗了)。引入Op訊息後,事務訊息無論是Commit或者Rollback都會記錄一個Op操作。Commit相對於Rollback只是在寫入Op訊息前建立Half訊息的索引

Op訊息的儲存和對應關係

Op訊息寫入到全域性特定的Topic中透過原始碼中的方法

TransactionalMessageUtil.buildOpTopic();

這個Topic是一個內部的Topic(像Half訊息的Topic一樣),不會被使用者消費。Op訊息的內容為對應的Half訊息的儲存的Offset,這樣透過Op訊息能索引到Half訊息進行後續的回查操作。

Half訊息的索引構建

執行二階段Commit操作時,需要構建出Half訊息的索引。

  • 一階段的Half訊息由於是寫到一個特殊的Topic,
  • 二階段構建索引時需要讀取出Half訊息,並將Topic和Queue替換成真正的目標的Topic和Queue,之後透過一次普通訊息的寫入操作來生成一條對使用者可見的訊息。

所以,RocketMQ事務訊息二階段其實是利用了一階段儲存的訊息的內容,在二階段時恢復出一條完整的普通訊息,然後走一遍訊息寫入流程。

補償控制要點

如果由於網路閃斷、生產者應用重啟等原因,導致某條事務訊息的二次確認丟失,Broker端會透過掃描發現某條訊息長期處於"半事務訊息"時,需要主動向訊息生產者詢問該訊息的最終狀態(Commit或是Rollback)。

這樣最終保證了本地事務執行成功,下游就能收到訊息,本地事務執行失敗,下游就收不到訊息。總而保證了上下游資料的一致性。

注意:事務訊息的生產組名稱 ProducerGroupName不能隨意設定。事務訊息有回查機制,回查時Broker端如果發現原始生產者已經崩潰,則會聯絡同一生產者組的其他生產者例項回查本地事務執行情況以Commit或Rollback半事務訊息。

RocketMQ的回查功能實現原理

如果在RocketMQ事務訊息的二階段過程中失敗了,例如在做Commit操作時,出現網路問題導致Commit失敗,那麼需要透過一定的策略使這條訊息最終被Commit。RocketMQ採用了一種補償機制,稱為“回查”。

  • 回查次數的配置化

    • Broker端對未確定狀態的訊息發起回查,將訊息傳送到對應的Producer端(同一個Group的Producer),由Producer根據訊息來檢查本地事務的狀態,進而執行Commit或者Rollback。Broker端透過對比Half訊息和Op訊息進行事務訊息的回查並且推進CheckPoint(記錄那些事務訊息的狀態是確定的)。

    • 為了避免單個訊息被檢查太多次而導致半佇列訊息累積,我們預設將單個訊息的檢查次數限制為 15 次,但是使用者可以透過 Broker 配置檔案的 transactionCheckMax引數來修改此限制

    • 如果已經檢查某條訊息超過 N 次的話( N = transactionCheckMax ) 則 Broker 將丟棄此訊息,並在預設情況下同時列印錯誤日誌,執行回滾Rollback操作。

  • 回查行為的定製化d

    • 此外使用者可以透過重寫AbstractTransactionalMessageCheckListener 類來修改這個Rollback的行為,比如改寫為Commit,或者其他的記錄日誌或者傳送訊息郵件推送給指定人進行人工跟進。
  • 回查觸發時間定製化

事務訊息將在 Broker配置檔案中的引數transactionTimeout 這樣的特定時間長度之後被檢查。當傳送事務訊息時,使用者還可以透過設定使用者屬性CHECK_IMMUNITY_TIME_IN_SECONDS 來改變這個限制,該引數優先於 transactionTimeout 引數。

事務性訊息可能不止一次被檢查或消費。
  • 傳送給使用者的目標topic訊息可能會失敗,目前這依日誌的記錄而定。它的高可用性透過 RocketMQ 本身的高可用性機制來保證,如果希望確保事務訊息不丟失、並且事務完整性得到保證,建議使用同步的雙重寫入機制。

  • 事務訊息的生產者 ID 不能與其他型別訊息的生產者 ID 共享。與其他型別的訊息不同,事務訊息允許反向查詢、MQ伺服器能透過它們的生產者 ID 查詢到消費者。


訊息事務樣例

事務訊息共有三種狀態,提交狀態、回滾狀態、中間狀態。

  • TransactionStatus.CommitTransaction: 提交事務,它允許消費者消費此訊息。
  • TransactionStatus.RollbackTransaction: 回滾事務,它代表該訊息將被刪除,不允許被消費。
  • TransactionStatus.Unknown: 中間狀態(Pending),它代表需要檢查訊息佇列來確定狀態。

開發實現案例

傳送事務訊息樣例

建立事務性生產者

使用 TransactionMQProducer類建立生產者,並指定唯一的 ProducerGroup,就可以設定自定義執行緒池來處理這些檢查請求。執行本地事務後、需要根據執行結果對訊息佇列進行回覆。

	TransactionListener transactionListener = new TransactionListenerImpl();
	TransactionMQProducer producer = new
		TransactionMQProducer("please_rename_unique_group_name");
	ExecutorService executorService = new ThreadPoolExecutor(2, 5, 100,
												TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(2000), new ThreadFactory() {
           @Override
           public Thread newThread(Runnable r) {
               Thread thread = new Thread(r);
               thread.setName("client-transaction-msg-check-thread");
               return thread;
           }
       });
       producer.setExecutorService(executorService);
       producer.setTransactionListener(transactionListener);
       producer.start();
       String[] tags = new String[] {"TagA", "TagB", "TagC", "TagD", "TagE"};
       for (int i = 0; i < 10; i++) {
           try {
               Message msg =
                   new Message("TopicTest1234", tags[i % tags.length], "KEY" + i,
                       ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
               SendResult sendResult = producer.sendMessageInTransaction(msg, null);
               System.out.printf("%s%n", sendResult);
               Thread.sleep(10);
           } catch (MQClientException | UnsupportedEncodingException e) {
               e.printStackTrace();
           }
       }
       for (int i = 0; i < 100000; i++) {
           Thread.sleep(1000);
       }
       producer.shutdown();
   }
實現事務的監聽介面

TransactionListener介面的定義如下:

public interface TransactionListener {
    /**
     * When send transactional prepare(half) message succeed, this method will be invoked to execute local transaction.
     *
     * @param msg Half(prepare) message
     * @param arg Custom business parameter
     * @return Transaction state
     */
    LocalTransactionState executeLocalTransaction(final Message msg, final Object arg);

    /**
     * When no response to prepare(half) message. broker will send check message to check the transaction status, and this
     * method will be invoked to get local transaction status.
     *
     * @param msg Check message
     * @return Transaction state
     */
    LocalTransactionState checkLocalTransaction(final MessageExt msg);
}

當傳送半訊息成功時,我們使用 executeLocalTransaction 方法來執行本地事務。它返回前一節中提到的三個事務狀態之一。checkLocalTransaction 方法用於檢查本地事務狀態,並回應訊息佇列的檢查請求。它也是返回前一節中提到的三個事務狀態之一。

public class TransactionListenerImpl implements TransactionListener {
  private AtomicInteger transactionIndex = new AtomicInteger(0);
  private ConcurrentHashMap<String, Integer> localTrans = new ConcurrentHashMap<>();
  @Override
  public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
      int value = transactionIndex.getAndIncrement();
      int status = value % 3;
      localTrans.put(msg.getTransactionId(), status);
      return LocalTransactionState.UNKNOW;
  }
  @Override
  public LocalTransactionState checkLocalTransaction(MessageExt msg) {
      Integer status = localTrans.get(msg.getTransactionId());
      if (null != status) {
          switch (status) {
              case 0:
                  return LocalTransactionState.UNKNOW;
              case 1:
                  return LocalTransactionState.COMMIT_MESSAGE;
              case 2:
                  return LocalTransactionState.ROLLBACK_MESSAGE;
          }
      }
      return LocalTransactionState.COMMIT_MESSAGE;
  }
}

executeLocalTransaction 是半事務訊息傳送成功後,執行本地事務的方法,具體執行完本地事務後,可以在該方法中返回以下三種狀態:

  • LocalTransactionState.COMMIT_MESSAGE:提交事務,允許消費者消費該訊息
  • LocalTransactionState.ROLLBACK_MESSAGE:回滾事務,訊息將被丟棄不允許消費。
  • LocalTransactionState.UNKNOW:暫時無法判斷狀態,等待固定時間以後Broker端根據回查規則向生產者進行訊息回查。

checkLocalTransaction是由於二次確認訊息沒有收到,Broker端回查事務狀態的方法。回查規則:本地事務執行完成後,若Broker端收到的本地事務返回狀態為LocalTransactionState.UNKNOW,或生產者應用退出導致本地事務未提交任何狀態。則Broker端會向訊息生產者發起事務回查,第一次回查後仍未獲取到事務狀態,則之後每隔一段時間會再次回查。

事務訊息使用上的限制

事務訊息不支援延時訊息和批次訊息。

相關文章