在服務端應用程序中,我們往往會通過事務處理來保證數據一致性(Data Consistency),例如:當用戶從庫存中取走了一定數量的物品,這些物品會體現在用戶的提貨單上,與此同時,庫存中物品的數量也應該減少。如果在這個過程中無法保證數據的一致性,那么就有可能出現用戶沒有成功取走物品,而庫存中的物品數量卻減少了;或者用戶成功取走了物品,而庫存中的物品數量卻沒有變化。前者導致物品總量比實際少了一些,而后者又導致物品總量比實際多了,這樣的問題就是數據一致性問題。為了保證應用程序不會出現這類問題,我們通常會使用數據庫事務。單說數據庫事務就有很多相關的知識,但這都不是這里打算深入討論的內容。我們會著重介紹一下微服務架構下跨服務事務的實現以及與之相關的Saga體系結構模式,不過在此之前,還是有必要回顧一下事務處理的一些解決方案。
本地事務
本地事務通常會在同一個資源管理器(Resource Manager)上完成,最為常見的例子就是在同一個數據庫上操作多張數據表,在這些操作完成之后,數據表的變更同時成功或者同時失敗。例如,下面的C#代碼會在一個本地事務中同時更新兩張數據表:
|
代碼中SqlTransaction能夠保證,對于Users表和Inventory表的更新要么同時成功,要么同時失敗。這個本地事務是在SQL Server的資源管理器上執行,因此,本地事務的效率是比較高的。
在微服務架構中,我們往往會選擇Database-per-Service的設計,這樣做的好處是能夠獲得比較好的數據隔離性,而且不同的服務可以根據本身的特點選擇不同的數據存儲方案。因此,就單個服務而言,實現本地事務是比較容易的事情,它能夠很好地滿足服務本身的業務需求,也能夠很好地保證數據的一致性。然而很明顯,本地事務無法保證跨服務的數據一致性。
分布式事務
分布式事務往往會橫跨多個資源管理器(Resource Manager,RM),并由分布式事務協調器(Distributed Transaction Coordinator,DTC)負責事務協調。分布式事務通常基于兩段提交協議(Two-phase Commit, 2PC)實現:事務提交分兩個階段進行,在第一階段(準備階段)中,2PC協議需要確保DTC已經獲得了所有來自RM的提交反饋信息,對于每個RM,DTC都需要明確知道它是否可以成功完成其本地事務,或者無法完成。就RM而言,在這一階段會嘗試提交其本地事務,如果能夠成功提交,則向DTC報告“可以提交”的狀態,否則報告“無法提交”的狀態。DTC在收集了所有參與者RM的狀態后,如果全部為“可以提交”,則啟動第二階段(提交階段),通知所有RM完成正式提交;但只要有一個RM報告“無法提交”,則DTC會通知其它的RM取消提交操作。
一個成功的2PC提交的過程大致可以用下面的順序圖來表示:
此外,三段提交協議(Three-phase commit, 3PC)也是實現分布式事務的一種模式,與2PC相比,3PC主要是為了解決DTC或者RM出現故障的情形,它將2PC中的第一階段(準備階段)進行了細分,將RM分為了Awaiting和pre-commit兩種狀態。總的來說,3PC和2PC過程大致相同,可以參考這篇文章進一步了解,在此就不多說了。
從 2.0版本開始,.NET Framework引入了一個非常方便的類:TransactionScope,這個類能夠輔助完成分布式事務處理,比如,下面的偽代碼能夠實現跨SQL Server服務器的事務處理:
|
然而,獲得如此的便捷需要付出一定的成本:首先,跨數據庫的事務處理效率是非常低的;其次,不是所有的數據庫驅動都能夠支持TransactionScope,并且,上面的代碼需要執行成功,就需要確保Windows操作系統中的Distributed Transaction Coordinator服務是處于啟動狀態,而且應用程序與該服務間的通訊不能中斷。
圖:Microsoft Distributed Transaction Coordinator服務
然而在微服務架構中,無法通過分布式事務來保證數據的一致性,原因大致有如下幾個方面:
2PC/3PC協議是阻塞式協議,其本身的特點使得分布式事務協調器成為了整個體系中的單點,一旦DTC發生錯誤,容易導致RM長期處于等待狀態,資源得不到釋放,從而造成服務不可用。或者更進一步,如果在提交階段,其中某個服務真的提交失敗了,那又如何維持各服務間狀態的一致性?解決這樣的問題并不是不可能,但是成本會比較大,而且,這還不是微服務架構下實現2PC/3PC協議的唯一弊端
基于2PC/3PC協議的分布式事務處理比較低效,由于它是阻塞式的,所以服務本身需要完成提交或者回滾之后才能繼續處理其它的事務,在微服務環境中,一次這樣的阻塞可能會影響到很多的服務實例
不是所有的數據庫或者基礎結構設施都能夠支持2PC/3PC協議,應該說絕大部分不支持。我所用過的可以支持分布式事務的基礎結構設施包括Microsoft SQL Server數據庫、Oracle數據庫以及微軟的MSMQ,因此,在技術選型上是有一定限制的:如果你的微服務所采用的數據存儲/數據傳輸技術不是這些,那么就很難實現分布式事務。此外,分布式事務協調器本身也有很多限制,比如上面提到的MSDTC就只能在Windows Server上運行
這種分布式事務處理方式打破了微服務架構的設計原則:Database-per-service的設計要求微服務之間不能互相訪問對方的數據庫,而DTC的存在,使得數據庫實現細節不得不被暴露出來,否則DTC無法完成跨數據庫的事務協調
Saga模式:跨服務的事務處理
在微服務架構中,事務處理往往會橫跨多個服務,這就難免會需要依賴于服務間的通信機制。微服務間的通信分同步和異步,而異步通信才是更為推薦的方案:在設計微服務架構時,應該盡可能地選擇異步方式來實現服務間通信,這樣才能更好地實現服務間解耦。就事務處理而言,由于它并不是一個瞬時操作,而是一個長時運行的任務(long-running process),因此更適合采用異步方式來完成。Saga模式所解決的問題就是這種基于異步消息機制的跨服務事務處理,它的基本過程是,每個微服務自己處理本地事務,然后根據處理結果向消息隊列派發消息以便整個事務能夠進行下一步的處理,與此同時,微服務還會偵聽來自于其它微服務的消息,來決定自己是否需要進行事務補償(Compensation)。當一個Saga事務被啟動后,它會一步一步地執行其中的每一個步驟(Saga Step)。在每一個步驟中,有且僅有一個微服務實例的參與者負責其本地事務的執行,當所有的步驟全部成功完成后,Saga事務也就成功提交了。當然,如果其中有某個步驟執行失敗,那么之前成功的本地事務就應該回滾,否則無法保證數據一致性。由于此刻已成功的本地事務已經無法回滾,所以,在Saga模式中,一般都會通過補償操作來實現本地事務回滾的效果。整個流程大致可以使用下面的流程圖表示:
一般會有兩種方式來實現Saga模式:編排式和協調式。
編排式(Choreography)
編排式Saga實現中,會將每個步驟分散到各個微服務中,通過事件消息的相關性將這些Saga步驟串聯起來,“編排”出一個Saga事務處理流程。例如:購物車微服務發出一個“創建銷售訂單”的消息,訂單微服務偵聽到這個消息并創建銷售訂單,庫存微服務則在偵聽到這個消息后,檢查庫存狀態,如果庫存不足,則發出一個“庫存狀態檢查失敗”的消息,然后訂單微服務獲得這個消息后,執行一個補償事務,將訂單狀態標記為“失效”,購物車微服務則執行它的補償事務,將當前購物車中的信息恢復到創建訂單之前的樣子。整個過程中,微服務之間互通信息,沒有第三方的組件參與協調它們之間的協作關系,因此,這種實現方式被稱作“編排式”。下面的示意圖表示了這種編排式Saga的實現:
編排式實現有如下優點:
實現相對比較簡單,尤其是當涉及的微服務個數并不多的時候,編排式實現比較簡單明了
由于不需要依賴于第三方組件進行協調,所以不存在額外的部署和維護成本
但也有一些缺點:
當事務過程比較復雜時,往往一個Saga事務會包括多個步驟,編排式實現會使得整個事務處理過程變得錯綜復雜難于理解和維護
由于沒有協調組件,消息來來回回容易造成混亂,甚至出現消息間互相影響循環處理的情況(比如A發消息給B,B處理完消息后又發消息給A,如此反復不斷)
測試和排錯變得復雜:你需要啟動所有的微服務才能夠調試或者測試某個Saga步驟
協調式(Orchestration)
協調式Saga實現中,會有一個協調器的角色來負責協調Saga的每一個步驟,協調器與各微服務之間也是通過消息隊列進行通信,因此,它也是基于異步消息機制的。下面的示意圖表示了協調式Saga的實現:
協調式Saga實現有如下這些優點:
對于過程比較復雜的Saga事務,協調式比編排式的實現更加清晰,不會出現消息混亂的情況
從單個微服務的角度,它無需關心在自己參與了Saga事務之后,應該如何協作以便Saga事務能夠繼續往下走,它只需要對自己所處理的Saga事件(Saga Events)完成應答即可,因此,協調式Saga能夠更好地實現微服務的關注點分離(Separation of Concerns)
對于Saga事務流的控制更加簡單,對于消息的收發和處理的調試也相對比較容易
當然,也有缺點:
由于需要額外引入一個協調器,所以結構上要比編排式更為復雜
對于編排式與協調式的優缺點,也有一些觀點認為,協調式中的協調器部分會有單點失敗的可能性,其實在微服務的體系中,如果設計上在這部分多加考慮,是可以避免這樣的問題的,例如,可以利用消息隊列的機制,保證Saga應答事件被、且僅被處理一次,那么,即使有多個協調器實例在運行,也能夠保證Saga能夠正確執行,在這種情況下,單個協調器發生故障無法正常工作也不會影響整個Saga事務的處理。
相比之下,我更傾向采用協調式的實現,一方面它能夠分離關注點,使得Saga模式的實現變得更為優雅;另一方面,比較容易從實現中抽取出一套特定的框架,進而重用于不同的項目中。
接下來,我們通過一個簡單的案例,來了解一下整個Saga體系的設計和實現。
案例:訂單業務下Saga的簡單實現
我們選擇購物網站的下訂單的流程來介紹Saga的實現。為了簡化問題,我們將下訂單的流程進行簡化,并且省去了很多額外的業務處理部分(比如客戶賬戶可用額度應該屬于客戶會員管理微服務,并且額度的增加和扣除都有一定的業務邏輯,這里我們就簡單地將它歸為客戶信息微服務了),因此,不要太過糾結這樣的業務流程是否合理。現假設有這樣的業務場景:
客戶通過網站的購物車系統下訂單(Sales Order)
購物車微服務啟動下訂單的流程,在這個過程中:
首先,會通知訂單微服務,需要創建一個訂單,此時訂單狀態為Created
然后,客戶信息微服務校驗當前客戶賬戶是否合法
接下來,客戶信息微服務預留(扣除)客戶賬戶的額度
最后,庫存微服務預留(扣除)訂單中商品的數量
這個過程中任何一步發生錯誤,都需要通知已執行的步驟,以便回滾已經更改的數據,保證數據一致性。比如,在預留客戶賬戶額度的時候如果失敗,則需要將訂單狀態置為Aborted,表示該訂單因某些原因不得不取消
下面,我們基于這樣的業務場景,簡單地做些設計與實現。
總體設計
通過簡單的分析,可以得知:
整個事務的完成需要涉及4個不同的微服務:購物車微服務、訂單微服務、客戶信息微服務和庫存微服務
在不同的Saga事務步驟中,有些微服務的操作是有對應的補償操作的,目的是為了在Saga事務執行失敗時,能夠將其本地數據變更回滾到變更之前的狀態;而有些微服務的操作是無需補償操作的,比如校驗客戶賬戶是否合法
每個Saga事務步驟都會有這幾個狀態:等待執行(Awaiting)、正在執行(Started)、成功執行(Succeeded)、執行失敗(Failed)、正在補償(Compensating)、補償完成(Compensated)以及取消(Cancelled)
依據每個Saga事務步驟的不同狀態,Saga本身也是有狀態的:已創建(Created)、正在執行(Started)、正在撤銷(Aborting)、已經撤銷(Aborted)和成功完成(Completed)
基于這樣的分析,可以得到下面的設計指導:
一個Saga事務(下面簡稱Saga)由若干個Saga事務步驟組成
Saga是有狀態的,它的各個步驟也是,因此,Saga是需要被持久化的
Saga的管理以及Saga事件的處理都需要有一個管理者負責協調,稱之為Saga Manager
Saga與各個微服務之間采用異步消息進行通信
于是,相關的UML類圖大致如下:
有幾點大致說明一下:
IDataAccessObject是一個數據訪問的接口,它提供了對某種數據庫中的數據進行增刪改查的功能。在我們的案例中,采用MongoDB的實現
IEventPublisher是一個事件派發接口,它可以向消息隊列派發消息。在我們的案例中,采用RabbitMQ的實現
Saga Manager會使用IDataAccessObject實例來管理Saga的生命周期,也會使用IEventPublisher實例來派發Saga消息
當SagaEventHandler接收到來自各個微服務的響應事件時,它會通過Saga Manager讀取對應的Saga,然后在Saga上進行狀態轉換,從而觸發下一個Saga步驟(或者上一個Saga步驟的補償事務)的執行
Saga維護本身及其各個步驟的狀態,每個Saga步驟會有兩個待實現的抽象方法,用來返回事務消息的類型,以及補償事務消息的類型
執行過程可以用下面的UML順序圖來表示:
上面的順序圖僅展示了一次由Saga事件觸發的狀態轉換過程,在這個過程中,難點就是Saga對象在得到當前Saga事件時,是如何完成狀態轉換,并產生下一步驟所對應的Saga事件的。下面就進行一些簡單的介紹。
詳細設計:Saga事件處理與狀態轉換
在SagaManager創建了Saga之后,會調用StartAsync方法,將第一個Saga Step的Saga事件發送到消息隊列,之后,SagaManager會偵聽自己的消息隊列以便獲得來自不同微服務的處理反饋消息。在這個Saga反饋消息事件的處理邏輯中,事件處理器會根據反饋消息事件中所附帶的SagaId,通過SagaManager讀取Saga實例,然后執行狀態轉換。例如,下面的代碼就是在Saga反饋消息事件處理邏輯中,完成了Saga的狀態轉換:
|
在TransitAsync方法中,Saga通過對已接收到的反饋消息進行狀態轉換,并發出下一個Saga Step的消息:
|
Saga的ProcessEvent的流程大致如下:
在上面的流程圖中:
藍色框和紅色框中表示在這個節點上,會獲得下一個Saga步驟或者上一個Saga步驟所產生的Saga事件,這個事件會被接下來的處理邏輯發送到消息隊列中
藍色框表示,當前Step已經執行成功,Saga的狀態會轉換到下一個步驟進行執行,并讀取下一個步驟的Saga事件
紅色框表示,當前Step已經執行成功,Saga的狀態會轉換到上一個步驟進行執行,并讀取上一個步驟的補償事件
灰色虛線框流程并不是ProcessEvent方法的主要職責,但為了保持流程的完整性,我將這部分用虛線補上
此外,值得一提的是,并不是所有的步驟都需要有補償操作,比如,對于“客戶信息微服務校驗當前客戶賬戶是否合法”這個步驟,如果其后續某個步驟失敗,那么該步驟并不需要進行補償,因為它本身沒有產生任何領域對象狀態的變更。對于這種情況,紅色框中“回退到上一個Step”的操作就需要依次迭代之前的每個步驟,找到需要進行補償的步驟為止。
就我們的例子而言,各個Saga步驟的定義如下:
對于“補償事件類型”這一欄不為空的Saga步驟,它需要進行事務補償,因此,當Saga執行失敗并進行回溯時,需要在這些步驟上獲取補償事件并發送給對應的微服務,以完成事務補償。
實現效果
假設我們只允許客戶最多預留1000元的賬戶額度,并且只能預留庫存中不多于5000個商品,那么如果創建訂單時,請求方給的參數沒有超出這個范圍,那么所有的微服務都應該應答“成功”消息,也就是對應的事件類型也應該為“成功”:
假設我們請求的庫存預留數大于5000:
可以看到:當reserve-inventory事件的應答為false時,產生了兩個補償事件:compensate-reserve-credit和compensate-create-sales-order,并且這兩個補償事件的處理應答都為true:
查詢數據庫狀態,根據SagaStatus和SagaStepStatus兩個枚舉的值,Saga的狀態為Aborted:
有關Saga的大致設計和實現就先介紹這些吧,其實內容還有很多,可以參考本文的案例代碼:
https://github.com/daxnet/byteart-retail-msa
Saga框架相關的代碼在此:
https://github.com/daxnet/byteart-retail-msa/tree/main/src/ByteartRetail.TestClients.Common/Sagas
CQRS中的Saga
順便提一句,在CQRS體系結構模式中,Saga的實現有其自己的“職責”:
Command接收命令,發出領域事件
Saga接收領域事件,發出命令
如何理解?
Command部分在接收到來自客戶端的命令后,會操作領域模型對象,領域模型對象發生狀態變化的結果,就是發出領域事件
Saga在接收到領域事件之后,產生自身的狀態轉換,當達到某個狀態時,又發出命令,從而影響領域模型
更多思考
實現Saga事務其實并不是那么容易,還有很多需要思考的內容,本文也沒法全部涵蓋,例如:
一個更為優雅的設計是使用有限狀態機,使用Saga事件作為觸發器,來完成Saga的狀態轉換,例如,使用MassTransit的Automatonymous框架
如何(或者是否需要)保證消息派發的順序。在RabbitMQ中,發送到同一個Exchange,并由同一個RoutingKey指定的派發路由上的消息,可以保證其順序性;再比如,Apache Kafka是可以保證消息的派發和接收順序的。但是這些框架無法保證應用程序本身是按照消息接收的順序進行處理。所以,微服務需要保證消息處理的冪等性
如何保證消息不會被遺漏,也就是如何保證消息至少被處理一次。可以采用Listen To Yourself模式(參考我之前的文章《ASP.NET Core Web API下事件驅動型架構的實現(五):在微服務中使用自我監聽模式保證數據庫更新與消息派發的可靠性》),也可以在微服務內部使用消息存儲,確保未被派發消息不會丟失
如何保證補償事務能夠真正實現“補償”。如果補償不成功,也會導致數據不一致,無法實現最終一致性。可以在微服務處理補償事務的時候,使用類似Retry或者熔斷這樣的機制,通過反復嘗試來強制補償成功。如果最終仍然不成功,則需要記錄下來,等待后續手工補償,比如,通過郵件通知的方式,由管理員進行處理
多個SagaManager實例同時運行時,如何保證Saga能被正確處理。通常可以讓多個運行SagaManager的微服務實例同時偵聽同一個消息隊列,以便能夠以輪詢的形式處理反饋消息
以后有機會再慢慢分析吧。
總結
本文首先介紹了本地事務以及分布式事務,并通過微服務架構引入Saga體系結構模式,以實現跨服務的事務處理。然后通過一個簡單的業務案例,介紹了Saga體系結構模式的整體設計和簡單實現,并列舉了一些遺留問題可供進一步思考和討論。Saga的實現方式并非本文介紹的這一種,但本文介紹的方式還是相對比較簡單易懂的。整個Saga的設計體系是可以抽象成一套開發框架的,以便隔離狀態轉換和事件派發的復雜度,讓開發者更多地關注到業務實現上來。