開心一刻
昨晚和一哥們一起吃夜宵,點了幾瓶啤酒
不一會天空下起了小雨,哥們突然道:糟了
我:怎麼了
哥們:外面下雨了,我老婆還在等著我去接她
他給了自己一巴掌,說道:真他媽不是個東西
我心想:哥們真是個好丈夫
很快他補充道:喝酒怎麼能分心呢
我一口啤酒直接笑噴而出
知識回顧
本文不講什麼是 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;
專案地址:spring-boot-rocketmq-order,spring-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 考慮的是資料最終一致性,上游服務提交之後,下游服務最終只能成功,做不到回滾上游服務的資料