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