分佈式事務原理及解決方案案例

事務的具體定義

事務(Transaction)是訪問並可能更新數據庫中各種數據項的一個程序執行單元(unit)。在關係數據庫中,一個事務由一組SQL語句組成。事務應該具有4個屬性:原子性、一致性、隔離性、持久性。這四個屬性通常稱爲ACID特性。
事務提供一種機制將一個活動涉及的所有操作納入到一個不可分割的執行單元,組成事務的所有操作只有在所有操作均能正常執行的情況下方能提交,只要其中任一操作執行失敗,都將導致整個事務的回滾。

簡單地說,事務提供一種“要麼什麼都不做,要麼做全套(All or Nothing)”機制。

數據庫本地事務

在計算機系統中,更多的是通過關係型數據庫來控制事務,這是利用數據庫本身的事務特性來實現的,因此叫數據庫事務,由於應用主要靠關係數據庫來控制事務,而數據庫通常和應用在同一個服務器,所以基於關係型數據庫的事務又被稱爲本地事務。
回顧一下數據庫事務的四大特性 ACID:

說到數據庫事務就不得不說,數據庫事務中的四大特性 ACID:

A:原子性(Atomicity):一個事務(transaction)中的所有操作,要麼全部完成,要麼全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。

就像你買東西要麼交錢收貨一起都執行,要麼發不出貨,就退錢。

C:一致性(Consistency):事務的一致性指的是在一個事務執行之前和執行之後數據庫都必須處於一致性狀態。
* 如果事務成功地完成,那麼系統中所有變化將正確地應用,系統處於有效狀態。
* 如果在事務中出現錯誤,那麼系統中的所有變化將自動地回滾,系統返回到原始狀態。

I:隔離性(Isolation):指的是在併發環境中,當不同的事務同時操縱相同的數據時,每個事務都有各自的完整數據空間。

由併發事務所做的修改必須與任何其他併發事務所做的修改隔離。事務查看數據更新時,數據所處的狀態要麼是另一事務修改它之前的狀態,要麼是另一事務修改它之後的狀態,事務不會查看到中間狀態的數據。

D:持久性(Durability):指的是隻要事務成功結束,它對數據庫所做的更新就必須永久保存下來。即使發生系統崩潰,重新啓動數據庫系統後,數據庫還能恢復到事務成功結束時的狀態。

分佈式事務

隨着互聯網的快速發展,軟件系統由原來的單體應用轉變爲分佈式應用,隨着應用服務化,出現各個微服務,以及這些服務對應的庫表,多個庫表之間的數據操作可能需要保證原子性。

下圖描述了單體應用向微服務的演變:


 
 

分佈式系統會把一個應用系統拆分爲可獨立部署的多個服務,因此需要服務與服務之間遠程協作才能完成事務操作,這種分佈式系統環境下由不同的服務之間通過網絡遠程協作完成事務稱之爲分佈式事務,例如用戶註冊送積分事務、創建訂單減庫存事務,銀行轉賬事務等都是分佈式事務。

分佈式事務基礎理論

CAP定理

CAP定理,又被叫作布魯爾定理。對於設計分佈式系統來說(不僅僅是分佈式事務)的架構師來說,CAP就是你的入門理論。
CAP理論告訴我們,一個分佈式系統不可能同時滿足一致性(C:Consistency)可用性(A:Availability)分區容錯性(P:Partion tolerance)這三個基本需求,最多隻能同時滿足其中的兩項。

C - Consistency(一致性):

在分佈式環境下,一致性是指數據在多個副本之間是否能夠保持一致的特性。對某個指定的客戶端來說,讀操作能返回最新的寫操作。對於數據分佈在不同節點上的數據上來說,如果在某個節點更新了數據,那麼在其他節點如果都能讀取到這個最新的數據,那麼就稱爲強一致,如果有某個節點沒有讀取到,那就是分佈式不一致。

如何實現一致性?

  • 1、寫入主數據庫後要將數據同步到從數據庫。
  • 2、寫入主數據庫後,在向從數據庫同步期間要將從數據庫鎖定,待同步完成後再釋放鎖,以免在新數據寫入成功後,向從數據庫查詢到舊的數據。

分佈式系統一致性的特點:

  • 1、由於存在數據同步的過程,寫操作的響應會有一定的延遲。
  • 2、爲了保證數據一致性會對資源暫時鎖定,待數據同步完成釋放鎖定資源。
  • 3、如果請求數據同步失敗的結點則會返回錯誤信息,一定不會返回舊數據。

A - Availability(可用性):

可用性是指系統提供的服務必須一直處於可用的狀態,對於用戶的每一個操作請求總是能夠在有限的時間內返回正確結果。非故障的節點在合理的時間內返回合理的響應(不是錯誤和超時的響應)。可用性的兩個關鍵一個是合理的時間,一個是合理的響應。合理的時間指的是請求不能無限被阻塞,應該在合理的時間給出返回。合理的響應指的是系統應該明確返回結果並且結果是正確的,這裏的正確指的是比如應該返回50,而不是返回40。

