1.基礎概念
1.1.什麼是事務
什麼是事務?舉個生活中的例子:你去小賣鋪買東西,“一手交錢,一手交貨”就是一個事務的例子,交錢和交貨必 須全部成功,事務纔算成功,任一個活動失敗,事務將撤銷所有已成功的活動。
明白上述例子,再來看事務的定義:
事務可以看做是一次大的活動,它由不同的小活動組成,這些活動要麼全部成功,要麼全部失敗。
1.2.本地事務
在計算機系統中,更多的是通過關係型數據庫來控制事務,這是利用數據庫本身的事務特性來實現的,因此叫數據庫事務,由於應用主要靠關係數據庫來控制事務,而數據庫通常和應用在同一個服務器,所以基於關係型數據庫的事務又被稱爲本地事務。
- 數據庫事務的四大特性 ACID
A(Atomic):原子性,構成事務的所有操作,要麼都執行完成,要麼全部不執行,不可能出現部分成功部分失敗的情況。
C(Consistency):一致性,在事務執行前後,數據庫的一致性約束沒有被破壞。比如:張三向李四轉100元, 轉賬前和轉賬後的數據是正確狀態這叫一致性,如果出現張三轉出100元,李四賬戶沒有增加100元這就出現了數據錯誤,就沒有達到一致性。
I(Isolation):隔離性,數據庫中的事務一般都是併發的,隔離性是指併發的兩個事務的執行互不干擾,一個事務不能看到其他事務運行過程的中間狀態。通過配置事務隔離級別可以避髒讀、重複讀等問題。
D(Durability):持久性,事務完成之後,該事務對數據的更改會被持久化到數據庫,且不會被回滾。
數據庫事務在實現時會將一次事務涉及的所有操作全部納入到一個不可分割的執行單元,該執行單元中的所有操作要麼都成功,要麼都失敗,只要其中任一操作執行失敗,都將導致整個事務的回滾。
- 事務的四種隔離級別
數據庫事務的隔離級別有4種,由低到高分別爲Read uncommitted 、Read committed 、Repeatable read 、Serializable 。而且,在事務的併發操作中可能會出現髒讀,不可重複讀,幻讀。
大多數數據庫默認的事務隔離級別是Read committed,比如Sql Server , Oracle等。Mysql的默認隔離級別是Repeatable read。
詳細說明可參考該篇博客https://www.cnblogs.com/ubuntu1/p/8999403.html
1.3.分佈式事務
隨着互聯網的快速發展,軟件系統由原來的單體應用轉變爲分佈式應用,下圖描述了單體應用向微服務的演變:
分佈式系統會把一個應用系統拆分爲可獨立部署的多個服務,因此需要服務與服務之間遠程協作才能完成事務操作,這種分佈式系統環境下由不同的服務之間通過網絡遠程協作完成事務稱之爲分佈式事務,例如用戶註冊送積分事務、創建訂單減庫存事務,銀行轉賬事務等都是分佈式事務。
我們知道本地事務依賴數據庫本身提供的事務特性來實現,因此以下邏輯可以控制本地事務:
begin transaction;
//1.本地數據庫操作:張三減少金額
//2.本地數據庫操作:李四增加金額
commit transation;
但是在分佈式環境下,會變成下邊這樣:
begin transaction;
//1.本地數據庫操作:張三減少金額
//2.遠程調用:讓李四增加金額
commit transation;
可以設想,當遠程調用讓李四增加金額成功了,由於網絡問題遠程調用並沒有返回,此時本地事務提交失敗就回滾了張三減少金額的操作,此時張三和李四的數據就不一致了。
因此在分佈式架構的基礎上,傳統數據庫事務就無法使用了,張三和李四的賬戶不在一個數據庫中甚至不在一個應用系統裏,實現轉賬事務需要通過遠程調用,由於網絡問題就會導致分佈式事務問題。
1.4 分佈式事務產生的場景
- 典型的場景就是微服務架構微服務之間通過遠程調用完成事務操作。
比如:訂單微服務和庫存微服務,下單的同時訂單微服務請求庫存微服務減庫存。 簡言之:跨JVM進程產生分佈式事務。
- 單體系統訪問多個數據庫實例,當單體系統需要訪問多個數據庫(實例)時就會產生分佈式事務。
比如:用戶信息和訂單信息分別在兩個MySQL實例存儲,用戶管理系統刪除用戶信息,需要分別刪除用戶信息及用戶的訂單信息,由於數據分佈在不同的數據實例,需要通過不同的數據庫鏈接去操作數據,此時產生分佈式事務。 簡言之:跨數據庫實例產生分佈式事務。
- 多服務訪問同一個數據庫實例
比如:訂單微服務和庫存微服務即使訪問同一個數據庫也會產生分佈式事務,原因就是跨JVM進程,兩個微服務持有了不同的數據庫鏈接進行數據庫操作,此時產生分佈式事務。
2.分佈式事務基礎理論
與本地事務不同的是,分佈式系統之所以叫分佈式,是因爲提供服務的各個節點分佈在不同機器上,相互之間通過網絡交互。不能因爲有一點網絡問題就導致整個系統無法提供服務,網絡因素成爲了分佈式事務的考量標準之一。因此,分佈式事務需要更進一步的理論支持,接下來,我們先來回顧一下分佈式事務的CAP理論。
在講解分佈式事務控制解決方案之前需要先說明一些基礎理論,通過理論知識指導我們確定分佈式事務控制的目標,從而幫助我們理解每個解決方案。
2.1.CAP理論
2.1.1.理解CAP
CAP是 Consistency、Availability、Partition tolerance三個詞語的縮寫,分別表示一致性、可用性、分區容忍性。
爲了方便對CAP理論的理解,我們結合電商系統中的一些業務場景來理解CAP。
如下圖,是商品信息管理的執行流程:
整體執行流程如下:
1、商品服務請求主數據庫寫入商品信息(添加商品、修改商品、刪除商品)
2、主數據庫向商品服務響應寫入成功。
3、商品服務請求從數據庫讀取商品信息。
C - Consistency:
一致性是指寫操作後的讀操作可以讀取到最新的數據狀態,當數據分佈在多個節點上,從任意結點讀取到的數據都是最新的狀態。
上圖中,商品信息的讀寫要滿足一致性就是要實現如下目標:
- 商品服務寫入主數據庫成功,則向從數據庫查詢新數據也成功。
- 商品服務寫入主數據庫失敗,則向從數據庫查詢新數據也失敗。
如何實現一致性?
- 寫入主數據庫後要將數據同步到從數據庫。
- 寫入主數據庫後,在向從數據庫同步期間要將從數據庫鎖定,待同步完成後再釋放鎖,以免在新數據寫入成功後,向從數據庫查詢到舊的數據。
分佈式系統一致性的特點:
- 由於存在數據同步的過程,寫操作的響應會有一定的延遲。
- 爲了保證數據一致性會對資源暫時鎖定,待數據同步完成釋放鎖定資源。
- 如果請求數據同步失敗的結點則會返回錯誤信息,一定不會返回舊數據。
A - Availability :
可用性是指任何事務操作都可以得到響應結果,且不會出現響應超時或響應錯誤。
上圖中,商品信息讀取滿足可用性就是要實現如下目標:
- 從數據庫接收到數據查詢的請求則立即能夠響應數據查詢結果。
- 從數據庫不允許出現響應超時或響應錯誤。
如何實現可用性?
- 寫入主數據庫後要將數據同步到從數據庫。
- 由於要保證從數據庫的可用性,不可將從數據庫中的資源進行鎖定。
- 即使數據還沒有同步過來,從數據庫也要返回要查詢的數據,哪怕是舊數據,如果連舊數據也沒有則可以按照約定返回一個默認信息,但不能返回錯誤或響應超時。
分佈式系統可用性的特點:
- 所有請求都有響應,且不會出現響應超時或響應錯誤。
P - Partition tolerance:
通常分佈式系統的各個結點部署在不同的子網,這就是網絡分區,不可避免的會出現由於網絡問題而導致結點之間通信失敗,此時仍可對外提供服務,這叫分區容忍性。
上圖中,商品信息讀寫滿足分區容忍性就是要實現如下目標:
- 主數據庫向從數據庫同步數據失敗不影響讀寫操作。
- 其一個結點掛掉不影響另一個結點對外提供服務。
如何實現分區容忍性?
- 儘量使用異步取代同步操作,例如使用異步方式將數據從主數據庫同步到從數據,這樣結點之間能有效的實現松耦合。
- 添加從數據庫結點,其中一個從結點掛掉其它從結點提供服務。
分佈式分區容忍性的特點:
- 分區容忍性是分佈式系統具備的基本能力。
2.1.2.CAP組合方式
1、上邊商品管理的例子是否同時具備CAP呢?
在所有分佈式事務場景中不會同時具備CAP三個特性,因爲在具備了P的前提下C和A是不能共存的。
CAP原則:
又稱CAP定理,指的是在一個分佈式系統中,一致性(Consistency)、可用性(Availability)、分區容錯性(Partition tolerance)。CAP 原則指的是,這三個要素最多隻能同時實現兩點,不可能三者兼顧。因此在進行分佈式架構設計時,必須做出取捨。
比如:
下圖滿足了P即實現分區容忍性:
本圖分區容忍的含義是:
- 主數據庫通過網絡向從數據同步數據,可以認爲主從數據庫部署在不同的分區,通過網絡進行交互。
- 當主數據庫和從數據庫之間的網絡出現問題不影響主數據庫和從數據庫對外提供服務。
- 其一個結點掛掉不影響另一個結點對外提供服務。
如果要實現C則必須保證數據一致性,在數據同步的時候爲防止向從數據庫查詢不一致的數據則需要將從數據庫數據鎖定,待同步完成後解鎖,如果同步失敗從數據庫要返回錯誤信息或超時信息。
如果要實現A則必須保證數據可用性,不管任何時候都可以向從數據庫查詢數據,且不會響應超時或返回錯誤信息。 通過分析發現在滿足P的前提下C和A存在矛盾性。
2、CAP有哪些組合方式呢?
所以在生產中對分佈式事務處理時要根據需求來確定滿足CAP的哪兩個方面。
1)AP:
放棄一致性,追求分區容忍性和可用性。這是很多分佈式系統設計時的選擇。
例如:
上邊的商品管理,完全可以實現AP,前提是隻要用戶可以接受所查詢的到數據在一定時間內不是最新的即可。
通常實現AP都會保證最終一致性,後面講的BASE理論就是根據AP來擴展的,一些業務場景 比如:訂單退款,今日退款成功,明日賬戶到賬,只要用戶可以接受在一定時間內到賬即可。
2)CP:
放棄可用性,追求一致性和分區容錯性,我們的zookeeper其實就是追求的強一致,又比如跨行轉賬,一次轉賬請求要等待雙方銀行系統都完成整個事務纔算完成。
3)CA:
放棄分區容忍性,即不進行分區,不考慮由於網絡不通或結點掛掉的問題,則可以實現一致性和可用性。那麼系統將不是一個標準的分佈式系統,我們最常用的關係型數據就滿足了CA。
上邊的商品管理,如果要實現CA則架構如下:
主數據庫和從數據庫中間不再進行數據同步,數據庫可以響應每次的查詢請求,通過事務隔離級別實現每個查詢請求都可以返回最新的數據。
2.1.3 總結
CAP是一個已經被證實的理論:一個分佈式系統最多隻能同時滿足 一致性(Consistency)、可用性(Availability)和分區容忍性(Partition tolerance)這三項中的兩項。它可以作爲我們進行架構設計、技術選型的考量標準。對於多數大型互聯網應用的場景,結點衆多、部署分散,而且現在的集羣規模越來越大,所以節點故障、網絡故障是常態,而且要保證服務可用性達到N個9(99.99…%),並要達到良好的響應性能來提高用戶體驗, 因此一般都會做出如下選擇:保證P和A,捨棄C強一致,保證最終一致性。
2.2.BASE理論
1、理解強一致性和最終一致性
CAP理論告訴我們一個分佈式系統最多隻能同時滿足一致性(Consistency)、可用性(Availability)和分區容忍性(Partition tolerance)這三項中的兩項,其中AP在實際應用中較多,AP即捨棄一致性,保證可用性和分區容忍性,但是在實際生產中很多場景都要實現一致性,比如前邊我們舉的例子主數據庫向從數據庫同步數據,即使不要 一致性,但是最終也要將數據同步成功來保證數據一致,這種一致性和CAP中的一致性不同,CAP中的一致性要求在任何時間查詢每個結點數據都必須一致,它強調的是強一致性,但是最終一致性是允許可以在一段時間內每個結點的數據不一致,但是經過一段時間每個結點的數據必須一致,它強調的是最終數據的一致性。
2、Base理論介紹
BASE 是 Basically Available(基本可用)、Soft state(軟狀態)和 Eventually consistent (最終一致性)三個短語的縮寫。BASE理論是對CAP中AP的一個擴展,通過犧牲強一致性來獲得可用性,當出現故障允許部分不可用但要保證核心功能可用,允許數據在一段時間內是不一致的,但最終達到一致狀態。滿足BASE理論的事務,我們稱之爲“柔性事務”。
- 基本可用:分佈式系統在出現故障時,允許損失部分可用功能,保證核心功能可用。如,電商網站交易付款出現問題了,商品依然可以正常瀏覽。
- 軟狀態:由於不要求強一致性,所以BASE允許系統中存在中間狀態(也叫軟狀態),這個狀態不影響系統可用性,如訂單的"支付中"、“數據同步中”等狀態,待數據最終一致後狀態改爲“成功”狀態。
- 最終一致:最終一致是指經過一段時間後,所有節點數據都將會達到一致。如訂單的"支付中"狀態,最終會變 爲“支付成功”或者"支付失敗",使訂單狀態與實際交易結果達成一致,但需要一定時間的延遲、等待。
3.分佈式事務解決方案之2PC(兩階段提交)
前面已經回顧了分佈式事務的基礎理論,以理論爲基礎,針對不同的分佈式場景業界常見的解決方案有2PC、 TCC、可靠消息最終一致性、最大努力通知這幾種。
3.1.什麼是2PC
2PC即兩階段提交協議,是將整個事務流程分爲兩個階段,準備階段(Prepare phase)、提交階段(commit phase),2是指兩個階段,P是指準備階段,C是指提交階段。
舉例:張三和李四好久不見,老友約起聚餐,飯店老闆要求先買單,才能出票。這時張三和李四分別抱怨近況不如意,囊中羞澀,都不願意請客,這時只能AA。只有張三和李四都付款,老闆才能出票安排就餐。但由於張三和李四都是鐵公雞,形成了尷尬的一幕:
準備階段:老闆要求張三付款,張三付款。老闆要求李四付款,李四付款。
提交階段:老闆出票,兩人拿票紛紛落座就餐。
例子中形成了一個事務,若張三或李四其中一人拒絕付款,或錢不夠,店老闆都不會給出票,並且會把已收款退回。
整個事務過程由事務管理器和參與者組成,店老闆就是事務管理器,張三、李四就是事務參與者,事務管理器負責決策整個分佈式事務的提交和回滾,事務參與者負責自己本地事務的提交和回滾。
在計算機中部分關係數據庫如Oracle、MySQL支持兩階段提交協議,如下圖:
- 準備階段(Prepare phase):事務管理器給每個參與者發送Prepare消息,每個數據庫參與者在本地執行事務,並寫本地的Undo/Redo日誌,此時事務沒有提交。(Undo日誌是記錄修改前的數據,用於數據庫回滾,Redo日誌是記錄修改後的數據,用於提交事務後寫入數據文件)
- 提交階段(commit phase):如果事務管理器收到了參與者的執行失敗或者超時消息時,直接給每個參與者發送回滾(Rollback)消息;否則,發送提交(Commit)消息;參與者根據事務管理器的指令執行提交或者回滾操作,並釋放事務處理過程中使用的鎖資源。注意:必須在最後階段釋放鎖資源。
下圖展示了2PC的兩個階段,分成功和失敗兩個情況說明:
成功情況:
失敗情況:
3.2.解決方案
3.2.1 XA方案
2PC的傳統方案是在數據庫層面實現的,如Oracle、MySQL都支持2PC協議,爲了統一標準減少行業內不必要的對接成本,需要制定標準化的處理模型及接口標準,國際開放標準組織Open Group定義了分佈式事務處理模型 DTP(Distributed Transaction Processing Reference Model)。
爲了讓大家更明確XA方案的內容程,下面新用戶註冊送積分爲例來說明:
執行流程如下:
- 應用程序(AP)持有用戶庫和積分庫兩個數據源。
- 應用程序(AP)通過TM通知用戶庫RM新增用戶,同時通知積分庫RM爲該用戶新增積分,RM此時並未提交事務,此時用戶和積分資源鎖定。
- TM收到執行回覆,只要有一方失敗則分別向其他RM發起回滾事務,回滾完畢,資源鎖釋放。
- 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提交該事務。提交階段結束資源鎖釋放。
XA方案的問題:
- 需要本地數據庫支持XA協議。
- 資源鎖需要等到兩個階段結束才釋放,性能較差。
3.2.2 Seata方案
Seata是由阿里中間件團隊發起的開源項目 Fescar,後更名爲Seata,它是一個是開源的分佈式事務框架。 傳統2PC的問題在Seata中得到了解決,它通過對本地關係數據庫的分支事務的協調來驅動完成全局事務,是工作在應用層的中間件。主要優點是性能較好,且不長時間佔用連接資源,它以高效並且對業務0侵入的方式解決微服務場景下面臨的分佈式事務問題,它目前提供AT模式(即2PC)及TCC模式的分佈式事務解決方案。
Seata的設計思想如下:
Seata的設計目標其一是對業務無侵入,因此從業務無侵入的2PC方案着手,在傳統2PC的基礎上演進,並解決 2PC方案面臨的問題。
Seata把一個分佈式事務理解成一個包含了若干分支事務的全局事務。全局事務的職責是協調其下管轄的分支事務 達成一致,要麼一起成功提交,要麼一起失敗回滾。此外,通常分支事務本身就是一個關係數據庫的本地事務,下圖是全局事務與分支事務的關係圖:
與 傳統2PC 的模型類似,Seata定義了3個組件來協議分佈式事務的處理過程:
- Transaction Coordinator ( TC ):事務協調器,它是獨立的中間件,需要獨立部署運行,它維護全局事務的運行狀態,接收TM指令發起全局事務的提交與回滾,負責與RM通信協調各各分支事務的提交或回滾。
- Transaction Manager ( TM ):事務管理器,TM需要嵌入應用程序中工作,它負責開啓一個全局事務,並最終向TC發起全局提交或全局回滾的指令。
- Resource Manager ( RM ):數據庫實例。控制分支事務,負責分支註冊、狀態彙報,並接收事務協調器TC的指令,驅動分支(本地)事務的提交和回滾。
還拿新用戶註冊送積分舉例Seata的分佈式事務過程:
具體的執行流程如下:
- 用戶服務的 TM 向 TC 申請開啓一個全局事務,全局事務創建成功並生成一個全局唯一的XID。
- 用戶服務的 RM 向 TC 註冊 分支事務,該分支事務在用戶服務執行新增用戶邏輯,並將其納入 XID 對應全局 事務的管轄。
- 用戶服務執行分支事務,向用戶表插入一條記錄。【完成分支事務並釋放鎖 】
- 邏輯執行到遠程調用積分服務時(XID 在微服務調用鏈路的上下文中傳播)。積分服務的RM 向 TC 註冊分支事務,該分支事務執行增加積分的邏輯,並將其納入 XID 對應全局事務的管轄。
- 積分服務執行分支事務,向積分記錄表插入一條記錄,執行完畢後,返回用戶服務。 【完成分支事務並釋放鎖】
- 用戶服務分支事務執行完畢。
- TM 向 TC 發起針對 XID 的全局提交或回滾決議。
- TC 調度 XID 下管轄的全部分支事務完成提交或回滾請求。
Seata實現2PC與傳統2PC的差別:
- 架構層次方面,傳統2PC方案的 RM 實際上是在數據庫層,RM 本質上就是數據庫自身,通過 XA 協議實現,而 Seata的 RM 是以jar包的形式作爲中間件層部署在應用程序這一側的。
- 兩階段提交方面,傳統2PC無論第二階段的決議是commit還是rollback,事務性資源的鎖都要保持到Phase2完成才釋放【XA方案並未提交數據,等到Phase2完成才提交本地事務並釋放鎖】。而Seata的做法是在Phase1 就將本地事務提交,這樣就可以省去Phase2持鎖的時間,整體提高效率。
3.3.seata實現2PC事務
3.3.1.業務說明
本示例通過Seata中間件實現分佈式事務,模擬三個賬戶的轉賬交易過程。
兩個賬戶在三個不同的銀行(張三在bank1、李四在bank2),bank1和bank2是兩個個微服務。交易過程是,張三 給李四轉賬指定金額。
上述交易步驟,要麼一起成功,要麼一起失敗,必須是一個整體性的事務。
3.3.2.程序組成部分
本示例程序組成部分如下:
微服務及數據庫的關係 :
dtx/seata-demo/seata-demo-bank1 銀行1,操作張三賬戶, 連接數據庫bank1
dtx/seata-demo/seata-demo-bank2 銀行2,操作李四賬戶,連接數據庫bank2
服務註冊中心:dtx/discover-server
本示例程序技術架構如下:
交互流程如下:
1、請求bank1進行轉賬,傳入轉賬金額。
2、bank1減少轉賬金額,調用bank2,傳入轉賬金額。
3.3.3 項目示例代碼
鏈接:https://pan.baidu.com/s/1IMy0-82ryYGeNkJmjmcKsw
提取碼:2jl3
- 配置seata
在src/main/resource中,新增file.conf、registry.conf(可選,使用nacos客戶端不需要)文件,內容可拷貝自seata中的配置文件。
- 在registry.conf中registry.type使用file:
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "file"
- 在file.conf中更改service.vgroup_mapping.[springcloud服務名]-fescar-service-group = “default”,並修改
service.default.grouplist =[seata服務端地址]
service {
#vgroup->rgroup
vgroup_mapping.seata-demo-bank1-fescar-service-group = "default"
#only support single node
default.grouplist = "127.0.0.1:8888"
#degrade current not support
enableDegrade = false
#disable
disable = false
}
關於vgroup_mapping的配置:
vgroup_mapping.[事務分組服務名] = Seata Server集羣名稱(默認名稱爲default)
default.grouplist = Seata Server集羣地址
在 org.springframework.cloud:spring-cloud-starter-alibaba-seata 的
org.springframework.cloud.alibaba.seata.GlobalTransactionAutoConfiguration 類中,默認會使用
${spring.application.name}-fescar-service-group 作爲事務分組服務名註冊到 Seata Server上,如果和
file.conf 中的配置不一致,會提示 no available server to connect 錯誤
也可以通過配置 spring.cloud.alibaba.seata.tx-service-group 修改後綴,但是必須和 file.conf 中的配置保持一致。
- 創建代理數據源
新增DatabaseConfiguration.java,Seata的RM通過DataSourceProxy才能在業務代碼的事務提交時,通過這個切入點,與TC進行通信交互、記錄undo_log等。
@Configuration
public class DatabaseConfiguration {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.ds0")
public DruidDataSource ds0() {
DruidDataSource druidDataSource = new DruidDataSource();
return druidDataSource;
}
@Primary
@Bean
public DataSource dataSource(DruidDataSource ds0) {
DataSourceProxy pds0 = new DataSourceProxy(ds0);
return pds0;
}
}
3.3.4.啓動TC(事務協調器)
(1)下載seata服務器
下載地址:https://github.com/seata/seata/releases
(2)解壓並啓動
[seata服務端解壓路徑]/bin/seata-server.bat -p 8888 -m file
注:其中8888爲服務端口號;file爲啓動模式,這裏指seata服務將採用文件的方式存儲信息。
3.3.5.Seata的執行流程
1、正常提交流程
2、回滾流程
回滾流程省略了開始的RM註冊過程。
要點說明:
- 每個RM使用DataSourceProxy連接數據庫,其目的是使用ConnectionProxy,使用數據源和數據連接代理的目的就是在第一階段將undo_log和業務數據放在一個本地事務提交,這樣就保存了只要有業務操作就一定有undo_log。
- 在第一階段undo_log中存放了數據修改前和修改後的值,爲事務回滾作好準備,所以第一階段完成就已經將分支事務提交,也就釋放了鎖資源。
- TM開啓全局事務開始,將XID全局事務id放在事務上下文中,通過feign調用也將XID傳入下游分支事務,每個分支事務將自己的Branch ID分支事務ID與XID關聯。
- 第二階段全局事務提交,TC會通知各各分支參與者提交分支事務,在第一階段就已經提交了分支事務,這裏各個參與者只需要刪除undo_log即可,並且可以異步執行,第二階段很快可以完成。
- 第二階段全局事務回滾,TC會通知各各分支參與者回滾分支事務,通過 XID 和 Branch ID 找到相應的回滾日誌,通過回滾日誌生成反向的 SQL 並執行,以完成分支事務回滾到之前的狀態,如果回滾失敗則會重試回滾操作。
3.3.6.主要代碼解析
package top.chengsw.seatademo.bank1.service.impl;
import io.seata.core.context.RootContext;
import io.seata.spring.annotation.GlobalTransactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import top.chengsw.seatademo.bank1.dao.AccountInfoDao;
import top.chengsw.seatademo.bank1.feign.Bank2Client;
import top.chengsw.seatademo.bank1.service.AccountInfoService;
@Slf4j
@Service
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
private AccountInfoDao accountInfoDao;
@Autowired
private Bank2Client bank2Client;
@Override
@GlobalTransactional //全局事務是從張三轉賬開始,因此@GlobalTransactional在這裏開啓
@Transactional
public void updateAccountBalance(String accountNo, Double amount) {
log.info(">> Bank1 Service Begin with XID: {}", RootContext.getXID());
//張三扣減金額
accountInfoDao.updateAccountBalance(accountNo, -1 * amount);
//向李四轉賬
String remoteRst = bank2Client.transfer(amount);
//遠程調用失敗
if (remoteRst.equals("error")) {
throw new RuntimeException("bank1 下游服務異常");
}
//人爲製造錯誤
if (amount == 3) {
throw new RuntimeException("bank1 make exception 3");
}
}
}
將@GlobalTransactional註解標註在全局事務發起的Service實現方法上,開啓全局事務:
GlobalTransactionalInterceptor會攔截@GlobalTransactional註解的方法,生成全局事務ID(XID),XID會在整個分佈式事務中傳遞。
在遠程調用時,spring-cloud-alibaba-seata會攔截Feign調用將XID傳遞到下游服務。
3.3.7.測試場景
張三向李四轉賬成功。
李四事務失敗,張三事務回滾成功。
張三事務失敗,李四事務回滾成功。
分支事務超時測試。
3.3.8.總結
由於Seata的0侵入性並且解決了傳統2PC長期鎖資源的問題,所以推薦採用Seata實現2PC。
Seata實現2PC要點:
1、全局事務開始使用 @GlobalTransactional 標識 。
2、每個本地事務方案仍然使用 @Transactional 標識。
3、每個數據都需要創建undo_log表,此表是seata保證本地事務一致性的關鍵。
4.分佈式事務解決方案之TCC
4.1.什麼是TCC事務
TCC是Try、Confirm、Cancel三個詞語的縮寫,TCC要求每個分支事務實現三個操作:預處理Try、確認Confirm、撤銷Cancel。Try操作做業務檢查及資源預留,Confirm做業務確認操作,Cancel實現一個與Try相反的操作即回滾操作。TM首先發起所有的分支事務的try操作,任何一個分支事務的try操作執行失敗,TM將會發起所有分支事務的Cancel操作,若try操作全部成功,TM將會發起所有分支事務的Confirm操作,其中Confirm/Cancel操作若執行失敗,TM會進行重試。
TCC分爲三個階段:
- Try階段是做業務檢查(一致性)及資源預留(隔離),此階段僅是一個初步操作,它和後續的Confirm 一起才能真正構成一個完整的業務邏輯。
- Confirm階段是做確認提交,Try階段所有分支事務執行成功後開始執行Confirm。通常情況下,採用TCC則認爲 Confirm階段是不會出錯的。即:只要Try成功,Confirm一定成功。若Confirm階段真的出錯了,需引入重試機制或人工處理。
- Cancel 階段是在業務執行錯誤需要回滾的狀態下執行分支事務的業務取消,預留資源釋放。通常情況下,採用TCC則認爲Cancel階段也是一定成功的。若Cancel階段真的出錯了,需引入重試機制或人工處理。
- TM事務管理器
TM事務管理器可以實現爲獨立的服務,也可以讓全局事務發起方充當TM的角色,TM獨立出來是爲了成爲公用組件,是爲了考慮系統結構和軟件複用。TM在發起全局事務時生成全局事務記錄,全局事務ID貫穿整個分佈式事務調用鏈條,用來記錄事務上下文,追蹤和記錄狀態,由於Confirm 和Cancel失敗需進行重試,因此需要實現爲冪等,冪等性是指同一個操作無論請求多少次,其結果都相同。
4.2.TCC 解決方案
目前市面上的TCC框架衆多比如下面這幾種:
tcc-transaction https://github.com/changmingxie/tcc-transaction
Hmily https://github.com/yu199195/hmily
ByteTCC https://github.com/liuyangming/ByteTCC
EasyTransaction https://github.com/QNJR-GROUP/EasyTransaction
上面所講的Seata也支持TCC,但Seata的TCC模式對Spring Cloud並沒有提供支持。我們的目標是理解TCC的原 理以及事務協調運作的過程,因此更請傾向於輕量級易於理解的框架,因此最終確定了Hmily。
Hmily是一個高性能分佈式事務TCC開源框架。基於Java語言來開發(JDK1.8),支持Dubbo,Spring Cloud等 RPC框架進行分佈式事務。它目前支持以下特性:
- 支持嵌套事務(Nested transaction support).
- 採用disruptor框架進行事務日誌的異步讀寫,與RPC框架的性能毫無差別。
- 支持SpringBoot-starter 項目啓動,使用簡單。
- RPC框架支持 : dubbo,motan,springcloud。
- 本地事務存儲支持 : redis,mongodb,zookeeper,file,mysql。
- 事務日誌序列化支持 :java,hessian,kryo,protostuff。
- 採用Aspect AOP 切面思想與Spring無縫集成,天然支持集羣。
- RPC事務恢復,超時異常恢復等。
Hmily利用AOP對參與分佈式事務的本地方法與遠程方法進行攔截處理,通過多方攔截,事務參與者能透明的 調用到另一方的Try、Confirm、Cancel方法;傳遞事務上下文;並記錄事務日誌,酌情進行補償,重試等。
Hmily不需要事務協調服務,但需要提供一個數據庫(mysql/mongodb/zookeeper/redis/file)來進行日誌存儲。 Hmily實現的TCC服務與普通的服務一樣,只需要暴露一個接口,也就是它的Try業務。Confirm/Cancel業務邏輯,只是因爲全局事務提交/回滾的需要才提供的,因此Confirm/Cancel業務只需要被Hmily TCC事務框架發現即可,不需要被調用它的其他業務服務所感知。
官網介紹:https://dromara.org/website/zh-cn/docs/hmily/index.html
TCC需要注意三種異常處理分別是空回滾、冪等、懸掛:
空回滾:
在沒有調用 TCC 資源 Try 方法的情況下,調用了二階段的 Cancel 方法,Cancel 方法需要識別出這是一個空回滾,然後直接返回成功。
出現原因是當一個分支事務所在服務宕機或網絡異常,分支事務調用記錄爲失敗,這個時候其實是沒有執行Try階段,當故障恢復後,分佈式事務進行回滾則會調用二階段的Cancel方法,從而形成空回滾。
解決思路是關鍵就是要識別出這個空回滾。思路很簡單就是需要知道一階段是否執行,如果執行了,那就是正常回滾;如果沒執行,那就是空回滾。前面已經說過TM在發起全局事務時生成全局事務記錄,全局事務ID貫穿整個分 布式事務調用鏈條。再額外增加一張分支事務記錄表,其中有全局事務 ID 和分支事務 ID,第一階段 Try 方法裏會插入一條記錄,表示一階段執行了。Cancel 接口裏讀取該記錄,如果該記錄存在,則正常回滾;如果該記錄不存在,則是空回滾。
冪等:
通過前面介紹已經瞭解到,爲了保證TCC二階段提交重試機制不會引發數據不一致,要求 TCC 的二階段 Try、 Confirm 和 Cancel 接口保證冪等,這樣不會重複使用或者釋放資源。如果冪等控制沒有做好,很有可能導致數據不一致等嚴重問題。
解決思路在上述“分支事務記錄”中增加執行狀態,每次執行前都查詢該狀態。
懸掛:
懸掛就是對於一個分佈式事務,其二階段 Cancel 接口比 Try 接口先執行。
出現原因是在 RPC 調用分支事務try時,先註冊分支事務,再執行RPC調用,如果此時 RPC 調用的網絡發生擁堵, 通常 RPC 調用是有超時時間的,RPC 超時以後,TM就會通知RM回滾該分佈式事務,可能回滾完成後,RPC 請求才到達參與者真正執行,而一個 Try 方法預留的業務資源,只有該分佈式事務才能使用,該分佈式事務第一階段預留的業務資源就再也沒有人能夠處理了,對於這種情況,我們就稱爲懸掛,即業務資源預留後沒法繼續處理。
解決思路是如果二階段執行完成,那一階段就不能再繼續執行。在執行一階段事務時判斷在該全局事務下,“分支事務記錄”表中是否已經有二階段事務記錄,如果有則不執行Try。
優化方案:
賬戶A
try:
try冪等校驗
try懸掛處理
檢查餘額是否夠30元
扣減30元
confirm:
空
cancel:
cancel冪等校驗
cancel空回滾處理
增加可用餘額30元
賬戶B
try:
空
confirm:
confirm冪等校驗
正式增加30元
cancel:
空
4.3.Hmily實現TCC事務
4.3.1.業務說明
本實例通過Hmily實現TCC分佈式事務,模擬兩個賬戶的轉賬交易過程。
兩個賬戶分別在不同的銀行(張三在bank1、李四在bank2),bank1、bank2是兩個微服務。交易過程是,張三給李四轉賬指定金額。
4.3.2 discover-server
discover-server是服務註冊中心,測試工程將自己註冊至discover-server。已經導過不用重複導入。
4.3.3 導入案例工程dtx-tcc-demo
tcc-demo是tcc的測試工程,根據業務需求需要創建兩個tcc-demo工程。
(1)tcc-demo
兩個測試工程如下:
tcc-demo/tcc-demo-bank1 銀行1,操作張三賬戶,連接數據庫bank1
tcc-demo/tcc-demo-bank2 銀行2,操作李四賬戶,連接數據庫bank2
(2)引入maven依賴
<dependency>
<groupId>org.dromara</groupId>
<artifactId>hmily‐springcloud</artifactId>
<version>2.0.4‐RELEASE</version>
</dependency>
(3)配置hmily
org:
dromara:
hmily :
serializer : kryo
recoverDelayTime : 128
retryMax : 30
scheduledDelay : 128
scheduledThreadMax : 10
repositorySupport : db
started: true # 事務開啓方設置true,另一方設置false
hmilyDbConfig :
driverClassName : com.mysql.cj.jdbc.Driver
url : jdbc:mysql://localhost:3306/bank?useUnicode=true
username : root
password : root
新增配置類接收application.yml中的Hmily配置信息,並創建HmilyTransactionBootstrap Bean:
@Bean
public HmilyTransactionBootstrap hmilyTransactionBootstrap(HmilyInitService hmilyInitService){
HmilyTransactionBootstrap hmilyTransactionBootstrap = new HmilyTransactionBootstrap(hmilyInitService);
hmilyTransactionBootstrap.setSerializer(env.getProperty("org.dromara.hmily.serializer"));
hmilyTransactionBootstrap.setRecoverDelayTime(Integer.parseInt(env.getProperty("org.dromara.hmily.recoverDelayTime")));
hmilyTransactionBootstrap.setRetryMax(Integer.parseInt(env.getProperty("org.dromara.hmily.retryMax")));
hmilyTransactionBootstrap.setScheduledDelay(Integer.parseInt(env.getProperty("org.dromara.hmily.scheduledDelay")));
hmilyTransactionBootstrap.setScheduledThreadMax(Integer.parseInt(env.getProperty("org.dromara.hmily.scheduledThreadMax")));
hmilyTransactionBootstrap.setRepositorySupport(env.getProperty("org.dromara.hmily.repositorySupport"));
hmilyTransactionBootstrap.setStarted(Boolean.parseBoolean(env.getProperty("org.dromara.hmily.started")));
HmilyDbConfig hmilyDbConfig = new HmilyDbConfig();
hmilyDbConfig.setDriverClassName(env.getProperty("org.dromara.hmily.hmilyDbConfig.driverClassName"));
hmilyDbConfig.setUrl(env.getProperty("org.dromara.hmily.hmilyDbConfig.url"));
hmilyDbConfig.setUsername(env.getProperty("org.dromara.hmily.hmilyDbConfig.username"));
hmilyDbConfig.setPassword(env.getProperty("org.dromara.hmily.hmilyDbConfig.password"));
hmilyTransactionBootstrap.setHmilyDbConfig(hmilyDbConfig);
return hmilyTransactionBootstrap;
}
啓動類增加@EnableAspectJAutoProxy並增加org.dromara.hmily的掃描項:
@SpringBootApplication
@EnableDiscoveryClient
@EnableHystrix
@EnableAspectJAutoProxy //AOP註解
@EnableFeignClients(basePackages ={"top.chengsw.tccdemo.bank1.spring"})
@ComponentScan({"top.chengsw.tccdemo.bank1","org.dromara.hmily"}) // "org.dromara.hmily"是固定的
public class Bank1HmilyServer {
public static void main(String[] args) {
SpringApplication.run(Bank1HmilyServer.class, args);
}
}
4.3.4 tcc-demo-bank1
tcc-demo-bank1實現try和cancel方法,如下:
try:
try冪等校驗
try懸掛處理
檢查餘額是夠扣減金額
扣減金額
confirm:
空
cancel:
cancel冪等校驗
cancel空回滾處理
增加可用餘額
1)Dao
@Mapper
@Component
public interface AccountInfoDao {
@Update("update account_info set account_balance=account_balance ‐ #{amount} where account_balance>#{amount} and account_no=#{accountNo} ")
int subtractAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
@Update("update account_info set account_balance=account_balance + #{amount} where account_no=#{accountNo} ")
int addAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
/**
* 增加某分支事務try執行記錄
* @param localTradeNo 本地事務編號
* @return
*/
@Insert("insert into local_try_log values(#{txNo},now());")
int addTry(String localTradeNo);
@Insert("insert into local_confirm_log values(#{txNo},now());")
int addConfirm(String localTradeNo);
@Insert("insert into local_cancel_log values(#{txNo},now());")
int addCancel(String localTradeNo);
/**
* 查詢分支事務try是否已執行
* @param localTradeNo 本地事務編號
* @return
*/
@Select("select count(1) from local_try_log where tx_no = #{txNo} ")
int isExistTry(String localTradeNo);
/**
* 查詢分支事務confirm是否已執行
* @param localTradeNo 本地事務編號
* @return
*/
@Select("select count(1) from local_confirm_log where tx_no = #{txNo} ")
int isExistConfirm(String localTradeNo);
/**
* 查詢分支事務cancel是否已執行
* @param localTradeNo 本地事務編號
* @return
*/
@Select("select count(1) from local_cancel_log where tx_no = #{txNo} ")
int isExistCancel(String localTradeNo);
}
2)try和cancel方法
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
private AccountInfoDao accountInfoDao;
@Autowired
private Bank2Client bank2Client;
@Override
@Transactional
@Hmily(confirmMethod = "commit", cancelMethod = "rollback")
public void updateAccountBalance(String accountNo, Double amount) {
//事務id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("******** Bank1 Service begin try... "+transId );
int existTry = accountInfoDao.isExistTry(transId);
//try冪等校驗
if(existTry>0){
log.info("******** Bank1 Service 已經執行try,無需重複執行,事務id:{} "+transId );
return ;
}
//try懸掛處理
if(accountInfoDao.isExistCancel(transId)>0 || accountInfoDao.isExistConfirm(transId)>0){
log.info("******** Bank1 Service 已經執行confirm或cancel,懸掛處理,事務id:{} "+transId);
return ;
}
//從賬戶扣減
if(accountInfoDao.subtractAccountBalance(accountNo ,amount )<= 0){
//扣減失敗
throw new HmilyRuntimeException("bank1 exception,扣減失敗,事務id:{}"+transId);
}
//增加本地事務try成功記錄,用於冪等性控制標識
accountInfoDao.addTry(transId);
//遠程調用bank2
if(!bank2Client.test2(amount,transId)){
throw new HmilyRuntimeException("bank2Client exception,事務id:{}"+transId);
}
if(amount == 10){//異常一定要拋在Hmily裏面
throw new RuntimeException("bank1 make exception 10");
}
log.info("******** Bank1 Service end try... "+transId );
}
@Transactional
public void commit( String accountNo, double amount) {
String localTradeNo = HmilyTransactionContextLocal.getInstance().get().getTransId();logger.info("******** Bank1 Service begin commit..."+localTradeNo );
}
@Transactional
public void rollback( String accountNo, double amount) {
String localTradeNo = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("******** Bank1 Service begin rollback... " +localTradeNo);
if(accountInfoDao.isExistTry(localTradeNo) == 0){ //空回滾處理,try階段沒有執行什麼也不用做
log.info("******** Bank1 try階段失敗... 無需rollback "+localTradeNo );
return;
}
if(accountInfoDao.isExistCancel(localTradeNo) > 0){ //冪等性校驗,已經執行過了,什麼也不用做
log.info("******** Bank1 已經執行過rollback... 無需再次rollback " +localTradeNo);
return;
}
//再將金額加回賬戶
accountInfoDao.addAccountBalance(accountNo,amount);
//添加cancel日誌,用於冪等性控制標識
accountInfoDao.addCancel(localTradeNo);
log.info("******** Bank1 Service end rollback... " +localTradeNo);
}
}
3)feignClient
@FeignClient(value = "seata‐demo‐bank2", fallback = Bank2Fallback.class)
public interface Bank2Client {
@GetMapping("/bank2/transfer")
@Hmily //SpringBoot和Hmily整合後,通過該註解,可以將張三的相關信息帶到李四
Boolean transfer(@RequestParam("amount") Double amount);
}
- Controller
@RestController
public class Bank1Controller {
@Autowired
AccountInfoService accountInfoService;
@RequestMapping("/transfer")
public String test(@RequestParam("amount") Double amount) {
this.accountInfoService.updateAccountBalance("1", amount);
return "cn/itcast/dtx/tccdemo/bank1" + amount;
}
}
4.3.5 tcc-demo-bank2
tcc-demo-bank2實現如下功能:
try:
空
confirm:
confirm冪等校驗
正式增加金額
cancel:
空
1)Dao
@Component
@Mapper
public interface AccountInfoDao {
@Update("update account_info set account_balance=account_balance + #{amount} where
account_no=#{accountNo} ")
int addAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);
/**
* 增加某分支事務try執行記錄
* @param localTradeNo 本地事務編號
* @return
*/
@Insert("insert into local_try_log values(#{txNo},now());")
int addTry(String localTradeNo);
@Insert("insert into local_confirm_log values(#{txNo},now());")
int addConfirm(String localTradeNo);
@Insert("insert into local_cancel_log values(#{txNo},now());")
int addCancel(String localTradeNo);
/**
* 查詢分支事務try是否已執行
* @param localTradeNo 本地事務編號
* @return
*/
@Select("select count(1) from local_try_log where tx_no = #{txNo} ")
int isExistTry(String localTradeNo);
/**
* 查詢分支事務confirm是否已執行
* @param localTradeNo 本地事務編號
* @return
*/
@Select("select count(1) from local_confirm_log where tx_no = #{txNo} ")
int isExistConfirm(String localTradeNo);/**
* 查詢分支事務cancel是否已執行
* @param localTradeNo 本地事務編號
* @return
*/
@Select("select count(1) from local_cancel_log where tx_no = #{txNo} ")
int isExistCancel(String localTradeNo);
}
2)實現confirm方法
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {
@Autowired
private AccountInfoDao accountInfoDao;
@Override
@Transactional
@Hmily(confirmMethod = "confirmMethod", cancelMethod = "cancelMethod")
public void updateAccountBalance(String accountNo, Double amount) {
String localTradeNo = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("******** Bank2 Service Begin try ..." + localTradeNo);
}
@Transactional
public void confirmMethod(String accountNo, Double amount) {
String localTradeNo = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("******** Bank2 Service commit... " + localTradeNo);
if (accountInfoDao.isExistConfirm(localTradeNo) > 0) { //冪等性校驗,已經執行過了,什麼也不用做
log.info("******** Bank2 已經執行過confirm... 無需再次confirm " + localTradeNo);
return;
}
//正式增加金額
accountInfoDao.addAccountBalance(accountNo, amount);
//添加confirm日誌
accountInfoDao.addConfirm(localTradeNo);
}
@Transactional
public void cancelMethod(String accountNo, Double amount) {
String localTradeNo = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("******** Bank2 Service begin cancel... " + localTradeNo);
}
}
3)Controller
@RestController
public class Bank2Controller {
@Autowired
AccountInfoService accountInfoService;
@RequestMapping("/transfer")
public Boolean test2(@RequestParam("amount") Double amount) {
this.accountInfoService.updateAccountBalance("2", amount);
return true;
}
}
4.3.6.小結
如果拿TCC事務的處理流程與2PC兩階段提交做比較,2PC通常都是在跨庫的DB層面,而TCC則在應用層面的處理,需要通過業務邏輯來實現。
這種分佈式事務的實現方式的優勢在於,可以讓應用自己定義數據操作的粒度,使得降低鎖衝突、提高吞吐量成爲可能。
而不足之處則在於對應用的侵入性非常強,業務邏輯的每個分支都需要實現try、confirm、cancel三個操作。此外,其實現難度也比較大,需要按照網絡狀態、系統故障等不同的失敗原因實現不同的回滾策略。