關於 RocketMQ 事務訊息的正確開啟方式 → 你學廢了嗎

青石路發表於2021-11-15

開心一刻

  昨晚和一哥們一起吃夜宵,點了幾瓶啤酒

  不一會天空下起了小雨,哥們突然道:糟了

  我:怎麼了

  哥們:外面下雨了,我老婆還在等著我去接她

  他給了自己一巴掌,說道:真他媽不是個東西

  我心想:哥們真是個好丈夫

  很快他補充道:喝酒怎麼能分心呢

  我一口啤酒直接笑噴而出

知識回顧

  本文不講什麼是 RocketMQ ,不講它的實現原理,只想和大家探討下它的事務訊息的正確使用方式

  再探討之前,先帶大家回顧下知識點

  事務訊息的設計原理

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

關於 RocketMQ 事務訊息的正確開啟方式 → 你學廢了嗎

  什麼,英文看不懂?貼心的我早已想到,中文版的也有

  其中有兩個點:半事務、回查事務狀態,值得我們重點回顧

  Half 訊息

  何謂 half 訊息?

  訊息傳送方把訊息傳送到 MQ 服務,但是此訊息的狀態被標記為不能投遞,處於這種狀態下的訊息稱為 half 訊息;消費方不能消費 half 訊息

  傳送方對 half 訊息二次確認後,也就是 Commit 之後,消費方才可以消費到;如果是 Rollback,該訊息則會被刪除,永遠不會被消費到

  事務狀態回查

  如果在 RocketMQ 事務訊息的二階段過程中失敗了,例如在做 Commit 操作時(上圖中的第 4 步),出現網路問題導致 Commit 失敗,那麼需要通過一定的策略使這條訊息最終被 Commit

  RocketMQ 採用了一種補償機制,稱為“回查”。Broker 端對未確定狀態的訊息發起回查,將訊息傳送到對應的 Producer 端(同一個 Group 的 Producer),由 Producer 根據訊息來檢查本地事務的狀態,進而執行 Commit 或者 Rollback

  值得注意的是,RocketMQ 並不會無休止的的資訊事務狀態回查,預設回查 15 次,如果 15 次回查還是無法得知事務狀態,RocketMQ 預設回滾該訊息

  更多細節請檢視:事務訊息

實戰示例

  理論知識理解之後,就需要我們進行實操與分析了

  需求背景

  假設我們有兩個服務:訂單服務、積分服務,當使用者成功下單之後,需要給使用者加相應的積分

  實現方式有很多種,你知道哪些?

  假設我們用 RocketMQ 事務訊息來保證最終一致性,我們又該如何實現?

  環境準備

  RocketMQ:4.8.0

  rocketmq-client:4.9.2

  Spring Boot:2.1.0.RELEASE

  MySQL:5.7.29

  MyBatis Plus:3.4.2

  建表 SQL