如何實現可用性?

  • 1、寫入主數據庫後要將數據同步到從數據庫。
  • 2、由於要保證從數據庫的可用性,不可將從數據庫中的資源進行鎖定。
  • 3、即使數據還沒有同步過來,從數據庫也要返回要查詢的數據,哪怕是舊數據,如果連舊數據也沒有則可以按照約定返回一個默認信息,但不能返回錯誤或響應超時。

分佈式系統可用性的特點:

  • 1、 所有請求都有響應,且不會出現響應超時或響應錯誤。

P - Partition tolerance(分區容錯性):

分佈式系統在遇到任何網絡分區故障的時候,仍然需要能夠保證對外提供滿足一致性和可用性的服務,除非是整個網絡環境都發生了故障。打個比方,這裏個集羣有多臺機器,有臺機器網絡出現了問題,但是這個集羣仍然可以正常工作。

網絡分區是指在分佈式系統中,不同的節點分佈在不同的子網絡(機房或異地網絡等)中,由於一些特殊的原因導致這些子網絡之間出現網絡不連通的狀況,但各個子網絡的內部網絡是正常的,從而導致整個系統的網絡環境被切分成了若干孤立的區域。需要注意的是,組成一個分佈式系統的每個節點的加入與退出都可以看作是一個特殊的網絡分區。

如何實現分區容錯性?

  • 1、儘量使用異步取代同步操作,例如使用異步方式將數據從主數據庫同步到從數據,這樣結點之間能有效的實現松耦合。
  • 2、添加從數據庫結點,其中一個從結點掛掉其它從結點提供服務。

分佈式分區容錯性的特點:
1、分區容錯性分是布式系統具備的基本能力。

CAP組合方式

熟悉CAP的人都知道,三者不能共有。在所有分佈式事務場景中不會同時具備CAP三個特性,因爲在具備了P的前提下C和A是不能共存的。

在分佈式系統中,網絡無法100%可靠,分區其實是一個必然現象,如果我們選擇了CA而放棄了P,那麼當發生分區現象時,爲了保證一致性,這個時候必須拒絕請求,但是A又不允許,所以分佈式系統理論上不可能選擇CA架構,只能選擇CP或者AP架構。

順便一提,CAP理論中是忽略網絡延遲,也就是當事務提交時,從節點A複製到節點B,但是在現實中這個是明顯不可能的,所以總會有一定的時間是不一致。同時CAP中選擇兩個,比如你選擇了CP,並不是叫你放棄A。因爲P出現的概率實在是太小了,大部分的時間你仍然需要保證CA。就算分區出現了你也要爲後來的A做準備,比如通過一些日誌的手段,是其他機器回覆至可用。

CAP有哪些組合方式呢?

所以在生產中對分佈式事務處理時要根據需求來確定滿足CAP的哪兩個方面。
1)AP
放棄一致性(這裏說的一致性是強一致性),追求分區容錯性和可用性。這是很多分佈式系統設計時的選擇。

通常實現AP都會保證最終一致性,後面講的BASE理論就是根據AP來擴展的,一些業務場景 比如:訂單退款,今日退款成功,明日賬戶到賬,只要用戶可以接受在一定時間內到賬即可。

2)CP
放棄可用性,追求一致性和分區容錯性,我們的zookeeper其實就是追求的強一致,又比如跨行轉賬,一次轉賬請求要等待雙方銀行系統都完成整個事務纔算完成。

3)CA
放棄分區容忍性,即不進行分區,不考慮由於網絡不通或結點掛掉的問題,則可以實現一致性和可用性。那麼系統將不是一個標準的分佈式系統,我們最常用的關係型數據就滿足了CA。

階段總結

  • CAP是一個已經被證實的理論,一個分佈式系統最多隻能同時滿足一致性(Consistency)、可用性(Availability)和分區容忍性(Partition tolerance)這三項中的兩項。
  • 它可以作爲我們進行架構設計、技術選型的考量標準。對於多數大型互聯網應用的場景,結點衆多、部署分散,而且現在的集羣規模越來越大,所以節點故障、網絡故障是常態,而且要保證服務可用性達到N個9(99.99..%),並要達到良好的響應性能來提高用戶體驗,因此一般都會做出如下選擇:保證P和A,捨棄C強一致,保證最終一致性。
 
 
 
 

BASE理論

