一萬四千字分散式事務原理解析,全部掌握你還怕面試被問?

風平浪靜如碼發表於2020-11-07

前言

從 CPU 到記憶體、到磁碟、到作業系統、到網路,計算機系統處處存在不可靠因素。工程師和科學家努力使用各種軟硬體方法對抗這種不可靠因素,保證資料和指令被正確地處理。在網路領域有 TCP 可靠傳輸協議、在儲存領域有 Raid5 和 Raid6 演算法、在資料庫領域有基於 ARIES 演算法理論實現的事務機制……

這篇文章先介紹單機資料庫事務的 ACID 特性,然後指出分散式場景下操作多資料來源面臨的困境,引出分散式系統中常用的分散式事務解決方案,這些解決方案可以保證業務程式碼在操作多個資料來源的時候,能夠像操作單個資料來源一樣,具備 ACID 特性。文章在最後給出業界較為成熟的分散式事務框架——Seata 的 AT 模式全域性事務的實現。

一、單資料來源事務 & 多資料來源事務

如果一個應用程式在一次業務流中通過連線驅動和資料來源介面只連線並查詢(這裡的查詢是廣義的,包括增刪查改等)一個特定的資料庫,該應用程式就可以利用資料庫提供的事務機制(如果資料庫支援事務的話)保證對庫中記錄所進行的操作的可靠性,這裡的可靠性有四種語義:

  • 原子性,A

  • 一致性,C

  • 隔離性,I

  • 永續性,D

筆者在這裡不再對這四種語義進行解釋,瞭解單資料來源事務及其 ACID 特性是讀者閱讀這篇文章的前提。單個資料庫實現自身的事務特性是一個複雜又微妙的過程,例如 MySQL 的 InnoDB 引擎通過 Undo Log + Redo Log + ARIES 演算法來實現。這是一個很巨集大的話題,不在本文的描述範圍,讀者有興趣的話可自行研究。

單資料來源事務也可以叫做單機事務,或者本地事務。

在分散式場景下,一個系統由多個子系統構成,每個子系統有獨立的資料來源。多個子系統之間通過互相呼叫來組合出更復雜的業務。在時下流行的微服務系統架構中,每一個子系統被稱作一個微服務,同樣每個微服務都維護自己的資料庫,以保持獨立性。

例如,一個電商系統可能由購物微服務、庫存微服務、訂單微服務等組成。購物微服務通過呼叫庫存微服務和訂單微服務來整合出購物業務。使用者請求購物微服務商完成下單時,購物微服務一方面呼叫庫存微服務扣減相應商品的庫存數量,另一方面呼叫訂單微服務插入訂單記錄(為了後文描述分散式事務解決方案的方便,這裡給出的是一個最簡單的電商系統微服務劃分和最簡單的購物業務流程,後續的支付、物流等業務不在考慮範圍內)。電商系統模型如下圖所示:

在使用者購物的業務場景中,shopping-service 的業務涉及兩個資料庫:庫存庫(repo_db)和訂單庫(repo_db),也就是 g 購物業務是呼叫多資料來源來組合而成的。作為一個面向消費者的系統,電商系統要保證購物業務的高度可靠性,這裡的可靠性同樣有 ACID 四種語義。

但是一個資料庫的本地事務機制僅僅對落到自己身上的查詢操作(這裡的查詢是廣義的,包括增刪改查等)起作用,無法干涉對其他資料庫的查詢操作。所以,資料庫自身提供的本地事務機制無法確保業務對多資料來源全域性操作的可靠性。

基於此,針對多資料來源操作提出的分散式事務機制就出現了。

分散式事務也可以叫做全域性事務。

二、常見分散式事務解決方案

2.1 分散式事務模型

描述分散式事務,常常會使用以下幾個名詞:

  • 事務參與者:例如每個資料庫就是一個事務參與者

  • 事務協調者:訪問多個資料來源的服務程式,例如 shopping-service 就是事務協調者

  • 資源管理器(Resource Manager, RM):通常與事務參與者同義

  • 事務管理器(Transaction Manager, TM):通常與事務協調者同義

在分散式事務模型中,一個 TM 管理多個 RM,即一個服務程式訪問多個資料來源;TM 是一個全域性事務管理器,協調多方本地事務的進度,使其共同提交或回滾,最終達成一種全域性的 ACID 特性。

