MySQL 分散式事務的“路”與“坑”

語言: CN / TW / HK
1 資料庫事務
1.1 普通本地事務
分散式事務也是事務,事務的 ACID 基本特性依舊必須符合:
A:Atomic,原子性,事務內所有 SQL 作為原子工作單元執行,要麼全部成功,要麼全部失敗;
C:Consistent,一致性,事務完成後,所有資料的狀態都是一致的。如事務內A給B轉100,只要A減去了100,B賬戶則必定加上了100;
I:Isolation,隔離性,如果有多個事務併發執行,每個事務作出的修改必須與其他事務隔離;
D:Duration,永續性,即事務完成後,對資料庫資料的修改被持久化儲存。
普通的非分散式事務,在一個程序內部,基於鎖依賴於快照讀和當前讀,比較好實現 ACID 來保證事務的可靠性。但分散式事務參與方通常在不同機器的不同例項上,原來的區域性事務的鎖不能保證分散式事務的ACID特性,需要引入新的事務框架,MySQL的分散式事務是基於2PC(二階段提交)實現,下面詳細介紹下2pc分散式事務。
1.2 基於2pc的分散式事務
分散式事務有多種實現方式,如2PC(二階段提交)、3PC(三階段提交)、TCC(補償事務)等,MySQL是基於 2PC 實現的分散式事務,下面介紹 2PC 分散式事務實現方式。
兩階段提交:Two-Phase Commit , 簡稱2PC,為了使基於分散式系統架構下的所有節點在進行事務提交時保持一致性而設計的一種演算法。 
2PC的演算法思路可以概括為,參與者將操作成敗通知協調者,再由協調者根據所有參與者的反饋情報,決定各參與者是否要提交操作還是中止操作。這裡的參與者可以理解為 Resource Manager (RM),協調者可以理解為 Transaction Manager(TM)。
下圖說明了RM和TM在分散式事務中的運作過程:
第一階段提交:TM 會發送 Prepare 到所有RM詢問是否可以提交操作,RM 接收到請求,實現自身事務提交前的準備工作並返回結果。 
第二階段提交:根據RM返回的結果,所有RM都返回可以提交,則 TM 給 RM 傳送 commit 的命令,每個 RM 實現自己的提交,同時釋放鎖和資源,然後 RM 反饋提交成功,TM 完成整個分散式事務;如果任何一個 RM 返回不能提交,則涉及分散式事務的所有 RM 都需要回滾。
2 MySQL 分散式事務XA
MySQL分散式事務XA是基於上面的2pc框架實現,下面詳細介紹MySQL XA相關內容。
2.1 XA事務標準
X/Open 這個組織定義的一套分散式XA事務的標準,定義了規範和API介面,然後由廠商進行具體的實現。
XA規範中分散式事務由AP,RM,TM組成:
如上圖,應用程式AP定義事務邊界(定義事務開始和結束),並訪問事務邊界內的資源。資源管理器RM管理共享的資源,也就是資料庫例項。事務管理器TM負責管理全域性事務,分配事務唯一標識,監控事務的執行進度,並負責事務的提交、回滾、失敗恢復等。MySQL實現了XA標準語法,提供了上面的RMs能力,可以讓上層應用基於它快速支援分散式事務。
2.2 MySQL XA語法
XA START xid:開啟一個分散式事務xid。
XA END xid: 將分散式事務xid置於 IDLE 狀態,表示事務內的SQL操作完成。
XA PREPARE xid: 事務xid本地提交,成功狀態置於 PREPARED 失敗則回滾。
XA COMMIT xid:  事務最終提交,完成持久化。
XA ROLLBACK xid: 事務回滾終止。
XA RECOVER: 檢視 MySQL 中存在的 PREPARED 狀態的 XA 事務。
(1)語法要點
參與分散式事務的例項之間,在資料庫核心視角沒有直接關聯,互相不感知狀態,且一個分散式事務中各個節點上的子事務均可單獨執行無依賴,他們之間的關聯是通過全域性事務號在應用層建立的。
與普通事務比,XA事務開啟時多了一個全域性事務號,結束時多了一個end動作 和 prepare動作。
XA START, 開啟一個分散式事務,需要指定分散式事務號。
XA END ,在內部僅是一個狀態變化,聲明當前XA事務結束,不允許追加新的sql語句,無其它作用,業界有人提出XA事務框架去掉這一步,減少一次網路互動,提高效能。
XA PREPARE,寫 binlog 和 redo log,預提交事務,並將分散式事務資訊儲存到全域性記憶體結構,讓其它連線可以查詢、回滾、提交,如果 prepare 失敗則回滾。
XA COMMIT,真正提交事務,修改事務狀態,釋放鎖資源。如果例項上 XA PREPARE 已經成功,那麼它的 XA COMMIT 一定能成功。
XA事務示例:201使用者給202使用者轉賬1000元,簡化如下:
第1步,開啟一個分散式事務,xa_ts:10001是應用層定義的全域性事務號,例項1和例項2通過它來構建分散式事務。
第2、3步是普通事務語句。
第4步,聲名xa事務結束,在此之後不能再追加更新插入查詢等語句,不屬於這個分散式事務也不允許,其它語句放在xa commit或xa rollback之後。
第5步,prepare 成功後,上層應用可以發起第6步提交事務。注意,必須是所有參與這個分散式事務的全部節點均 prepare 成功,即例項1和例項2都完成prepare,應用端才能發起提交,兩階段提交的框架核心點就在此。
如果有節點在前5步不能成功,所有參與分散式事務的節點都必須回滾。如例項2是賬戶加1000元,基本上什麼情況都能成功,肯定能成功執行第5步,但例項1就未必了,賬戶要扣1000元,可能資金不夠,會出錯回滾,若例項1不能執行到prepare,所有分散式事務參與者也必須回滾,所以例項2也要回滾。如果第5步全部成功,有一個節點執行了第6步提交了事務,那麼所有節點必須要均提交,否則就會導致資料不一致。處於xa prepare不提交會佔用資源,殘留xa事務等價於存在長事務,對刷髒和purge等都有影響,業務層最好要立即提交。
(2)殘留XA事務如何處理
上面說到xa事務不提交等價於長事務,一旦prepare成功要立即提交,否則會帶來很多問題。但是資料庫crash或應用系統出錯crash等原因都可能導致xa事務未能全部提交,這些殘存XA事務如何處理?這就要用到上面的 XA RECOVER語法了,執行xa recover 檢視未提交XA事務,選擇對應的進行rollback或commit。如果僅 gtrid_length欄位有值一般可以直接 xa rollback/commit  xid方式回滾或提交,xid就是xa recover中data。
如果gtrid_length和bqual_length 都有值,回滾或提交則相對複雜一些,需要以下面方式提交或回滾:
gtrid 和 bqual被拼接在 data欄位中,需要按他們長度切分,以下面未提交xa事務裡第一個為例,gtrid_length 為34,表示data中前34個字元為gtrid, bqual_length 為22,表示data中後22個字元為bqual,那麼對對其回滾或提交方式可表示如下:
如果data中有其它特殊字元,也可以轉成16進位制整數方式處理,執行語句如下:
因為是16進位制數,字元做了轉換,data中字元數會翻倍,回滾或提交內容要同步調整,將data中字元也要翻倍再拆分,如上grtrid長度34,則data中前34*2個16進位制數字是gtrid,bqual長度22,則後44個16進位制數字是bqual,回滾或提交語法如下:
注意:上面的提交或回滾都可能報xid不存在,這不一定是xid寫錯了,也可能是開啟這個XA事務的連線並未斷開,其它連線不能處理這個XA事務,這裡是MySQL報錯不準確。
(3)提交還是回滾的依據
上面給出如何進行提交或回滾的方法,但是提交or回滾應該選擇哪個?
殘留XA事務是提交還是回滾,必須要由業務決定,誰開啟XA事務,構建了分佈事務管理器TM,誰就必須為這個事務負責到底。
單個數據庫視角無法判斷出這個XA事務是應該提交還是應該回滾,不管選哪種都可能會導致全域性資料出錯,運維同學在處理時一定要與業務方確定好該事務是提交還是回滾,獲得授權後再操作。以上面轉賬為例,201使用者給202轉1000元,都prepare成功,發起commit,此時202使用者例項發生故障重啟,未完成commit,重啟之後有殘留XA事務,此時若201提交成功,那麼202必須提交,如果201未成功,202可以先201一起提交或一起回滾,由應用層事務管理器TM來決定。假如201提交成功,202回滾則201扣了1000,202未收到,對賬則錢少了。如201回滾了,202提交,則202加了1000,201未扣,對賬則錢多了。
2.3 MySQL XA事務設計上的“坑”
(1)設計上的缺陷
基於binlog的主從複製是MySQL高可用的基石,這也是MySQL能廣泛流行使用的最重要因素。在MySQL內部,對於普通事務(非XA事務),innodb等引擎和binlog為了保持資料的一致性,就是用的 2PC ,為了區分於XA事務的2PC ,稱之為內部兩階段提交。內部2pc使用binlog是作為協調者(TM),內部prepare時先寫redo再寫binlog,都持久化(受刷盤引數策略影響)後再提交。當發生Crash重啟時,會先恢復出所有prepare成功的事務,把裡面的xid事務號取出來,再到協調者Binlog中去找,如果binlog中有這個xid則說明innodb和binlog都執行成功,等價於外部xa 事務兩個參與節點都prepare成功,則繼續提交,如果binlog中找不到,剛說明只在引擎層完成,需要回滾,如果某個進行的事務xid在prepare中未找到,則說明prepare未完成,直接回滾,這個順序一定是先寫Redo log,最後寫Binlog。
那麼處於XA prepare 狀態的分散式事務到底是一個什麼樣的狀態?分散式XA事務也是基於普通事務實現,實際上就是一個支援掛起,支援讓其它會話繼續提交或回滾,支援crash或重啟之後還能恢復這種掛起狀態的普通事務。
普通事務的prepare動作是發生在顯式commit之後,先寫redo後再寫binlog。XA事務的prepare發生在顯式XA commit之前,它需要生成binlog,然後再寫redo,這與普通事務是相反的,這就導致這個外部2pc事務的內部2pc提交缺少了一個協調者,某些情況下會導致資料庫不一致。
一個XA事務的binlog由兩部分組成,從xa start到xa prepare是一個不可分原子語句塊,xa commit又是一個原子語句塊,且分別有各自的gtid,如下圖binlog:
 事務號為  X'7831',X'',1 的分散式事務prepare之後,中間插入了很多普通事務,然後再執行的xa commit。