1、理解強一致性和最終一致性
CAP理論告訴我們一個分佈式系統最多隻能同時滿足一致性(Consistency)、可用性(Availability)和分區容忍性(Partition tolerance)這三項中的兩項,其中AP在實際應用中較多,AP即捨棄一致性,保證可用性和分區容忍性,但是在實際生產中很多場景都要實現一致性,比如主數據庫向從數據庫同步數據,即使不要一致性,但是最終也要將數據同步成功來保證數據一致,這種一致性和CAP中的一致性不同,CAP中的一致性要求在任何時間查詢每個結點數據都必須一致,它強調的是強一致性,但是最終一致性是允許可以在一段時間內每個結點的數據不一致,但是經過一段時間每個結點的數據必須一致,它強調的是最終數據的一致性。

BASEBasically Available(基本可用)Soft state(軟狀態)Eventually consistent (最終一致性)三個短語的縮寫。BASE理論是對CAP中AP的一個擴展,通過犧牲強一致性來獲得可用性,當出現故障允許部分不可用但要保證核心功能可用,允許數據在一段時間內是不一致的,但最終達到一致狀態。滿足BASE理論的事務,我們稱之爲“柔性事務”。

BASE理論指的是:

Basically Available(基本可用):允許響應時間拉長,允許功能上的損失,允許降級頁面(系統繁忙,稍後重試等),即分佈式系統在出現故障時,允許損失部分可用功能,保證核心功能可用。如,電商網站交易付款出現問題了,商品依然可以正常瀏覽。

Soft state(軟狀態):是指允許系統中的數據存在中間狀態,並認爲該中間狀態的存在不會影響系統的整體可用性。如訂單的"支付中"、“數據同步中”等狀態,待數據最終一致後狀態改爲“成功”狀態。

Eventually consistent(最終一致性):本質就是需要保證最終數據能夠達到一致性,而不需要實時保證系統數據的強一致性。如訂單的"支付中"狀態,最終會變爲“支付成功”或者"支付失敗",使訂單狀態與實際交易結果達成一致,但需要一定時間的延遲、等待。

分佈式事務解決方案

以理論爲基礎,針對不同的分佈式場景業界常見的解決方案有2PCTCC可靠消息最終一致性最大努力通知這幾種。

兩階段提交:分佈式事務兩階段提交——XA方案 Seata方案

TCC方案:分佈式事務TCC方案——Hmily方案

可靠消息最終一致性:分佈式事務解決方案——可靠消息最終一致性

最大努力通知:分佈式事務解決方案——最大努力通知

分佈式事務對比分析:

2階段提交(2PC):

最大的詬病是一個阻塞協議。RM在執行分支事務後需要等待TM的決定,此時服務會阻塞並鎖定資源。由於其阻塞機制和最差時間複雜度高, 因此,這種設計不能適應隨着事務涉及的服務數量增加而擴展的需要,很難用於併發較高以及子事務生命週期較長 (long-running transactions) 的分佈式服務中。

TCC方案:

如果拿TCC事務的處理流程與2PC兩階段提交做比較,2PC通常都是在跨庫的DB層面,而TCC則在應用層面的處理,需要通過業務邏輯來實現。這種分佈式事務的實現方式的優勢在於,可以讓應用自己定義數據操作的粒度,使得降低鎖衝突、提高吞吐量成爲可能。而不足之處則在於對應用的侵入性非常強,業務邏輯的每個分支都需要實現try、confirm、cancel三個操作。此外,其實現難度也比較大,需要按照網絡狀態、系統故障等不同的失敗原因實現不同的回滾策略。典型的使用場景:滿,登錄送優惠券等。

可靠消息最終一致性

可靠消息最終一致性事務適合執行週期長且實時性要求不高的場景。引入消息機制後,同步的事務操作變爲基於消息執行的異步操作,避免了分佈式事務中的同步阻塞操作的影響,並實現了兩個服務的解耦。典型的使用場景:註冊送積分,登錄送優惠券等。

最大努力通知

最大努力通知是分佈式事務中要求最低的一種,適用於一些最終一致性時間敏感度低的業務;允許發起通知方處理業務失敗,在接收通知方收到通知後積極進行失敗處理,無論發起通知方如何處理結果都會不影響到接收通知方的後續處理;發起通知方需提供查詢執行情況接口,用於接收通知方校對結果。典型的使用場景:銀行通知、支付結果通知等。

 2PCTCC可靠消息最大努力通知
一致性 強一致 最終一致 最終一致 最終一致
吞吐量
實現複雜度

總結:

在條件允許的情況下,我們儘可能選擇本地事務單數據源,因爲它減少了網絡交互帶來的性能損耗,且避免了數據弱一致性帶來的種種問題。若某系統頻繁且不合理的使用分佈式事務,應首先從整體設計角度觀察服務的拆分是否合理,是否高內聚低耦合?是否粒度太小?分佈式事務一直是業界難題,因爲網絡的不確定性,而且我們習慣於拿
分佈式事務與單機事務ACID做對比。

無論是數據庫層的XA、還是應用層TCC、可靠消息、最大努力通知等方案,都沒有完美解決分佈式事務問題,它們不過是各自在性能、一致性、可用性等方面做取捨,尋求某些場景偏好下的權衡。

 