2.2 二將軍問題和冪等性

二將軍問題是網路領域的一個經典問題,用於表達計算機網路中互聯協議設計的微妙性和複雜性。這裡給出一個二將軍問題的簡化版本:

一支白軍被圍困在一個山谷中,山谷的左右兩側是藍軍。困在山谷中的白軍人數多於山谷兩側的任意一支藍軍,而少於兩支藍軍的之和。若一支藍軍對白軍單獨發起進攻,則必敗無疑;但若兩支藍軍同時發起進攻,則可取勝。兩隻藍軍的總指揮位於山谷左側,他希望兩支藍軍同時發起進攻,這樣就要把命令傳到山谷右側的藍軍,以告知發起進攻的具體時間。假設他們只能派遣士兵穿越白軍所在的山谷(唯一的通訊通道)來傳遞訊息,那麼在穿越山谷時,士兵有可能被俘虜。

只有當送信士兵成功往返後,總指揮才能確認這場戰爭的勝利(上方圖)。現在問題來了,派遣出去送信的士兵沒有回來,則左側藍軍中的總指揮能不能決定按命令中約定的時間發起進攻?

答案是不確定,派遣出去送信的士兵沒有回來,他可能遇到兩種狀況:

1)命令還沒送達就被俘虜了(中間圖),這時候右側藍軍根本不知道要何時進攻;

2)命令送達,但返回途中被俘虜了(下方圖),這時候右側藍軍知道要何時進攻,但左側藍軍不知道右側藍軍是否知曉進攻時間。

類似的問題在計算機網路中普遍存在,例如傳送者給接受者傳送一個 HTTP 請求,或者 MySQL 客戶端向 MySQL 伺服器傳送一條插入語句,然後超時了沒有得到響應。請問伺服器是寫入成功了還是失敗了?答案是不確定,有以下幾種情況:

1)可能請求由於網路故障根本沒有送到伺服器,因此寫入失敗;

2)可能伺服器收到了,也寫入成功了,但是向客戶端傳送響應前伺服器當機了;

3)可能伺服器收到了,也寫入成功了,也向客戶端傳送了響應,但是由於網路故障未送到客戶端。

無論哪種場景,在客戶端看來都是一樣的結果:它發出的請求沒有得到響應。為了確保服務端成功寫入資料,客戶端只能重發請求,直至接收到服務端的響應。

類似的問題問題被稱為網路二將軍問題。

網路二將軍問題的存在使得訊息的傳送者往往要重複傳送訊息,直到收到接收者的確認才認為傳送成功,但這往往又會導致訊息的重複傳送。例如電商系統中訂單模組呼叫支付模組扣款的時候,如果網路故障導致二將軍問題出現,扣款請求重複傳送,產生的重複扣款結果顯然是不能被接受的。因此要保證一次事務中的扣款請求無論被髮送多少次,接收方有且只執行一次扣款動作,這種保證機制叫做接收方的冪等性。

2.3 兩階段提交(2PC) & 三階段提交(3PC)方案

2PC 是一種實現分散式事務的簡單模型,這兩個階段是:

1)準備階段:事務協調者向各個事務參與者發起詢問請求:“我要執行全域性事務了,這個事務涉及到的資源分佈在你們這些資料來源中,分別是……,你們準備好各自的資源(即各自執行本地事務到待提交階段)”。各個參與者協調者回復 yes(表示已準備好,允許提交全域性事務)或 no(表示本參與者無法拿到全域性事務所需的本地資源,因為它被其他本地事務鎖住了)或超時。

2)提交階段:如果各個參與者回覆的都是 yes,則協調者向所有參與者發起事務提交操作,然後所有參與者收到後各自執行本地事務提交操作並向協調者傳送 ACK;如果任何一個參與者回覆 no 或者超時,則協調者向所有參與者發起事務回滾操作,然後所有參與者收到後各自執行本地事務回滾操作並向協調者傳送 ACK。

2PC 的流程如下圖所示:

從上圖可以看出,要實現 2PC,所有的參與者都要實現三個介面:

  • Prepare():TM 呼叫該介面詢問各個本地事務是否就緒

  • Commit():TM 呼叫該介面要求各個本地事務提交

  • Rollback():TM 呼叫該介面要求各個本地事務回滾

可以將這三個介面簡單地(但不嚴謹地)理解成 XA 協議。XA 協議是 X/Open 提出的分散式事務處理標準。MySQL、Oracle、DB2 這些主流資料庫都實現了 XA 協議,因此都能被用於實現 2PC 事務模型。

2PC 簡明易懂,但存在如下的問題:

1)效能差,在準備階段,要等待所有的參與者返回,才能進入階段二,在這期間,各個參與者上面的相關資源被排他地鎖住,參與者上面意圖使用這些資源的本地事務只能等待。因為存在這種同步阻塞問題,所以影響了各個參與者的本地事務併發度;

2)準備階段完成後,如果協調者當機,所有的參與者都收不到提交或回滾指令,導致所有參與者“不知所措”;

3)在提交階段,協調者向所有的參與者傳送了提交指令,如果一個參與者未返回 ACK,那麼協調者不知道這個參與者內部發生了什麼(由於網路二將軍問題的存在,這個參與者可能根本沒收到提交指令,一直處於等待接收提交指令的狀態;也可能收到了,併成功執行了本地提交,但返回的 ACK 由於網路故障未送到協調者上),也就無法決定下一步是否進行全體參與者的回滾。

2PC 之後又出現了 3PC,把兩階段過程變成了三階段過程,分別是:詢問階段、準備階段、提交或回滾階段,這裡不再詳述。3PC 利用超時機制解決了 2PC 的同步阻塞問題,避免資源被永久鎖定,進一步加強了整個事務過程的可靠性。但是 3PC 同樣無法應對類似的當機問題,只不過出現多資料來源中資料不一致問題的概率更小。

2PC 除了效能和可靠性上存在問題,它的適用場景也很侷限,它要求參與者實現了 XA 協議,例如使用實現了 XA 協議的資料庫作為參與者可以完成 2PC 過程。但是在多個系統服務利用 api 介面相互呼叫的時候,就不遵守 XA 協議了,這時候 2PC 就不適用了。所以 2PC 在分散式應用場景中很少使用。

所以前文提到的電商場景無法使用 2PC,因為 shopping-service 通過 RPC 介面或者 Rest 介面呼叫 repo-service 和 order-service 間接訪問 repo_db 和 order_db。除非 shopping-service 直接配置 repo_db 和 order_db 作為自己的資料庫。

2.4 TCC 方案

描述 TCC 方案使用的電商微服務模型如下圖所示,在這個模型中,shopping-service 是事務協調者,repo-service 和 order-service 是事務參與者。

上文提到,2PC 要求參與者實現了 XA 協議,通常用來解決多個資料庫之間的事務問題,比較侷限。在多個系統服務利用 api 介面相互呼叫的時候,就不遵守 XA 協議了,這時候 2PC 就不適用了。現代企業多采用分散式的微服務,因此更多的是要解決多個微服務之間的分散式事務問題。

TCC 就是一種解決多個微服務之間的分散式事務問題的方案。TCC 是 Try、Confirm、Cancel 三個詞的縮寫,其本質是一個應用層面上的 2PC,同樣分為兩個階段:

1)階段一:準備階段。協調者呼叫所有的每個微服務提供的 try 介面,將整個全域性事務涉及到的資源鎖定住,若鎖定成功 try 介面向協調者返回 yes。

2)階段二:提交階段。若所有的服務的 try 介面在階段一都返回 yes,則進入提交階段,協調者呼叫所有服務的 confirm 介面,各個服務進行事務提交。如果有任何一個服務的 try 介面在階段一返回 no 或者超時,則協調者呼叫所有服務的 cancel 介面。

TCC 的流程如下圖所示:

這裡有個關鍵問題,既然 TCC 是一種服務層面上的 2PC,它是如何解決 2PC 無法應對當機問題的缺陷的呢?答案是不斷重試。由於 try 操作鎖住了全域性事務涉及的所有資源,保證了業務操作的所有前置條件得到滿足,因此無論是 confirm 階段失敗還是 cancel 階段失敗都能通過不斷重試直至 confirm 或 cancel 成功(所謂成功就是所有的服務都對 confirm 或者 cancel 返回了 ACK)。