一個XA事務的binlog被切分成了兩個獨立的部分,如果在主節點在生成XA prepare binlog之後發生crash, 還沒有在引擎層做prepare,重啟之後引擎層中因沒有完成prepare動作而回滾。但在主從架構中,只要binlog正常產生就可能會同步到Slave機,這種情況下會導致slave機上多了這個xa prepare的中間狀事務,最終複製出現問題。這個問題已經被發現多年,官方確認了bug,一直未修復(https://bugs.mysql.com/bug.php?id=87560)。
(2)遇到該問題處理思路
雖然我們要儘量避免出現故障,但也做好面對任何故障的準備,謀而後動,有招不亂!
在常規連線中,MySQL的XA事務執行prepare之後,通常不能執行其它非xa語句,會報錯提醒當前正在xa事務中。但在複製的sql 回放執行緒中,執行完xa prepare之後,可以直接執行其它非此xa事務的sql,因為在master端生成的XA事務Binlog可能就是分開的,如上圖例子就是。所以slave機sql執行緒執行完xa prepare的binlog後,是被允許接著正常執行其它事務的binlog的。如果xa preapre過程master上發生crash,剛好生成了binlog,但沒有做完後續的prepare動作,備機收到了這個xa preare動作的binlog,master重啟後會回滾掉這個事務,不會再生成這個xa事務後續binlog,這會導致備機執行完xa prepare後一直掛起,佔用的鎖等資源不會釋放,直到新同步過來的binlog與之衝突報錯,才會暴露問題。
要修復分兩種情況處理:
情況1:基於gtid的複製,應該直接會報gtid重複錯誤(推測,本地沒能復現)。master上重啟應該會回滾掉了前半個XA事務,後面事務會重新生成這個相同gtid的事務,導致複製出錯,此時停止複製,將備機上這半個XA事務回滾,並reset gtid到之前的gtid,重建複製即可。注意這裡可能有多個XA事務在Binlog中處於prepare狀態,需要解析binlog仔細確定要回滾的事務是哪個。
情況2:未開gtid的複製,此時比上面情況要麻煩,沒有gtid來確定binlog事務是否重複,只要後面事務不涉及到這半個xa事務鎖定的資源,備機就可以正常維持複製體系,一直同步資料,等到有衝突資料出現錯誤,回放執行緒重試超過一定次數後(slave_transaction_retries重試引數控制),sql執行緒報出相應錯誤,複製中斷後才能被感知。恢復資料和上面差不多,回滾這個XA事務,重建主從,但是這個事務的binlog不一定能找到,因為沒有gtid不會立即報錯,可能幾分鐘後報錯,也可能幾個月後報錯,取決於業務什麼時候產生衝突資料。並且在這個事務之後,從機又同步了很多資料,這些資料是否可靠需要評估。線上強烈建議開啟Gtid複製模式,非gtid的複製官方已經在淘汰!
3 分散式事務的一致性
使用到分散式事務,就必須要保證分散式事務的一致性。
分散式事務的一致性又分寫一致性和讀一致性,寫一致性XA框架XA prepare 和XA commit已經解決,只要保證有提交全提交,有回滾全回滾就能保證寫一致性。
讀一致性則要複雜的多,先看看MySQL官方對XA事務在讀一致性上的“隻言片語”:

上面內容是從官方說明文件裡擷取,裡面對XA讀一致性略有介紹:如果應用程式對讀敏感,首選SERIALIZABLE隔離級別,RR級別不足以用於分散式事務,官方沒有對這裡的不足做具體說明,但我們可以構建一個例子來分析這個“may not be sufficien”來描述讀一致性是否恰當。
如下圖,有A、B兩個賬戶在兩個例項上,假設每個賬戶初始都100塊,A給B轉賬20,時間線左邊為A賬戶例項上的操作,右邊為B賬戶例項上的操作,中間T1到T6為不同時間點。
T1時刻:初始均100。
T2時刻:AB賬戶均完成xa prepare操作,一個減20,一個加20。
T3時刻:A帳戶節點XA commit成功。
T5時刻:B帳戶XA commit成功。
當處在RR或RC隔離級別時,發起一個對賬操作,統計AB帳戶資金總額,當只有他們相互轉賬時,總金額應該恆為200。T6 時刻時,查詢A為80,B為120,總賬為200,無問題。T4時刻查詢A賬戶為80,查詢B賬戶時由於MVCC機制,會讀到上個快照中的值100,加一起為180,總賬不對。因為是操作不同例項,當開始做xa commit之後,可能由於網路等原因,並不能保證所有節點的XA commit同時到達所有節點,在一個高併發場景,導致上面的問題幾乎是必然的。因此,當使用MySQL 原生XA分散式事務時,若無其它手段來保障讀一致性,而應用又有跨節點讀的應用場景,應當使用序列化(SERIALIZABLE)隔離級別,“may not be sufficien”顯然是不恰當的,沒有任何一個業務能接受這種資料統計不對的。
如果是序列化隔離級別,T4時刻讀到A為80,讀B時會等待,直到T5時刻XA commit成功之後,  才能讀到B為120,總賬200,無問題。序列化隔離級別只有讀-讀不阻塞,讀-寫,寫-讀,寫-寫均會阻塞,而RC、RR僅寫-寫阻塞,因此只有序列化隔離級才能充分保障MySQL XA事務的讀一致性。但它阻塞太多,效能也是各種隔離級別中最差的,所以如無必要,通常不會使用這一隔離級別。業界有很多方案來解決分散式事務RR、RC下的讀一致性問題,以提高資料庫效能,但原生的MySQL不具備這種能力,因此使用MySQL原生XA事務的業務需要謹慎選擇隔離級別。
4 小結

只要我們小心面對殘留XA事務,謹慎處理Crash之後的可能存在的多餘binlog資料,認真評估使用RR、RC隔離級別是否有讀一致性讀問題等問題之後,MySQL 的XA事務基本沒有其它問題,可以作為RM完備提供跨節點分散式事務能力,MySQL已經實現了X/Open 組織定義的分散式事務處理規範中的語法功能,完全可以放心放業務在這條路上奔跑!

作者簡介

Flyfox  高階後端工程師

從事資料庫核心工作十多年,深度參與多個基於PostgreSQL、MySQL自研資料庫專案,目前負責RDS產品研發團隊工作。

推薦閱讀

|Elastic-Job的執行原理及優化實踐

|圖資料庫平臺建設及業務落地

|資料庫查詢效能優化指南


本文分享自微信公眾號 - OPPO數智技術(OPPO_tech)。
如有侵權,請聯絡 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。