分佈式事務解決方案之2PC(兩階段提交)

2PC即兩階段提交協議,是將整個事務流程分爲兩個階段,準備階段(Prepare phase)、提交階段(commit phase),2是指兩個階段,P是指準備階段,C是指提交階段。

整個事務過程由事務管理器和參與者組成,事務管理器負責決策整個分佈式事務的提交和回滾,事務參與者負責自己本地事務的提交和回滾。

在計算機中部分關係數據庫如Oracle、MySQL支持兩階段提交協議,如下圖:

  • 1、準備階段(Prepare phase):事務管理器給每個參與者發送Prepare消息,每個數據庫參與者在本地執行事務,並寫本地的Undo/Redo日誌,此時事務沒有提交。(Undo日誌是記錄修改前的數據,用於數據庫回滾,Redo日誌是記錄修改後的數據,用於提交事務後寫入數據文件)

  • 2、提交階段(commit phase):如果事務管理器收到了參與者的執行失敗或者超時消息時,直接給每個參與者發送回滾(Rollback)消息;否則,發送提交(Commit)消息;參與者根據事務管理器的指令執行提交或者回滾操作,並釋放事務處理過程中使用的鎖資源。注意:必須在最後階段釋放鎖資源。

下圖展示了2PC的兩個階段,分成功和失敗兩個情況說明:

成功情況:

 
 

失敗情況:

 
 

解決方案

XA方案

2PC的傳統方案是在數據庫層面實現的,如Oracle、MySQL都支持2PC協議,爲了統一標準減少行業內不必要的對接成本,需要制定標準化的處理模型及接口標準,國際開放標準組織Open Group定義了分佈式事務處理模型DTP(Distributed Transaction Processing Reference Model)。

爲了讓大家更明確XA方案的內容程,下面新用戶註冊送積分爲例來說明:

 
 

執行流程如下:

  • 1、應用程序(AP)持有用戶庫和積分庫兩個數據源。
  • 2、應用程序(AP)通過TM通知用戶庫RM新增用戶,同時通知積分庫RM爲該用戶新增積分,RM此時並未提交事務,此時用戶和積分資源鎖定。
  • 3、TM收到執行回覆,只要有一方失敗則分別向其他RM發起回滾事務,回滾完畢,資源鎖釋放。
  • 4、TM收到執行回覆,全部成功,此時向所有RM發起提交事務,提交完畢,資源鎖釋放。

DTP模型定義如下角色:

  • AP(Application Program):即應用程序,可以理解爲使用DTP分佈式事務的程序。
  • RM(Resource Manager):即資源管理器,可以理解爲事務的參與者,一般情況下是指一個數據庫實例,通過資源管理器對該數據庫進行控制,資源管理器控制着分支事務。
  • TM(Transaction Manager):事務管理器,負責協調和管理事務,事務管理器控制着全局事務,管理事務生命週期,並協調各個RM。全局事務是指分佈式事務處理環境中,需要操作多個數據庫共同完成一個工作,這個工作即是一個全局事務。
  • DTP模型定義TM和RM之間通訊的接口規範叫XA,簡單理解爲數據庫提供的2PC接口協議,基於數據庫的XA協議來實現2PC又稱爲XA方案。
    以上三個角色之間的交互方式如下:
    • 1)TM向AP提供 應用程序編程接口,AP通過TM提交及回滾事務。
    • 2)TM交易中間件通過XA接口來通知RM數據庫事務的開始、結束以及提交、回滾等。

總結:
整個2PC的事務流程涉及到三個角色AP、RM、TM。AP指的是使用2PC分佈式事務的應用程序;RM指的是資源管理器,它控制着分支事務;TM指的是事務管理器,它控制着整個全局事務。

  • 1)在準備階段RM執行實際的業務操作,但不提交事務,資源鎖定;

  • 2)在提交階段TM會接受RM在準備階段的執行回覆,只要有任一個RM執行失敗,TM會通知所有RM執行回滾操作,否則,TM將會通知所有RM提交該事務。提交階段結束資源鎖釋放。

應用落地方案是:atomikos + druid的DruidXADataSource
需要引入maven依賴:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.10</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jta-atomikos</artifactId>
</dependency>

XA方案的問題:

  • 1、需要本地數據庫支持XA協議。
  • 2、資源鎖需要等到兩個階段結束才釋放,性能較差。

Seata方案

Seata是由阿里中間件團隊發起的開源項目 Fescar,後更名爲Seata,它是一個是開源的分佈式事務框架。
傳統2PC的問題在Seata中得到了解決,它通過對本地關係數據庫的分支事務的協調來驅動完成全局事務,是工作在應用層的中間件。主要優點是性能較好,且不長時間佔用連接資源,它以高效並且對業務0侵入的方式解決微服務場景下面臨的分佈式事務問題,它目前提供AT模式、XA模式、Saga模式和TCC模式的分佈式事務解決方案。