這裡還有個關鍵問題,在不斷重試 confirm 和 cancel 的過程中(考慮到網路二將軍問題的存在)有可能重複進行了 confirm 或 cancel,因此還要再保證 confirm 和 cancel 操作具有冪等性,也就是整個全域性事務中,每個參與者只進行一次 confirm 或者 cancel。實現 confirm 和 cancel 操作的冪等性,有很多解決方案,例如每個參與者可以維護一個去重表(可以利用資料庫表實現也可以使用記憶體型 KV 元件實現),記錄每個全域性事務(以全域性事務標記 XID 區分)是否進行過 confirm 或 cancel 操作,若已經進行過,則不再重複執行。

TCC 由支付寶團隊提出,被廣泛應用於金融系統中。我們用銀行賬戶餘額購買基金時,會注意到銀行賬戶中用於購買基金的那部分餘額首先會被凍結,由此我們可以猜想,這個過程大概就是 TCC 的第一階段。

2.5 事務狀態表方案

另外有一種類似 TCC 的事務解決方案,藉助事務狀態表來實現。假設要在一個分散式事務中實現呼叫 repo-service 扣減庫存、呼叫 order-service 生成訂單兩個過程。在這種方案中,協調者 shopping-service 維護一張如下的事務狀態表:

初始狀態為 1,每成功呼叫一個服務則更新一次狀態,最後所有的服務呼叫成功,狀態更新到 3。

有了這張表,就可以啟動一個後臺任務,掃描這張表中事務的狀態,如果一個分散式事務一直(設定一個事務週期閾值)未到狀態 3,說明這條事務沒有成功執行,於是可以重新呼叫 repo-service 扣減庫存、呼叫 order-service 生成訂單。直至所有的呼叫成功,事務狀態到 3。

如果多次重試仍未使得狀態到 3,可以將事務狀態置為 error,通過人工介入進行干預。

由於存在服務的呼叫重試,因此每個服務的介面要根據全域性的分散式事務 ID 做冪等,原理同 2.4 節的冪等性實現。

2.7 基於訊息中介軟體的最終一致性事務方案

無論是 2PC & 3PC 還是 TCC、事務狀態表,基本都遵守 XA 協議的思想,即這些方案本質上都是事務協調者協調各個事務參與者的本地事務的進度,使所有本地事務共同提交或回滾,最終達成一種全域性的 ACID 特性。在協調的過程中,協調者需要收集各個本地事務的當前狀態,並根據這些狀態發出下一階段的操作指令。

但是這些全域性事務方案由於操作繁瑣、時間跨度大,或者在全域性事務期間會排他地鎖住相關資源,使得整個分散式系統的全域性事務的併發度不會太高。這很難滿足電商等高併發場景對事務吞吐量的要求,因此網際網路服務提供商探索出了很多與 XA 協議背道而馳的分散式事務解決方案。其中利用訊息中介軟體實現的最終一致性全域性事務就是一個經典方案。

為了表現出這種方案的精髓,我將使用如下的電商系統微服務結構來進行描述:

在這個模型中,使用者不再是請求整合後的 shopping-service 進行下單,而是直接請求 order-service 下單,order-service 一方面新增訂單記錄,另一方面會呼叫 repo-service 扣減庫存。

這種基於訊息中介軟體的最終一致性事務方案常常被誤解成如下的實現方式:

這種實現方式的流程是:

1)order-service 負責向 MQ server 傳送扣減庫存訊息(repo_deduction_msg);repo-service 訂閱 MQ server 中的扣減庫存訊息,負責消費訊息。

2)使用者下單後,order-service 先執行插入訂單記錄的查詢語句,後將 repo_deduction_msg 發到訊息中介軟體中,這兩個過程放在一個本地事務中進行,一旦“執行插入訂單記錄的查詢語句”失敗,導致事務回滾,“將 repo_deduction_msg 發到訊息中介軟體中”就不會發生;同樣,一旦“將 repo_deduction_msg 發到訊息中介軟體中”失敗,丟擲異常,也會導致“執行插入訂單記錄的查詢語句”操作回滾,最終什麼也沒有發生。

