深度剖析分布式事務,輕松掌握實現原理與應用技巧!
前言
大家好,今天我們來一起探討分布式事務的相關知識。相信大家都有多多少少接觸過分布式事務,因為我們現在寫的代碼可是服務于億級用戶量級的,那么大的請求量級不可能全部寫在一臺服務器上面對吧。如果你還沒有研究過分布式事務,也沒關系,我們今天再一起來探討一番。我曾經接觸過分布式事務相關的中間件框架,比如現在很火的阿里開源的一款分布式事務中間件Seata。目前我在Seata社區主要做一些RPC以及性能優化的相關工作,所以我可能會對分布式事務具體實現比較了解。以Seata為契機,我們一起來探討分布式事務。
什么是事務?
開始前,先來問大家兩個問題:
第一問題:什么是事務?
在編寫代碼的時候,我們常常會遇到各種事務問題。那么,我們該如何清晰明了地描述事務的概念呢?事務是指如何確保對一組(多個)數據操作在執行的過程中,要么全部都能夠成功執行,要么全部失敗。而且,一旦事務成功執行,所變更的數據不會丟失;若事務失敗,所有的數據變更都要回到事務開始之前的狀態。簡單來說,事務包括多個操作,這些操作要么全部執行,要么全部不執行。
第一問題:保證事務的目的是什么?
在理解事務的概念后,我們需要明確實現事務的最終目的是什么?如果在一組事務中,有些操作執行了,有些沒執行,會產生什么問題呢?舉個例子,如果你給父母轉賬1W元,結果你的賬戶扣了1W元,但是你父母的賬戶卻沒有加上1W元,這時你就會開始懷疑自己賺錢的意義。這種情況就是所謂的“數據一致性問題”。
當大家明確了以上兩個問題之后,我才能繼續往下跟大家繼續分享今天的這個主題,因為今天這個主題,都是在圍繞著怎么保證事務一致性的問題展開的。
單進程下完美的解決方案
A(原子性)、C(一致性)、I(隔離性)、D(持久性)。C 是事務最終的目標,那么A、I、D 就是為實現這個目標努力的打工仔,如果這幾個打工仔不能正常工作的話,那么一致性就得不到保障。
原子性:原子性是指一組操作要么全部執行成功,要么全部執行失敗。這個概念和事務十分相似。如果不保證原子性,就可能出現在同一個事務中,某些操作執行成功,而另一些操作執行失敗的情況,這會導致數據不一致,而且很難恢復。因此,原子性是保障數據一致性的重要特性之一。
隔離性: 事務的隔離性指的是多個事務之間的操作不會相互影響,它們之間相互隔離。如果沒有隔離性,就好像兩個人在同一張畫布上畫畫,一個畫豬,一個畫狗,最后會畫出一個四不像。也就是說,如果不保證隔離性,一個人修改數據時,其他人也可以修改,這會導致數據不一致。
持久性:持久性指的是一旦事務提交,所產生的數據變更不會因為任何意外(比如數據庫故障或服務器宕機)而丟失。因為如果事務產生的部分數據丟失,就會導致數據不一致。
單機事務實現采用ACID模型,通過加鎖實現對需要操作相同數據的事務進行隔離,保證事務之間的操作不會相互影響,從而實現了隔離性。在事務提交之前,記錄數據修改前的日志(undo log)和事務需要變更數據的日志(redo log),以保證事務不論在哪個階段都能通過undo log對事務數據進行回滾,把數據恢復到事務開始之前的狀態。同時,通過redo log保證事務在提交后,即使數據庫或服務器出現故障,也能重做未成功寫入磁盤的數據,實現了事務的持久性和原子性。
分布式事務的誕生
在公司發展初期,由于用戶量少、數據量少,系統的并發請求并不高,因此只需要將應用單點部署即可滿足業務需求。但隨著業務的快速發展和復雜度的增加,幾乎每個公司的系統都會從單體架構轉向分布式架構,特別是微服務架構。
單進程事務演變成多進程事務時,場景發生了改變。之前是一個人獨立完成一項任務,現在變成了多個人協作完成同一項任務。在單進程事務中,決定權在自己手中,因此決定回滾或提交事務較為容易。但在多進程事務中,如何協調多個人的操作以達到一致性,則成為一個難題。因此,需要有一個統一的協調者來協調多個節點的操作,以確保多個進程操作的一致性。
比如從圖中看到,假設在RPC調用過程中,其中有一個RPC調用異常了,我們怎么去回滾前面兩個已經執行成功的事務呢?
這就不得不涉及到我們應該怎么去設計一個分布式事務的執行模型。
分布式事務模型:2PC
目前絕大部分分布式事務框架為 2PC 二階段事務模型。
2PC協議的核心思路是協調者和參與者通過兩個階段的協商達成最終操作的一致性。首先,第一階段的目的是確認各個參與者是否具備執行事務的條件。根據第一階段參與者的響應結果,制定出第二階段的事務策略。如果第一階段中任意一個參與者不具備事務執行條件,那么第二階段的決策就是回滾事務。只有在所有參與者都具備事務執行條件的情況下,才進行整體事務的提交。
但是這個模型也不是萬能的,在遇到異常情況,很可能就會造成數據不一致(但是這個不一致,在最后都會有框架驅動達成最終一致性)
我下面舉兩個例子
參與者掛掉
如果在第一階段,協調者發送Prepare指令給所有的參與者后,參與者掛掉了,那么此時協調者因為遲遲收不到參與者的消息而導致超時,所以協調者在超時之后會統一發送abort指令進行事務回滾。
如果在第二階段,協調者發送commit或者abort指令給所有參與者后,參與者掛掉了,那么協調者會在超時之后進行消息重發,直到參與者恢復后收到到commit或者abort ,向協調者返回成功。
協調者掛掉
協調者在第一階段發送Prepare指令后掛掉,那么此時參與者此時會一直得不到協調者下一步的指令,那么此時參與者會一直陷入阻塞狀態,資源也會一直被鎖住,直到協調者恢復之后向參與者發出下一步的指令。
協調者在第二階段掛掉,那么此時協調者已向所有者發出最后階段的指令了,所以收到指令的參與者會完成最后的commit或rollback操作,對于參與者來說事務已經結束,所以不存在阻塞和鎖的問題, 當協調者恢復后,會把事務日志狀態標記為結束。
CAP 定律
強一致性的事務一致性方案在單機事務場景下可以完美實現,但在分布式事務場景下效果并不理想。這是因為單機事務和分布式事務所面臨的場景不同。在單機事務中,只需要考慮數據一致性問題。而在分布式事務場景中,需要同時考慮數據一致性、多節點的可用性、網絡分區等多個問題。因此,強一致性的事務模型始終無法完美解決分布式事務場景。
由此引出CAP定律,什么是CAP定律呢?
CA組合就是保證一致性和可用性,放棄分區容忍性,即不進行分區,不考慮由于網絡不通或節點掛掉的問題。那么系統將不是一個標準的分布式系統,我們最常用的關系型數據庫就滿足了CA。
CP組合就是保證一致性和分區容忍性,放棄可用性。Zookerper就是追求強一致性,放棄了可用性,還有跨行轉賬,一次轉賬請求要等待雙方銀行系統都完成整個事務才能完成。
AP組合就是保證可用性和分區容忍性,放棄一致性。這是分布式系統設計時的選擇。
BASE 理論
CAP理論表明在分布式系統中,無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance)。在分布式系統中,分區容錯性是必須滿足的,而可用性是分布式系統設計的主要目標,通常需要犧牲一致性來保證可用性和分區容錯性。
但是,犧牲一致性并不意味著完全放棄它。所謂犧牲,是指在一段時間內,系統可以暫時不保證一致性,但最終還是要恢復到一致性狀態,通常被稱為最終一致性。基于最終一致性模型,BASE理論提出了一套實踐理論,從基本可用性、軟狀態和最終一致性三個方面來指導我們進行分布式系統設計。
Seata介紹
Seata(Simple Extensible Autonomous Transaction Architecture,簡單可擴展自治事務框架)是 2019 年 1 月份阿里巴巴和螞蟻集團共同開源的分布式事務解決方案。目前在GitHub已經有超過 2 萬+ star,社區非常活躍。我在19年7月份的時候正式加入Seata開源社區。
在整個 Seata 體系下,所有模式(AT、TCC、XA、SAGA)都遵循這套角色模型。
Seata AT 模式
從圖中以及代碼中可以看到,在分布式事務場景下,只需要在發起方的方法上面添加注解@GlobalTransaction注解就可以了,完全不「干擾」業務的邏輯。
Seata AT 模式:通信交互
可以看出,AT 模式遵循 TC、TM、RM 交互:
- 首先TM回向TC服務發送一個Begin指令開啟全局事務,TC 返回全局事務xid;
- 各個分支事務向TC服務發送Branch Register進行分支事務注冊;
- TM 向TC服務決議全局提交或者回滾,TC 收到TM最終的二階段指令后,會驅動各個分支進行提交或者回滾。
可以看出,Seata AT模式是一個 2PC 事務模型。
Seata AT 模式如何保證對業務的無入侵?
1、數據源代理
Seata 在數據源做了一層代理層,所以我們使用 Seata 時,我們使用的數據源實際上用的是 Seata 自帶的數據源代理 DataSourceProxy,Seata 在這層代理中加入了很多邏輯,主要是解析 SQL,把業務數據在更新前后的數據鏡像組織成回滾日志,并將 undo log 日志插入 undo_log 表中,保證每條更新數據的業務 sql 都有對應的回滾日志存在。
2、一階段
可以看出,AT模式的分支事務,必須使用支持ACID的關系型數據且業務與回滾日志需要在同一個數據庫中,因為業務SQL和回滾日志,需要使用本地事務同時插入數據庫中,要么同時成功要么同時失敗。如果分開在不同數據庫中,就又會產生分布式事務問題,這純屬于套娃行為了。
3、二階段提交
當TM決議全局事務提交,TC會發送commit指令給各個分支事務,因為“業務 SQL”在一階段已經提交至數據庫, 所以 Seata 框架只需將一階段保存的快照數據和行鎖刪掉,完成數據清理即可。
4、二階段回滾
當TM決議全局事務回滾,TC會發送rollback指令給各個分支事務,回滾方式便是用“before image”還原業務數據;但在還原前要首先要校驗臟寫,對比“數據庫當前業務數據”和 “after image”,如果兩份數據完全一致就說明沒有臟寫,可以還原業務數據,如果不一致就說明有臟寫,出現臟寫就需要轉人工處理。
從整個流程可以看出來,在沒有發生臟寫的情況下,所有的事務操作都被Seata數據源代理悄悄地處理了。
Seata AT 模式:事務隔離級別
剛剛我們說到臟寫,那么Seata AT模式是怎么發生臟寫或者臟讀的呢?這不得不從Seata的默認的事務隔離級別說起。
想象一個場景:
某個全局事務事務下有若干個分支事務,在全局事務執行過程中(全局事務還沒執行完),某個本地事務提交了,如果Seata沒有采取任何措施,會造成什么問題?
傳統意義的臟讀是讀到了未提交的數據,Seata 臟讀是讀到了全局事務下未提交的數據,全局事務可能包含多個本地事務,某個本地事務提交了不代表全局事務提交了。
在絕大部分應用在讀已提交的隔離級別下工作是沒有問題的,而實際上,這當中又有絕大多數的應用場景,實際上工作在讀未提交的隔離級別下同樣沒有問題。
在極端場景下,應用如果需要達到全局的讀已提交,Seata 設計了由事務協調器維護的全局寫排他鎖,來保證事務間的寫隔離,同時,將全局事務默認定義在讀未提交的隔離級別上。
但是默認情況下,Seata 的全局事務是工作在讀未提交隔離級別的,保證絕大多數場景的高效性。
Seata AT 模式:寫隔離
1、提交成功
兩個全局事務 tx1 和 tx2,分別對 a 表的 m 字段進行更新操作,m 的初始值 1000。
tx1 先開始,開啟本地事務,拿到本地鎖,更新操作 m = 1000 - 100 = 900。本地事務提交前,先拿到該記錄的 全局鎖 ,本地提交釋放本地鎖。tx2 后開始,開啟本地事務,拿到本地鎖,更新操作 m = 900 - 100 = 800。本地事務提交前,嘗試拿該記錄的 全局鎖 ,tx1 全局提交前,該記錄的全局鎖被 tx1 持有,tx2 需要重試等待 全局鎖 。
tx1 二階段全局提交,釋放 全局鎖 。tx2 拿到 全局鎖 提交本地事務。
2、事務回滾
如果 tx1 的二階段全局回滾,則 tx1 需要重新獲取該數據的本地鎖,進行反向補償的更新操作,實現分支的回滾。
此時,如果 tx2 仍在等待該數據的 全局鎖,同時持有本地鎖,則 tx1 的分支回滾會失敗。分支的回滾會一直重試,直到 tx2 的 全局鎖 等鎖超時,放棄 全局鎖 并回滾本地事務釋放本地鎖,tx1 的分支回滾最終成功。
因為整個過程 全局鎖 在 tx1 結束前一直是被 tx1 持有的,所以不會發生 臟寫 的問題。
Seata AT 模式:讀隔離
Seata AT模式下的臟讀是指在全局事務未提交之前,其他業務可能會讀取已提交的分支事務的數據。本質上,這意味著Seata默認的全局事務是讀未提交。
在特定場景下,可能需要全局讀取已提交數據。目前,Seata將通過代理SELECT FOR UPDATE語句來實現此需求。
執行SELECT FOR UPDATE語句將申請全局鎖。如果全局鎖已被其他事務持有,則Seata將釋放本地鎖并回滾SELECT FOR UPDATE語句的本地執行,并進行重試。在此過程中,查詢將被阻塞,直到全局鎖被獲取,并確保讀取的數據是已提交的,然后才會返回查詢結果。
Seata AT 模式:與XA的區別
seata 的事務提交方式跟 XA 協議的兩段式提交在總體上來說基本是一致的,那它們之間有什么不同呢?
我們都知道 XA 協議它依賴的是數據庫層面來保障事務的一致性,也即是說 XA 的各個分支事務是在數據庫層面上驅動的,由于 XA 的各個分支事務需要有 XA 的驅動程序,一方面會導致數據庫與 XA 驅動耦合,另一方面它會導致各個分支的事務資源鎖定周期長,這也是它沒有在互聯網公司流行的重要因素。
前面在將為什么無侵入的時候講到,Seata 在數據源做了一層代理層,所以我們使用 Seata 時,我們使用的數據源實際上用的是 Seata 自帶的數據源代理 DataSourceProxy。
這樣做的好處就是,本地事務執行完可以立即釋放本地事務鎖定的資源,然后向 TC 上報分支狀態。
當 TM 決議全局提交時,就不需要同步協調處理了,TC 會異步調度各個 RM 分支事務刪除對應的 undo log 日志即可,這個步驟非常快速地可以完成,XA就做不到,它必須同步等待所有分支處理完之后才認為全局事務已完成,這個期間被鎖定的資源其它業務是不能訪問的,這也就是為什么XA性能這么差的原因。正常的業務來說,二階段commit的幾率遠大于rollback,因此Seata AT模式相對于XA性能提升是非常巨大的。
當 TM 決議全局回滾時,RM 收到 TC 發送的回滾請求,RM 通過 XID 找到對應的 undo log 回滾日志,然后執行回滾日志完成回滾操作。
如上圖所示,Seata 的 RM 實際上是已中間件的形式放在應用層,不用依賴數據庫對協議的支持,完全剝離了分布式事務方案對數據庫在協議支持上的要求。
TCC 模式
TCC是分布式事務的一種解決方案,它也是一種2PC模型。
TCC優點:
1、性能高:沒有全局鎖,本地事務鎖在本地操作完成后馬上會釋放,不會像2PC、3PC 一樣整個事務執行的過程都會鎖住資源,所以TCC性能非常高。
2、具備隔離性: 通過隔離資源達到事務隔離的目的,先預留資源,再真正使用資源,避免了出現兩個事務并發時可能導致的同一個資源被使用多次的問題,適合資源敏感的場景。
3、允許事務失敗:可以進行事務回滾。
TCC缺點:
1、業務侵入性強:需要修改原來的結構設計來預留資源, 需要在原有的方法基礎上把業務拆分為Try、Confirm、Cancel三個方法。
TCC適用場景:
有資源隔離性要求、并且對業務系統有控制權,有修改結構的權限。
Seata TCC 模式:使用效果
如圖所示,參與者需要實現Try、Confirm、Cancel這三個方法,并在Try方法中添加@TwoPhaseBusinessAction注解,填寫二階段commit和rollback的方式到注解參數中。隨后,使用Dubbo等rpc協議發布遠程RPC服務,在發起方的方法中添加@GlobalTransactional注解來開啟全局事務,然后在全局事務內調用參與者的一階段Try方法。此時,二階段就由Seata框架來驅動完成。
Seata TCC 模式:通信交互
結合剛剛的使用例子,我們來看看 Seata 是如何實現TCC模式的,在這張通信交互圖可以看出,它與AT模式一樣遵循 TC、TM、RM 角色模型。
其中TM負責開啟全局事務,參與者執行try方法時會注冊分支事務,TM決議全局事務提交或回滾后,TC協調者會驅動全局事務內的參與者進行提交或者回滾。
Seata TCC 模式:實踐例子
如圖所示,Try 方法作為一階段準備方法,需要做資源的檢查和預留。在扣錢場景下,Try 要做的事情是就是檢查賬戶余額是否充足,預留轉賬資金,預留的方式就是凍結 A 賬戶的 轉賬資金。Try 方法執行之后,賬號 A 余額雖然還是 100,但是其中 30 元已經被凍結了,不能被其他事務使用。
二階段 Confirm 方法執行真正的扣錢操作。Confirm 會使用 Try 階段凍結的資金,執行賬號扣款。Confirm 方法執行之后,賬號 A 在一階段中凍結的 30 元已經被扣除,賬號 A 余額變成 70 元 。
如果二階段是回滾的話,就需要在 Cancel 方法內釋放一階段 Try 凍結的 30 元,使賬號 A 的回到初始狀態,100 元全部可用。
用戶接入 TCC 模式,最重要的事情就是考慮如何將業務模型拆成 2 階段,實現成 TCC 的 3 個方法,并且保證 Try 成功 Confirm 一定能成功。相對于 AT 模式,TCC 模式對業務代碼有一定的侵入性,但是 TCC 模式無 AT 模式的全局行鎖,TCC 性能會比 AT 模式高很多。
TCC可能會遇到什么樣的問題?
即使我們擁有了一套完備的TCC接口,也不能高枕無憂。在微服務架構下,很可能會遇到網絡超時、重發、機器宕機等一系列異常情況,這會導致分布式事務執行出現異常。根據螞蟻多年的實踐,我們發現最常見的異常有三種,分別是空回滾、冪等、懸掛。
因此,TCC接口需要解決這三類問題。實際上,Seata框架已經支持這三種異常的處理,我們將把這些異常的處理移植到Seata框架中。這樣,業務就無需關注這些異常情況,可以專注于業務邏輯。
雖然業務無需關注這些異常,但了解其內部實現機制有助于更好地排查問題。接下來,我將為大家一一講解這三類異常出現的原因以及對應的解決方案。
Seata TCC 模式:如何防止空回滾?
什么是空回滾?
TCC 服務在未收到 Try 請求的情況下收到 Cancel 請求,這種場景被稱為空回滾;空回滾在生產環境經常出現,用戶在實現TCC服務時,應允許允許空回滾的執行,即收到空回滾時返回成功。
如圖所示,事務協調器在調用 TCC 服務的一階段 Try 操作時,可能會出現因為丟包而導致的網絡超時,此時事務管理器會觸發二階段回滾,調用 TCC 服務的 Cancel 操作,而 Cancel 操作調用未出現超時。
要想防止空回滾,那么必須在 Cancel 方法中識別這是一個空回滾,Seata 是如何做的呢?
Seata 的做法是新增一個 TCC 事務控制表,包含事務的 XID 和 BranchID 信息,在 Try 方法執行時插入一條記錄,表示一階段執行了,執行 Cancel 方法時讀取這條記錄,如果記錄不存在,說明 Try 方法沒有執行。
Seata TCC 模式:如何防懸掛?
懸掛指的是二階段 Cancel 方法比 一階段 Try 方法優先執行,由于允許空回滾的原因,在執行完二階段 Cancel 方法之后直接空回滾返回成功,此時全局事務已結束,但是由于 Try 方法隨后執行,這就會造成一階段 Try 方法預留的資源永遠無法提交和釋放了。
那么懸掛是如何產生的呢?
在圖示中,當事務協調器調用TCC服務的一階段Try操作時,由于網絡擁堵等原因,可能會出現超時的情況。此時,事務管理器會觸發二階段回滾,調用TCC服務的Cancel操作,但Cancel調用未超時。之后,被網絡擁堵延遲的一階段Try數據包被TCC服務收到,導致二階段Cancel請求比一階段Try請求先執行,這會導致TCC服務在執行晚到的Try之后,永遠不會再收到二階段的Confirm或Cancel請求,從而導致TCC服務懸掛的情況。
用戶在實現 TCC 服務時,要允許空回滾,但是要拒絕執行空回滾之后 Try 請求,要避免出現懸掛。
Seata 是怎么處理懸掛的呢?
在 TCC 事務控制表記錄狀態的字段 status 中增加一個狀態:
- suspended:4
當執行二階段 Cancel 方法時,如果發現 TCC 事務控制表有相關記錄,說明二階段 Cancel 方法優先一階段 Try 方法執行,因此插入一條 status=4 狀態的記錄,當一階段 Try 方法后面執行時,判斷 status=4 ,則說明有二階段 Cancel 已執行,并返回 false 以阻止一階段 Try 方法執行成功。
Seata TCC 模式:如何冪等控制?
冪等問題指的是 TC 重復進行二階段提交,因此 Confirm/Cancel 接口需要支持冪等處理,即不會產生資源重復提交或者重復釋放。
那么冪等問題是如何產生的呢?
參與者執行完二階段之后,由于網絡抖動或者宕機問題,會造成 TC 收不到參與者執行二階段的返回結果,TC 會重復發起調用,直到二階段執行結果成功。
Seata 是如何處理冪等問題的呢?
同樣的也是在 TCC 事務控制表中增加一個記錄狀態的字段 status,該字段有有 3 個值,分別為:
- tried:1
- committed:2
- rollbacked:3
二階段 Confirm/Cancel 方法執行后,將狀態改為 committed 或 rollbacked 狀態。當重復調用二階段 Confirm/Cancel 方法時,判斷事務狀態即可解決冪等問題。