Seata的設計思想如下:

Seata的設計目標其一是對業務無侵入,因此從業務無侵入的2PC方案着手,在傳統2PC的基礎上演進,並解決2PC方案面臨的問題。
Seata把一個分佈式事務理解成一個包含了若干分支事務的全局事務。全局事務的職責是協調其下管轄的分支事務達成一致,要麼一起成功提交,要麼一起失敗回滾。此外,通常分支事務本身就是一個關係數據庫的本地事務。

下圖是全局事務與分支事務的關係圖:


 
 

Seata有3個基本組成部分:

  • Transaction Coordinator (TC): 事務協調器,它是獨立的中間件,需要獨立部署運行,它維護全局事務的運行狀態,接收TM指令發起全局事務的提交與回滾,負責與RM通信協調各各分支事務的提交或回滾。

  • Transaction Manager (TM): 事務管理器,TM需要嵌入應用程序中工作,它負責開啓一個全局事務,並最終向TC發起全局提交或全局回滾的指令。

  • Resource Manager (RM): 控制分支事務,負責分支註冊、狀態彙報,並接收事務協調器TC的指令,驅動分支(本地)事務的提交和回滾。

 
 

Seata管理的分佈式事務的典型生命週期:

  • 1、TM要求TC開始一項新的全局事務,TC生成代表全局事務的XID。
  • 2、XID通過微服務的調用鏈傳播。
  • 3、RM將本地事務註冊爲XID到TC的相應全局事務的分支。
  • 4、TM要求TC提交或回退XID的相應全局事務。
  • 5、TC驅動XID對應的全局事務下的所有分支事務以完成分支提交或回滾。
 
 
 
image.png

Seata實現2PC與傳統2PC的差別:

  • 架構層次方面,傳統2PC方案的 RM 實際上是在數據庫層,RM 本質上就是數據庫自身,通過 XA 協議實現,而Seata的 RM 是以jar包的形式作爲中間件層部署在應用程序這一側的。

  • 兩階段提交方面,傳統2PC無論第二階段的決議是commit還是rollback,事務性資源的鎖都要保持到Phase2完成才釋放。而Seata的做法是在Phase1 就將本地事務提交,這樣就可以省去Phase2持鎖的時間,整體提高效率。



分佈式事務Seata的一致性解讀

在微服務的架構下,數據不一致的產生原因。

 
 

在微服務的環境下,由於調用鏈路跨越多個應用,甚至跨越多個數據源,數據的一致性在普通情況下難以保證,導致數據不一致的原因非常多,這裏列舉了三個最常見的原因

  • 1、業務異常一個服務鏈路調用中,如果調用的過程出現業務異常,產生異常的應用獨立回滾,非異常的應用數據已經持久化到數據庫。

  • 2、網絡異常調用的過程中,由於網絡不穩定,導致鏈路中斷,部分應用業務執行完成,部分應用業務未被執行。

  • 3、服務不可用若服務不可用,無法被正常調用,也會導致問題的產生。

 
 

在以往如果出現數據不一致的問題,相信大多數的解決方案是這樣的:

  • 1、人工補償數據。
  • 2、定時任務檢查和補償數據。

但是這兩種方式的缺點也是顯然意見的,一種是浪費大量的人力成本和時間,另外一種是浪費大量的系統資源去檢查數據是否一致和額外的人力成本。

原理

 
 

在接觸一項新技術之前,我們應該先從宏觀的角度去理解它大概包含些什麼。在Seata中,它大概分爲以下三個角色。

  • 黃色,Transaction Manager(TM),client端
  • 藍色,Resource Manager(RM),client端
  • 綠色,Transaction Coordinator(TC),server端

你可以根據顏色,名字,縮寫甚至客戶端/服務端去區分這三者的關係,同時簡單去理解它們每一個自身的職責大概是要幹些什麼事情,後面的講解我也會保持一樣的顏色和名字來區分它們。

 
 

Seata其中只一個核心是數據源代理,意味着在你執行一句Sql語句時,Seata會幫你在執行之前和之後做一些額外的操作,從而保證數據的一致性,並且儘可能做到無感知,讓你使用起來感覺非常方便和神奇。這裏首先要去理解兩個知識點。

  • 前置鏡像(Before Image):保存數據變更前的樣子。
  • 後置鏡像(After Image):保存數據變更後的樣子。
  • Undo Log:保存鏡像。

有時候新項目接入的時候,有同事會問,爲什麼事務不生效,如果你也遇到過同樣的問題,那首先要檢查一下自己的數據源是否已經代理成功。

當執行一句Sql時,Seata會嘗試去獲取這條/批數據變更前的內容,並保存到前置鏡像中(Insert語句沒有前置鏡像),然後執行業務Sql,執行完後會嘗試去獲取這條/批數據變更後的內容,並保存到後置鏡像中(Delete語句沒有後置鏡像),之後會進行分支事務註冊,TC在收到分支事務註冊請求時,會持久化這些分支事務信息和根據操作數據的主鍵爲維度作爲全局鎖並持久化,可選持久化方式有:

  • file
  • db
  • redis