3)repo-service 接收到 repo_deduction_msg 之後,先執行庫存扣減查詢語句,後向 MQ sever 反饋訊息消費完成 ACK,這兩個過程放在一個本地事務中進行,一旦“執行庫存扣減查詢語句”失敗,導致事務回滾,“向 MQ sever 反饋訊息消費完成 ACK”就不會發生,MQ server 在 Confirm 機制的驅動下會繼續向 repo-service 推送該訊息,直到整個事務成功提交;同樣,一旦“向 MQ sever 反饋訊息消費完成 ACK”失敗,丟擲異常,也對導致“執行庫存扣減查詢語句”操作回滾,MQ server 在 Confirm 機制的驅動下會繼續向 repo-service 推送該訊息,直到整個事務成功提交。

這種做法看似很可靠。但沒有考慮到網路二將軍問題的存在,有如下的缺陷:

1)存在網路的 2 將軍問題,上面第 2)步中 order-service 傳送 repo_deduction_msg 訊息失敗,對於傳送方 order-service 來說,可能是訊息中介軟體沒有收到訊息;也可能是中介軟體收到了訊息,但向傳送方 order-service 響應的 ACK 由於網路故障沒有被 order-service 收到。因此 order-service 貿然進行事務回滾,撤銷“執行插入訂單記錄的查詢語句”,是不對的,因為 repo-service 那邊可能已經接收到 repo_deduction_msg 併成功進行了庫存扣減,這樣 order-service 和 repo-service 兩方就產生了資料不一致問題。

2)repo-service 和 order-service 把網路呼叫(與 MQ server 通訊)放在本地資料庫事務裡,可能會因為網路延遲產生資料庫長事務,影響資料庫本地事務的併發度。

以上是被誤解的實現方式,下面給出正確的實現方式,如下所示:

上圖所示的方案,利用訊息中介軟體如 rabbitMQ 來實現分散式下單及庫存扣減過程的最終一致性。對這幅圖做以下說明:

1)order-service 中,

在 t_order 表新增訂單記錄 &&

在 t_local_msg 新增對應的扣減庫存訊息

這兩個過程要在一個事務中完成,保證過程的原子性。同樣,repo-service 中,

檢查本次扣庫存操作是否已經執行過 &&

執行扣減庫存如果本次扣減操作沒有執行過 &&

寫判重表 &&

向 MQ sever 反饋訊息消費完成 ACK

這四個過程也要在一個事務中完成,保證過程的原子性。

2)order-service 中有一個後臺程式,源源不斷地把訊息表中的訊息傳送給訊息中介軟體,成功後則刪除訊息表中對應的訊息。如果失敗了,也會不斷嘗試重傳。由於存在網路 2 將軍問題,即當 order-service 傳送給訊息中介軟體的訊息網路超時時,這時候訊息中介軟體可能收到了訊息但響應 ACK 失敗,也可能沒收到,order-service 會再次傳送該訊息,直至訊息中介軟體響應 ACK 成功,這樣可能發生訊息的重複傳送,不過沒關係,只要保證訊息不丟失,不亂序就行,後面 repo-service 會做去重處理。

3)訊息中介軟體向 repo-service 推送 repo_deduction_msg,repo-service 成功處理完成後會向中介軟體響應 ACK,訊息中介軟體收到這個 ACK 才認為 repo-service 成功處理了這條訊息,否則會重複推送該訊息。但是有這樣的情形:repo-service 成功處理了訊息,向中介軟體傳送的 ACK 在網路傳輸中由於網路故障丟失了,導致中介軟體沒有收到 ACK 重新推送了該訊息。這也要靠 repo-service 的訊息去重特性來避免訊息重複消費。

4)在 2)和 3)中提到了兩種導致 repo-service 重複收到訊息的原因,一是生產者重複生產,二是中介軟體重傳。為了實現業務的冪等性,repo-service 中維護了一張判重表,這張表中記錄了被成功處理的訊息的 id。repo-service 每次接收到新的訊息都先判斷訊息是否被成功處理過,若是的話不再重複處理。

通過這種設計,實現了訊息在傳送方不丟失,訊息在接收方不被重複消費,聯合起來就是訊息不漏不重,嚴格實現了 order-service 和 repo-service 的兩個資料庫中資料的最終一致性。

