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

開心一刻

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

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

  我:怎麼了

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

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

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

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

  我一口啤酒直接笑噴而出

知識回顧

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

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

  事務消息的設計原理

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

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

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

  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

-- 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 ,最終積分服務收不到消息

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

    哦豁,竟然沒有打印異常日誌,也就說異常被 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 方法執行異常了,我們來實踐下

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

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

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

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

rocketmq-client 的 bug

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

  1、half 消息中置, executeLocalTransaction 的異常爲什麼沒有拋出來

  2、half 消息前置, 異常同樣沒有拋出來,爲什麼訂單數據卻回滾了

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

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

  其實 RocketMQ 是有打印日誌的,只是樓主的日誌配置的不對,導致控制檯未打印出來

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

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

最終一致性

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

  保證的是事務消息的發送與訂單服務的強一致

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

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

總結

  1、三種方式的抉擇

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

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

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

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

  3、最終一致性

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

參考

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章