在收到TC返回的分支註冊成功響應後,會把鏡像持久化到應用所在的數據源的Undo Log表中,最後提交本地事務。

以上所有操作都會保證在同一個本地事務中,保證業務操作和Undo Log操作的原子性。

一階段

 
 

理解了單個應用的處理流程,再從一個完全的調用鏈路,去看Seata的處理過程,相信理解起來會簡單很多:

  • 1、首先一個使用了@GlobalTransactional的接口被調用,Seata會對其進行攔截,攔截的角色我們稱之爲TM,這個時候會訪問TC開啓一個新的全局事務,TC收到請求後會生成XID和全局事務信息並持久化,然後返回XID。

  • 2、在每一層的調用鏈路中,XID都必須往下傳遞,然後每一層都經過之前說過的處理邏輯,直到執行完成/異常拋出。

直到這裏,一階段已經執行完成。

注意:如果發現事務不生效,需要檢查XID是否成功往下傳遞。

二階段提交

 
 

如果在整個調用鏈路的過程,沒有發生任何異常,那麼二階段提交的過程是非常簡單而且非常的高效,只有兩步:

  • 1、TC清理全局事務對應的信息。
  • 2、RM清理對應Undo Log信息。

二階段回滾

 
 

若調用過程中出現異常,會自動觸發反向回滾

反向回滾表示,如果調用鏈路順序爲 A -> B -> C,那麼回滾順序爲 C -> B -> A。
例:A=Insert,B=Update,如果回滾時不按照反向的順序進行回滾,則有可能出現回滾時先把A刪除了,再更新A,引發錯誤。

在回滾的過程中有可能會遇到一種非常極端的情況,回滾到對應的模塊時,找不到對應的Undo Log,這種情況主要發生在:

  • 分支事務註冊成功,但是由於網絡原因收不到成功的響應,Undo Log未被持久化。
  • 同時全局事務超時(超時時間可自由配置)觸發回滾。

這時候RM會持久化一個特殊的Undo Log,狀態爲GlobalFinished。由於這個全局事務已經回滾,需要防止網絡恢復時,未持久化Undo Log的應用收到了分支註冊成功的響應和持久化Undo Log,並提交本地最終引發的數據不一致。

讀已提交

由於在一階段的時候,數據已經保存到數據庫並提交,所以Seata默認的隔離級別爲讀未提交,如果需要把隔離級別提升至讀已提交則需要使用@GlobalLock標籤並且在查詢語句上加上for update

@GlobalLock
@Transactional
public PayMoneyDto detail(ProcessOnEventRequestDto processOnEventRequestDto) {
    return baseMapper.detail(processOnEventRequestDto.getProcessInfoDto().getBusinessKey())
}

@Mapper
public interface PayMoneyMapper extends BaseMapper<PayMoney> {
    
    @Select("select id, name, amount, account, has_repayment, pay_amount from pay_money m where m.business_key = #{businessKey} for update")
    PayMoneyDto detail(@Param("businessKey") String businessKey);
}

這個時候Seata會對添加了for update的查詢語句進行代理:


 
 

如果一個全局事務1正在操作,並且未進行二階段提交/回滾的時候,全局鎖是被全局事務1鎖持有的,同時另外一個全局事務2嘗試去查詢相同的數據,由於查詢語句被代理,seata會嘗試去獲取這條數據的全局鎖,直到獲取成功/失敗(重試次數達到配置值)爲止。

問題

總體來說遇到的問題不算多,解決起來也比較容易,比如以下這個問題:

 
 

經過排查發現,由於Seata會使用jdbc標準接口嘗試獲取業務操作所對應的表結構,由於表結構改動頻率較少,並且考慮到表結構變更後應用會進行重啓,所以會對錶結構進行緩存,如果表結構改動後不對應用進行重啓,有可能引發構建鏡像時出現NullPointerException。下面貼出關鍵代碼

修改表結構,需要對應用進行重啓,即可解決此問題,非常簡單

第二個遇到的問題就是運行一段時間後,發現branch_table和lock_table存在數據殘留,並且根據xid查詢global_table沒有對應的數據,導致後續操作相同的數據行會出現獲取全局鎖失敗,並且會每隔一段時間小量出現。這個異常隱藏的比較深,而且在開發環境和測試環境無法復現,通過跟蹤源碼和總結原因發現,是由於開啓了Mysql主從,導致提交/回滾時,Seata通過xid查詢分支事務時,數據未同步到從庫,導致遺漏了一部分分支事務數據。

源碼部分