基於訊息中介軟體的最終一致性全域性事務方案是網際網路公司在高併發場景中探索出的一種創新型應用模式,利用 MQ 實現微服務之間的非同步呼叫、解耦合和流量削峰,支援全域性事務的高併發,並保證分散式資料記錄的最終一致性。

三、Seata in AT mode 的實現

第 2 章給出了實現實現分散式事務的集中常見的理論模型。本章給出業界開源分散式事務框架 Seata 的實現。

Seata 為使用者提供了 AT、TCC、SAGA 和 XA 事務模式。其中 AT 模式是 Seata 主推的事務模式,因此本章分析 Seata in AT mode 的實現。使用 AT 有一個前提,那就是微服務使用的資料庫必須是支援事務的關係型資料庫。

3.1 Seata in AT mode 工作流程概述

Seata 的 AT 模式建立在關係型資料庫的本地事務特性的基礎之上,通過資料來源代理類攔截並解析資料庫執行的 SQL,記錄自定義的回滾日誌,如需回滾,則重放這些自定義的回滾日誌即可。AT 模式雖然是根據 XA 事務模型(2PC)演進而來的,但是 AT 打破了 XA 協議的阻塞性制約,在一致性和效能上取得了平衡。

AT 模式是基於 XA 事務模型演進而來的,它的整體機制也是一個改進版本的兩階段提交協議。AT 模式的兩個基本階段是:

1)第一階段:首先獲取本地鎖,執行本地事務,業務資料操作和記錄回滾日誌在同一個本地事務中提交,最後釋放本地鎖;

2)第二階段:如需全域性提交,非同步刪除回滾日誌即可,這個過程很快就能完成。如需要回滾,則通過第一階段的回滾日誌進行反向補償。

本章描述 Seata in AT mode 的工作原理使用的電商微服務模型如下圖所示:

在上圖中,協調者 shopping-service 先呼叫參與者 repo-service 扣減庫存,後呼叫參與者 order-service 生成訂單。這個業務流使用 Seata in XA mode 後的全域性事務流程如下圖所示:

上圖描述的全域性事務執行流程為:

1)shopping-service 向 Seata 註冊全域性事務,併產生一個全域性事務標識 XID

2)將 repo-service.repo_db、order-service.order_db 的本地事務執行到待提交階段,事務內容包含對 repo-service.repo_db、order-service.order_db 進行的查詢操作以及寫每個庫的 undo_log 記錄

3)repo-service.repo_db、order-service.order_db 向 Seata 註冊分支事務,並將其納入該 XID 對應的全域性事務範圍

4)提交 repo-service.repo_db、order-service.order_db 的本地事務

5)repo-service.repo_db、order-service.order_db 向 Seata 彙報分支事務的提交狀態

6)Seata 彙總所有的 DB 的分支事務的提交狀態,決定全域性事務是該提交還是回滾

7)Seata 通知 repo-service.repo_db、order-service.order_db 提交/回滾本地事務,若需要回滾,採取的是補償式方法

其中 1)2)3)4)5)屬於第一階段,6)7)屬於第二階段。

3.1Seata in AT mode 工作流程詳述

在上面的電商業務場景中,購物服務呼叫庫存服務扣減庫存,呼叫訂單服務建立訂單,顯然這兩個呼叫過程要放在一個事務裡面。即:

start global_trx

 call 庫存服務的扣減庫存介面

 call 訂單服務的建立訂單介面

commit global_trx

在庫存服務的資料庫中,存在如下的庫存表 t_repo:

在訂單服務的資料庫中,存在如下的訂單表 t_order:

現在,id 為 40002 的使用者要購買一隻商品程式碼為 20002 的滑鼠,整個分散式事務的內容為:

1)在庫存服務的庫存表中將記錄

修改為

2)在訂單服務的訂單表中新增一條記錄

以上操作,在 AT 模式的第一階段的流程圖如下:

從 AT 模式第一階段的流程來看,分支的本地事務在第一階段提交完成之後,就會釋放掉本地事務鎖定的本地記錄。這是 AT 模式和 XA 最大的不同點,在 XA 事務的兩階段提交中,被鎖定的記錄直到第二階段結束才會被釋放。所以 AT 模式減少了鎖記錄的時間,從而提高了分散式事務的處理效率。AT 模式之所以能夠實現第一階段完成就釋放被鎖定的記錄,是因為 Seata 在每個服務的資料庫中維護了一張 undo_log 表,其中記錄了對 t_order / t_repo 進行操作前後記錄的映象資料,即便第二階段發生異常,只需回放每個服務的 undo_log 中的相應記錄即可實現全域性回滾。

undo_log 的表結構:

第一階段結束之後,Seata 會接收到所有分支事務的提交狀態,然後決定是提交全域性事務還是回滾全域性事務。

1)若所有分支事務本地提交均成功,則 Seata 決定全域性提交。Seata 將分支提交的訊息傳送給各個分支事務,各個分支事務收到分支提交訊息後,會將訊息放入一個緩衝佇列,然後直接向 Seata 返回提交成功。之後,每個本地事務會慢慢處理分支提交訊息,處理的方式為:刪除相應分支事務的 undo_log 記錄。之所以只需刪除分支事務的 undo_log 記錄,而不需要再做其他提交操作,是因為提交操作已經在第一階段完成了(這也是 AT 和 XA 不同的地方)。這個過程如下圖所示:

分支事務之所以能夠直接返回成功給 Seata,是因為真正關鍵的提交操作在第一階段已經完成了,清除 undo_log 日誌只是收尾工作,即便清除失敗了,也對整個分散式事務不產生實質影響。

2)若任一分支事務本地提交失敗,則 Seata 決定全域性回滾,將分支事務回滾訊息傳送給各個分支事務,由於在第一階段各個服務的資料庫上記錄了 undo_log 記錄,分支事務回滾操作只需根據 undo_log 記錄進行補償即可。全域性事務的回滾流程如下圖所示:

這裡對圖中的 2、3 步做進一步的說明:

1)由於上文給出了 undo_log 的表結構,所以可以通過 xid 和 branch_id 來找到當前分支事務的所有 undo_log 記錄;

2)拿到當前分支事務的 undo_log 記錄之後,首先要做資料校驗,如果 afterImage 中的記錄與當前的表記錄不一致,說明從第一階段完成到此刻期間,有別的事務修改了這些記錄,這會導致分支事務無法回滾,向 Seata 反饋回滾失敗;如果 afterImage 中的記錄與當前的表記錄一致,說明從第一階段完成到此刻期間,沒有別的事務修改這些記錄,分支事務可回滾,進而根據 beforeImage 和 afterImage 計算出補償 SQL,執行補償 SQL 進行回滾,然後刪除相應 undo_log,向 Seata 反饋回滾成功。

事務具有 ACID 特性,全域性事務解決方案也在儘量實現這四個特性。以上關於 Seata in AT mode 的描述很顯然體現出了 AT 的原子性、一致性和永續性。下面著重描述一下 AT 如何保證多個全域性事務的隔離性的。

在 AT 中,當多個全域性事務操作同一張表時,通過全域性鎖來保證事務的隔離性。下面描述一下全域性鎖在讀隔離和寫隔離兩個場景中的作用原理:

1)寫隔離(若有全域性事務在改/寫/刪記錄,另一個全域性事務對同一記錄進行的改/寫/刪要被隔離起來,即寫寫互斥):寫隔離是為了在多個全域性事務對同一張表的同一個欄位進行更新操作時,避免一個全域性事務在沒有被提交成功之前所涉及的資料被其他全域性事務修改。寫隔離的基本原理是:在第一階段本地事務(開啟本地事務的時候,本地事務會對涉及到的記錄加本地鎖)提交之前,確保拿到全域性鎖。如果拿不到全域性鎖,就不能提交本地事務,並且不斷嘗試獲取全域性鎖,直至超出重試次數,放棄獲取全域性鎖,回滾本地事務,釋放本地事務對記錄加的本地鎖。

假設有兩個全域性事務 gtrx_1 和 gtrx_2 在併發操作庫存服務,意圖扣減如下記錄的庫存數量:

AT 實現寫隔離過程的時序圖如下:

圖中,1、2、3、4 屬於第一階段,5 屬於第二階段。