關於 RocketMQ 事務訊息的正確開啟方式 → 你學廢了嗎
-- order
CREATE TABLE `order`.`t_order` (
  `order_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `order_no` char(20) NOT NULL COMMENT '訂單號',
  `user_id` bigint(32) NOT NULL COMMENT '使用者id',
  `order_amount` decimal(16,2) NOT NULL,
  `note` varchar(255) DEFAULT NULL COMMENT '備註',
  PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 不一定非要存half訊息的事務id,實現方式有很多,甚至可以不用這張表,直接通過 t_order 新增欄位來實現
CREATE TABLE `order`.`t_order_transaction_log` (
  `transaction_id` varchar(32) NOT NULL COMMENT '主鍵(half 訊息的事務id)',
  `order_id` bigint(20) NOT NULL COMMENT '訂單主鍵',
  `note` varchar(500) DEFAULT NULL COMMENT '備註',
  PRIMARY KEY (`transaction_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


-- points
CREATE TABLE `points`.`t_point` (
  `point_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
  `order_no` char(20) NOT NULL COMMENT '訂單號',
  `user_id` bigint(20) NOT NULL COMMENT '使用者id',
  `point_num` decimal(16,2) NOT NULL COMMENT '積分數量',
  `note` varchar(255) DEFAULT NULL COMMENT '備註',
  PRIMARY KEY (`point_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
View Code

  專案地址:spring-boot-rocketmq-orderspring-boot-rocketmq-points

  後續只會對關鍵程式碼進行講解,所以建議大家把程式碼 down 下來看看,保證有個基本的印象

  回到標題,樓主為什麼會強調:正確的開啟方式

  你猜對了,RocketMQ 事務訊息的使用方式有很多種,樓主就結合工作專案中的使用方式,來和大家一起討論下,哪些方式是正確的,哪些方式是不正確的(以及不正確的原因)

  結合 Half 訊息傳送的時機,大致可分為三種:

  根據 half 訊息的位置,我們暫且將這三種方式命名為:half 訊息後置、half 訊息中置、half 訊息前置

  我們逐個來討論使用是否正確

  half 訊息後置

  這種方式有沒有覺得似曾相識?與發普通訊息是不是很類似? 本地業務執行完之後,發普通訊息給積分中心,是不是熟悉的味道?

  但還是有區別的,至少有回查機制,我們結合虛擬碼具體看看

  我們來分析下各種異常情況,看看這種方式是否有問題

  1、訂單資料或訂單事務日誌落庫異常,事務回滾,half 訊息不會傳送,沒問題

  2、half 訊息傳送異常,事務會回滾,沒問題

  3、half 訊息傳送未發生異常,但返回的不是 SEND_OK 狀態,程式碼丟擲了異常,事務回滾,沒問題

    思考:如果我們不關注 half 訊息傳送的結果,像這樣

    最終,訊息會推送給積分服務嗎?

  雖然看起來怪怪的,但又挑不出毛病

  half 訊息中置

  我們直接看虛擬碼

  我們來分析下各種異常情況,看看這種方式是否有問題

  1、訂單資料落庫異常,事務回滾,half 訊息不會傳送,沒問題

  2、half 訊息傳送異常,事務會回滾,沒問題

  3、half 訊息傳送未發生異常,但返回的不是 SEND_OK 狀態,程式碼丟擲異常,事務會回滾,沒問題

    思考:與之前的思考問題一樣,如果我們不關注 half 訊息傳送的結果,最終訊息會推送給積分服務嗎?

    只有傳送 half 訊息成功,並且傳送狀態為 SEND_OK ,才會執行 executeLocalTransaction ,向 t_order_transaction_log 表寫入事務日誌

    那麼即使 Broker 回查事務狀態,它得到的結果始終是 UNKNOW ,最終 half 訊息會被回滾,積分服務收不到訊息

    導致的問題就是:使用者下單成功,但卻沒有增加積分

    可見關注 half 訊息傳送結果的重要性

  4、half 訊息傳送成功,且返回的是 SEND_OK 狀態,但 executeLocalTransaction 執行異常了,會是什麼結果?

    程式碼很明顯,我們進行了 catch ,異常不會向上拋,訂單落庫還是成功的,只是訂單事務日誌落庫失敗了

    返回 ROLLBACK_MESSAGE ,half 訊息會回滾,積分服務收不到訊息

    那麼同樣的問題又出現了:使用者下單成功,但卻沒有增加積分

    如果我們不 catch ,像這樣

    理論上來講,異常往上拋,訂單資料會回滾, Broker 回查事務狀態,一直返回 UNKNOW ,最終積分服務收不到訊息

    理論上來講沒問題,但事實呢? 我們來實踐一下

關於 RocketMQ 事務訊息的正確開啟方式 → 你學廢了嗎

    哦豁,竟然沒有列印異常日誌,也就說異常被 catch 沒有往外拋,訂單資料也落庫了

    那麼又會出現同樣的問題:使用者下單成功,但卻沒有增加積分

    至於誰把異常 catch 了沒往外拋,相信大家都能想到,這算是 rocketmq-client 的一個 bug ;原始碼稍後再跟,我們先看完前置

  half 訊息前置

  直接上虛擬碼

  我們來分析下各種異常情況,看看這種方式是否有問題

  1、half 訊息傳送異常,本地事務不會執行,沒問題

  2、half 訊息傳送未發生異常,但返回的不是 SEND_OK 狀態,程式碼丟擲異常,本地事務不會執行,沒問題

    思考:與之前的思考問題一樣,如果我們不關注 half 訊息傳送的結果,會是什麼結果?

    只有 half 訊息傳送成功,且返回狀態是 SEND_OK 才會執行 executeLocalTransaction 

    即使 Broker 回查事務狀態,得到的結果始終是 UNKNOW ,最終 half 訊息會被回滾,積分服務收不到訊息

    訂單服務與積分服務都沒有落庫成功,也就說是沒問題的

  3、half 訊息傳送成功,且返回的狀態是 SEND_OK ,但 executeLocalTransaction 執行異常了,會是什麼結果

    也就是 save 方法執行異常了,我們來實踐下

關於 RocketMQ 事務訊息的正確開啟方式 → 你學廢了嗎

     異常還是被 catch 了沒往外拋,但是訂單資料卻回滾了,就結果而言是沒問題的

    half 訊息傳送成功了,但是 Broker 一直未收到本地事務的確認訊息, Broker 會回查,得到的結果始終是 UNKNOW ,最終 half 訊息會被回滾,積分服務收不到訊息

    訂單資料回滾了,積分服務未收到訊息,那麼此種情況是沒問題的

  看起來挺順眼,異常情況下也沒什麼問題

rocketmq-client 的 bug

  需要弄清楚的問題有兩個:

  1、half 訊息中置, executeLocalTransaction 的異常為什麼沒有丟擲來

  2、half 訊息前置, 異常同樣沒有丟擲來,為什麼訂單資料卻回滾了

  先看第一個問題,我們來跟下原始碼

關於 RocketMQ 事務訊息的正確開啟方式 → 你學廢了嗎

   rocketmq-client 捕獲了異常,但並未向外拋

關於 RocketMQ 事務訊息的正確開啟方式 → 你學廢了嗎  其實 RocketMQ 是有列印日誌的,只是樓主的日誌配置的不對,導致控制檯未列印出來

  對於第 1 個問題,相信大家已經清楚了

  關於第 2 個問題,我就不具體分析了,我給個提示,從事務 AOP 的控制範圍與異常丟擲點來考慮,如下圖

最終一致性

  前面講了那麼多,都是講的訂單服務,總結起來就是:事務訊息(而非 half 訊息)傳送成功,那麼本地事務一定是執行成功的

  保證的是事務訊息的傳送與訂單服務的強一致

  如果積分服務消費異常呢?

  那對不起,RocketMQ 事務訊息處理不了這種情況,回滾不了訂單服務的資料,只能通過補償機制(比如人工修復)修復積分服務的資料

總結

  1、三種方式的抉擇

    half 訊息中置,問題比較多,不推薦

    half 訊息後置,看起來挺彆扭的(難道只是樓主這麼覺得?),倒是沒什麼問題

    half 訊息前置,符合 RocketMQ 事務訊息的設計原理,推薦採用此種方式

  2、一定要關注 half 訊息傳送的結果,不拋異常不代表一定成功了,必要時需要根據 half 訊息傳送的結果做後續邏輯處理

  3、最終一致性

    RocketMQ 考慮的是資料最終一致性,上游服務提交之後,下游服務最終只能成功,做不到回滾上游服務的資料

參考

  基於RocketMQ分散式事務 - 完整示例

相關文章