@Override
public GlobalStatus commit(String xid) throws TransactionException {
    //根據xid查詢信息,如果開啓主從,會有可能導致查詢信息不完整
    GlobalSession globalSession = SessionHolder.findGlobalSession(xid);
    if (globalSession == null) {
        return GlobalStatus.Finished;
    }
    globalSession.addSessionLifecycleListener(SessionHolder.getRootSessionManager());
    // just lock changeStatus

    boolean shouldCommit = SessionHolder.lockAndExecute(globalSession, () -> {
        // Highlight: Firstly, close the session, then no more branch can be registered.
        globalSession.closeAndClean();
        if (globalSession.getStatus() == GlobalStatus.Begin) {
            if (globalSession.canBeCommittedAsync()) {
                globalSession.asyncCommit();
                return false;
            } else {
                globalSession.changeStatus(GlobalStatus.Committing);
                return true;
            }
        }
        return false;
    });

    if (shouldCommit) {
        boolean success = doGlobalCommit(globalSession, false);
        //If successful and all remaining branches can be committed asynchronously, do async commit.
        if (success && globalSession.hasBranch() && globalSession.canBeCommittedAsync()) {
            globalSession.asyncCommit();
            return GlobalStatus.Committed;
        } else {
            return globalSession.getStatus();
        }
    } else {
        return globalSession.getStatus() == GlobalStatus.AsyncCommitting ? GlobalStatus.Committed : globalSession.getStatus();
    }
}
@Override
public GlobalStatus rollback(String xid) throws TransactionException {
    //根據xid查詢信息,如果開啓主從,會有可能導致查詢信息不完整
    GlobalSession globalSession = SessionHolder.findGlobalSession(xid);
    if (globalSession == null) {
        return GlobalStatus.Finished;
    }
    globalSession.addSessionLifecycleListener(SessionHolder.getRootSessionManager());
    // just lock changeStatus
    boolean shouldRollBack = SessionHolder.lockAndExecute(globalSession, () -> {
        globalSession.close(); // Highlight: Firstly, close the session, then no more branch can be registered.
        if (globalSession.getStatus() == GlobalStatus.Begin) {
            globalSession.changeStatus(GlobalStatus.Rollbacking);
            return true;
        }
        return false;
    });
    if (!shouldRollBack) {
        return globalSession.getStatus();
    }

    doGlobalRollback(globalSession, false);
    return globalSession.getStatus();
}

部署-高可用

 
 

Seata和其他中間件的高可用部署方式差別不大,如圖片所示,確保應用服務和TC訪問相同的註冊中心和配置中心,同時只需要啓動多臺TC,並將store.mode改爲db模式即可完成高可用部署,並選擇合適的註冊中心和配置中心即可,目前支持的配置中心有

  • nacos
  • consul
  • etcd3
  • eureka
  • redis
  • sofa
  • zookeeper

可選的配置中心有

  • nacos
  • etcd3
  • consul
  • apollo
  • zk

部署-單節點多應用

 
 

當然也有更加靈活的部署方式,通過vgoup-mapping(事務集羣),可以做到單節點多應用的隔離,比如A應用和B應用訪問A-Group的兩個TC,C應用和D應用訪問B-Group的兩個TC,E應用和F應用訪問C-Group的兩個TC。

部署-異地容災

 
 
 
 

通過vgoup-mapping也可以做到異地容災,當原有集羣出現不可用時,可以通過變更配置立刻轉移到備用的集羣上。此處以Nacos作爲註冊中心舉例,TC配置方式如下:

# 廣州機房
registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"
  loadBalance = "RandomLoadBalance"
  loadBalanceVirtualNodes = 10

  nacos {
    application = "seata-server"
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    cluster = "Guangzhou"
    username = ""
    password = ""
  }
}
 

# 上海機房
registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"
  loadBalance = "RandomLoadBalance"
  loadBalanceVirtualNodes = 10

  nacos {
    application = "seata-server"
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    cluster = "Shanghai"
    username = ""
    password = ""
  }
}

 

基於可靠消息的分佈式事務

 

項目使用技術

springboot、dubbo、zookeeper、定時任務、消息中間件MQ

一、項目結構

maven父子工程:

父工程:consis

子工程:api-service、order、product、message

api-service:該項目主要是提供接口調用的,還包含實體類、枚舉等一些通用內容

order:該項目是專門處理訂單相關操作的系統

product:該項目是專門處理產品相關操作的系統

message:該項目是提供消息服務的系統,好包括定時任務

 

介紹下各個系統的實現

二、order訂單系統

核心代碼:

@Override
@Transactional
public void add(Orders order) {
    String messageBody = JSONObject.toJSONString( order );
    //添加消息到數據庫
    String messageId = transactionMessageService.savePreparMessage(order.getMessageId(), messageBody, Constant.ORDER_QUEUE_NAME );
    log.info(">>> 預發送消息,消息編號:{}", messageId);
    boolean flag = false;
    boolean success = false;
    try{

        Orders orders = orderDao.saveAndFlush( order );
        //int i = 1/0 ;
        log.info(">>> 插入訂單,訂單編號:{}", orders.getId());
        flag = true;
    }catch (Exception e){
        transactionMessageService.delete( messageId );
        log.info(">>> 業務執行異常刪除消息,消息編號:{}", messageId, e);
        throw new RuntimeException( ">>> 創建訂單失敗" );
    }finally {
        if(flag){
            try {
                transactionMessageService.confirmAndSend( messageId );
                success = true;
                log.info(">>> 確認並且發送消息到實時消息中間件,消息編號:{}", messageId);

            }catch (Exception e){
                log.error(">>> 消息確認異常,消息編號:{}", messageId, e);
                if(!success){
                    transactionMessageService.delete( messageId );
                    throw new RuntimeException( ">>> 確認消息異常,創建訂單失敗" );
                }
            }
        }
    }
}

  • 插入訂單表之前,首先創建預發送消息,保存到事務消息表中,此時消息狀態爲:未發送
  • 插入訂單,如果插入訂單失敗則將事務消息表中預發送消息刪除
  • 插入訂單成功後,修改消息表預發送消息狀態爲發送中,併發送消息至mq
  • 如果發送消息失敗,則訂單回滾並刪除事務消息表消息

三、message消息系統

核心代碼一:

@Override
public void sendMessageToMessageQueue(String queueName,final String messageBody) {

    jmsTemplate.convertAndSend( queueName,messageBody );

    log.info(">>> 發送消息到mq 隊列:{},消息內容:{}", queueName, messageBody);
}

  • 主要是activemq生產者講消息發送至MQ消息中間件

核心代碼二:

/**
 * 定時重發消息(每分鐘)
 */
@Scheduled(cron = "0 */1 * * * ?")
public void    handler(){
    //查詢transaction_message表中已發送但未被刪除的消息
    List<TransactionMessage> list = transactionMessageService.queryRetryList( Constant.MESSAGE_UNDEAD, maxTimeOut, Constant.MESSAGE_SENDING );
    if(list!=null && list.size() > 0){
        for (TransactionMessage message:list){
            try {
                transactionMessageService.retry( message.getMessageId() );
            } catch (Exception e) {
                log.warn(">>> 消息不存在,可能已經被消費,消息編號:{}", message.getMessageId());
            }
        }
    }
}

/**
 * 定時通知工作人員(每隔5分鐘)
 */
@Scheduled(cron = "0 */5 * * * ?")
public void    advance(){
    List<Long> messages = transactionMessageService.queryDeadList();
    log.warn(">>> 共有:{}條消息需要人工處理", messages.size());
    String ids = JSONObject.toJSONString( messages );
    //發郵件或者是發送短信通知工作人員處理

}

  • 定時重發消息
  • 定時將死亡的消息通知給工作人員,進行人工補償操作

四、product產品系統

核心代碼:

@Transactional
@JmsListener( destination = Constant.ORDER_QUEUE_NAME)
public void    receiveQueue(String msg){
    boolean flag = false;
    Orders orders = JSONObject.parseObject( msg, Orders.class );
    log.info(">>> 接收到mq消息隊列,消息編號:{} ,消息內容:{}", orders.getMessageId(), msg);

    TransactionMessage transactionMessage = transactionMessageService.findByMessageId( orders.getMessageId() );
    try {
        //保證冪等性
        if(transactionMessage!=null){
            List<OrderDetail> list = orders.getList();
            for(OrderDetail detail : list){
                Product product = productService.findById( detail.getId() );
                Long skuNum = product.getProductSku() - detail.getNum();
                if(skuNum >= 0){
                    product.setProductSku( skuNum );
                    productService.update( product );
                }else {
                    throw new Exception( ">>> 庫存不足,修改庫存失敗!" );
                }

            }
            //int i = 1 /0 ;
            flag = true;
        }

    }catch (Exception e){
        e.printStackTrace();
        throw new RuntimeException( e );
    }finally {
        if(flag){
            transactionMessageService.delete( orders.getMessageId() );
            DbLog dbLog = dbLogService.findByMesageId( orders.getMessageId() );
            if(dbLog!=null){
                dbLog.setState( "1" );//已處理成功
                dbLogService.update( dbLog );
            }
            log.info(">>> 業務執行成功刪除消息! messageId:{}", orders.getMessageId());
        }
    }

}

  • 從mq消息中間件中監聽並消費消息,將json消息轉爲訂單對象
  • 根據消息編號查詢該消息是否已被消費,保證冪等性
  • 如果消息未被消費(即存在此消息),則產品表扣減庫存;如果已經消費(不存在此消息),則不做處理
  • 產品表扣減庫存成功,則刪除此消息,如果待處理消息日誌表中有此消息,則更改狀態爲1,表示已處理;扣減失敗,則不做處理
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章