在上圖中 gtrx_1 和 gtrx_2 均成功提交,如果 gtrx_1 在第二階段執行回滾操作,那麼 gtrx_1 需要重新發起本地事務獲取本地鎖,然後根據 undo_log 對這個 id=10002 的記錄進行補償式回滾。此時 gtrx_2 仍在等待全域性鎖,且持有這個 id=10002 的記錄的本地鎖,因此 gtrx_1 會回滾失敗(gtrx_1 回滾需要同時持有全域性鎖和對 id=10002 的記錄加的本地鎖),回滾失敗的 gtrx_1 會一直重試回滾。直到旁邊的 gtrx_2 獲取全域性鎖的嘗試次數超過閾值,gtrx_2 會放棄獲取全域性鎖,發起本地回滾,本地回滾結束後,自然會釋放掉對這個 id=10002 的記錄加的本地鎖。此時,gtrx_1 終於可以成功對這個 id=10002 的記錄加上了本地鎖,同時拿到了本地鎖和全域性鎖的 gtrx_1 就可以成功回滾了。整個過程,全域性鎖始終在 gtrx_1 手中,並不會發生髒寫的問題。整個過程的流程圖如下所示:

2)讀隔離(若有全域性事務在改/寫/刪記錄,另一個全域性事務對同一記錄的讀取要被隔離起來,即讀寫互斥):在資料庫本地事務的隔離級別為讀已提交、可重複讀、序列化時(讀未提交不起什麼隔離作用,一般不使用),Seata AT 全域性事務模型產生的隔離級別是讀未提交,也就是說一個全域性事務會看到另一個全域性事務未全域性提交的資料,產生髒讀,從前文的第一階段和第二階段的流程圖中也可以看出這一點。這在最終一致性的分散式事務模型中是可以接受的。

如果要求 AT 模型一定要實現讀已提交的事務隔離級別,可以利用 Seata 的 SelectForUpdateExecutor 執行器對 SELECT FOR UPDATE 語句進行代理。SELECT FOR UPDATE 語句在執行時會申請全域性鎖,如果全域性鎖已經被其他全域性事務佔有,則回滾 SELECT FOR UPDATE 語句的執行,釋放本地鎖,並且重試 SELECT FOR UPDATE 語句。在這個過程中,查詢請求會被阻塞,直到拿到全域性鎖(也就是要讀取的記錄被其他全域性事務提交),讀到已被全域性事務提交的資料才返回。這個過程如下圖所示:

四、結束語

XA 協議是 X/Open 提出的分散式事務處理標準。文中提到的 2PC、3PC、TCC、本地事務表、Seata in AT mode,無論哪一種,本質都是事務協調者協調各個事務參與者的本地事務的進度,使使所有本地事務共同提交或回滾,最終達成一種全域性的 ACID 特性。在協調的過程中,協調者需要收集各個本地事務的當前狀態,並根據這些狀態發出下一階段的操作指令。這個思想就是 XA 協議的要義,我們可以說這些事務模型遵守或大致遵守了 XA 協議。

基於訊息中介軟體的最終一致性事務方案是網際網路公司在高併發場景中探索出的一種創新型應用模式,利用 MQ 實現微服務之間的非同步呼叫、解耦合和流量削峰,保證分散式資料記錄的最終一致性。它顯然不遵守 XA 協議。

對於某項技術,可能存在業界標準或協議,但實踐者針對具體應用場景的需求或者出於簡便的考慮,給出與標準不完全相符的實現,甚至完全不相符的實現,這在工程領域是一種常見的現象。TCC 方案如此、基於訊息中介軟體的最終一致性事務方案如此、Seata in AT mode 模式也如此。而新的標準往往就在這些創新中產生。

你難道真的沒有發現 2.6 節(基於訊息中介軟體的最終一致性事務方案)給出的正確方案中存在的業務漏洞嗎?請各位重新看下這張圖,仔細品一品兩個微服務的呼叫方向,把你的想法留在評論區吧 ?

寫在最後

歡迎大家關注我的公眾號【風平浪靜如碼】,海量Java相關文章,學習資料都會在裡面更新,整理的資料也會放在裡面。

覺得寫的還不錯的就點個贊,加個關注唄!點關注,不迷路,持續更新!!!

相關文章