為什么說傳統(tǒng)分布式事務不再適用于微服務架構?
傳統(tǒng)應用使用本地事務和分布式事務保證數據一致性,但是在微服務架構中數據都是服務私有的,需要通過服務提供的API來訪問,所以分布式事務不再適用微服務架構。那么微服務架構又該如何保證數據一致性呢?本文就來談談這個話題。
- 傳統(tǒng)分布式事務不是微服務中數據一致性的***選擇
- 微服務架構中應滿足數據最終一致性原則
- 微服務架構實現最終一致性的三種模式
- 對賬是***的***防線
傳統(tǒng)分布式事務
我們先來看下***部分,傳統(tǒng)使用本地事務和分布式事務保證一致性。
傳統(tǒng)單機應用一般都會使用一個關系型數據庫,好處是應用可以使用ACID。為保證一致性我們只需要:開始一個事務,改變(插入,刪除,更新)很多行,然后提交事務(如果有異常時回滾事務)。更進一步,借助開發(fā)平臺中的數據訪問技術和框架(如 Spring),我們需要做的事情更少,只需要關注數據本身的改變。
隨著組織規(guī)模不斷擴大,業(yè)務量不斷增長,單機應用和數據庫已經不足以支持龐大的業(yè)務量和數據量,這個時候需要對應用和數據庫進行拆分,這就出現了一個應用需要同時訪問兩個或兩個以上的數據庫情況。開始我們用分布式事務來保證一致性,也就是我們常說的兩階段提交協(xié)議(2PC)。
本地事務和分布式事務現在已經非常成熟,相關介紹很豐富,此處不再討論。我們下面來談談為什么分布式事務不適用于微服務架構。
首先,對于微服務架構來說,數據訪問變得更加復雜,這是因為數據都是微服務私有的,唯一可訪問的方式就是通過 API。這種打包數據訪問方式使得微服務之間松耦合,并且彼此之間獨立,更容易進行性能擴展。
其次,不同的微服務經常使用不同的數據庫。應用會產生各種不同類型的數據,關系型數據庫并不一定是***選擇。
例如,某個產生和查詢字符串的應用采用 Elasticsearch 的字符搜索引擎;某個產生社交圖片數據的應用可以采用圖數據庫,例如Neo4j。
基于微服務的應用一般都使用 SQL 和 NoSQL 結合的模式。但是這些非關系型數據大多數并不支持 2PC。
可見在微服務架構中已經不能選擇分布式事務了。
最終一致性原則
依據 CAP 理論,必須在可用性(availability)和一致性(consistency)之間做出選擇。如果選擇提供一致性需要付出在滿足一致性之前阻塞其他并發(fā)訪問的代價。這可能持續(xù)一個不確定的時間,尤其是在系統(tǒng)已經表現出高延遲時或者網絡故障導致失去連接時。
依據目前的成功經驗,可用性一般是更好的選擇,但是在服務和數據庫之間維護數據一致性是非常根本的需求,微服務架構中應選擇滿足最終一致性。
最終一致性是指系統(tǒng)中的所有數據副本經過一定時間后,最終能夠達到一致的狀態(tài)。
當然選擇了最終一致性,就要保證到最終的這段時間要在用戶可接受的范圍之內。那么我們怎么實現最終一致性呢?
從一致性的本質來看,是要保證在一個業(yè)務邏輯中包含的服務要么都成功,要么都失敗。那我們怎么選擇方向呢?保證成功還是保證失敗呢?
我們說業(yè)務模式決定了我們的選擇。實現最終一致性有三種模式:可靠事件模式、業(yè)務補償模式、TCC 模式。
可靠事件模式
可靠事件模式屬于事件驅動架構,當某件重要事情發(fā)生時,例如更新一個業(yè)務實體,微服務會向消息代理發(fā)布一個事件。消息代理會向訂閱事件的微服務推送事件,當訂閱這些事件的微服務接收此事件時,就可以完成自己的業(yè)務,也可能會引發(fā)更多的事件發(fā)布。
1. 如訂單服務創(chuàng)建一個待支付的訂單,發(fā)布一個“創(chuàng)建訂單”的事件。
2. 支付服務消費“創(chuàng)建訂單”事件,支付完成后發(fā)布一個“支付完成”事件。
3. 訂單服務消費“支付完成”事件,訂單狀態(tài)更新為待出庫。
從而就實現了完成的業(yè)務流程。但是這并不是一個***的流程。
這個過程可能導致出現不一致的地方在于:某個微服務在更新了業(yè)務實體后發(fā)布事件卻失敗;雖然微服務發(fā)布事件成功,但是消息代理未能正確推送事件到訂閱的微服務;接受事件的微服務重復消費了事件。
可靠事件模式在于保證可靠事件投遞和避免重復消費,可靠事件投遞定義為:
- 每個服務原子性的業(yè)務操作和發(fā)布事件。
- 消息代理確保事件傳遞至少一次。
避免重復消費要求服務實現冪等性,如支付服務不能因為重復收到事件而多次支付。
因為現在流行的消息隊列都實現了事件的持久化和 at least once 的投遞模式,『消息代理確保事件投遞至少一次』已經滿足,今天不做展開。
下面分享的內容主要從可靠事件投遞和實現冪等性兩方面來討論,我們先來看可靠事件投遞。
首先我們來看一個實現的代碼片段,這是從某生產系統(tǒng)上截取下來的。
根據上述代碼及注釋,初看可能出現 3 種情況:
- 操作數據庫成功,向消息代理投遞事件也成功。
- 操作數據庫失敗,不會向消息代理中投遞事件了。
- 操作數據庫成功,但是向消息代理中投遞事件時失敗,向外拋出了異常,剛剛執(zhí)行的更新數據庫的操作將被回滾。
從上面分析的幾種情況來看,貌似沒有問題。但是仔細分析不難發(fā)現缺陷所在,在上面的處理過程中存在一段隱患時間窗口。
微服務 A 投遞事件的時候可能消息代理已經處理成功,但是返回響應的時候網絡異常,導致 append 操作拋出異常。最終結果是事件被投遞,數據庫卻被回滾。
在投遞完成后到數據庫 commit 操作之間如果微服務 A 宕機也將造成數據庫操作因為連接異常關閉而被回滾。最終結果還是事件被投遞,數據庫卻被回滾。這個實現往往運行很長時間都沒有出過問題,但是一旦出現了將會讓人感覺莫名,很難發(fā)現問題所在。
下面給出兩種可靠事件投遞的實現方式。
1. 本地事件表
本地事件表方法將事件和業(yè)務數據保存在同一個數據庫中,使用一個額外的“事件恢復”服務來恢復事件,由本地事務保證更新業(yè)務和發(fā)布事件的原子性。考慮到事件恢復可能會有一定的延時,服務在完成本地事務后可立即向消息代理發(fā)布一個事件。
- 微服務在同一個本地事務中記錄業(yè)務數據和事件。
- 微服務實時發(fā)布一個事件立即通知關聯的業(yè)務服務,如果事件發(fā)布成功立即刪除記錄的事件。
- 事件恢復服務定時從事件表中恢復未發(fā)布成功的事件,重新發(fā)布,重新發(fā)布成功才刪除記錄的事件。
其中第Ⅱ條的操作主要是為了增加發(fā)布事件的實時性,由第三條保證事件一定被發(fā)布。
本地事件表方式業(yè)務系統(tǒng)和事件系統(tǒng)耦合比較緊密,額外的事件數據庫操作也會給數據庫帶來額外的壓力,可能成為瓶頸。
2. 外部事件表
外部事件表方法將事件持久化到外部的事件系統(tǒng),事件系統(tǒng)需提供實時事件服務以接受微服務發(fā)布事件,同時事件系統(tǒng)還需要提供事件恢復服務來確認和恢復事件。
- 業(yè)務服務在事務提交前,通過實時事件服務向事件系統(tǒng)請求發(fā)送事件,事件系統(tǒng)只記錄事件并不真正發(fā)送。
- 業(yè)務服務在提交后,通過實時事件服務向事件系統(tǒng)確認發(fā)送,事件得到確認后事件系統(tǒng)才真正發(fā)布事件到消息代理。
- 業(yè)務服務在業(yè)務回滾時,通過實時事件向事件系統(tǒng)取消事件。
- 如果業(yè)務服務在發(fā)送確認或取消之前停止服務了怎么辦呢?事件系統(tǒng)的事件恢復服務會定期找到未確認發(fā)送的事件向業(yè)務服務查詢狀態(tài),根據業(yè)務服務返回的狀態(tài)決定事件是要發(fā)布還是取消。
該方式將業(yè)務系統(tǒng)和事件系統(tǒng)獨立解耦,都可以獨立伸縮。但是這種方式需要一次額外的發(fā)送操作,并且需要發(fā)布者提供額外的查詢接口。
介紹完了可靠事件投遞再來說一說冪等性的實現,有些事件本身是冪等的,有些事件卻不是。
本身具有冪等性的事件需要考慮執(zhí)行順序
如果事件本身描述的是某個時間點的固定值(如賬戶余額為 100),而不是描述一條轉換指令(如余額增加 10),那么這個事件是冪等的。
我們要意識到事件可能出現的次數和順序是不可預測的,需要保證冪等事件的順序執(zhí)行,否則結果往往不是我們想要的。
如果我們先后收到兩條事件,(1)賬戶余額更新為100,(2)賬戶余額更新為120。
1. 微服務收到事件(1)
2. 微服務收到事件(2)
3. 微服務再次收到事件(1)
顯然結果是錯誤的,所以我們需要保證事件(2)一旦執(zhí)行事件(1)就不能再處理,否則賬戶余額仍不是我們想要的結果。
為保證事件的順序一個簡單的做法是在事件中添加時間戳,微服務記錄每類型的事件***處理的時間戳,如果收到的事件的時間戳早于我們記錄的,丟棄該事件。如果事件不是在同一個服務器上發(fā)出的,那么服務器之間的時間同步是個難題,更穩(wěn)妥的做法是使用一個全局遞增序列號替換時間戳。
對于本身不具有冪等性的操作,主要思想是為每條事件存儲執(zhí)行結果,當收到一條事件時我們需要根據事件的 ID 查詢該事件是否已經執(zhí)行過,如果執(zhí)行過直接返回上一次的執(zhí)行結果,否則調度執(zhí)行事件。
在這個思想下我們需要考慮重復執(zhí)行一條事件和查詢存儲結果的開銷。
重復處理開銷小的事件
如果重復處理一條事件開銷很小,或者可預見只有非常少的事件會被重復接收,可以選擇重復處理一次事件,在將事件數據持久化時由數據庫拋出唯一性約束異常。
重復處理開銷大事件使用事件存儲過濾重復事件
如果重復處理一條事件的開銷相比額外一次查詢的開銷要高很多,使用一個過濾服務來過濾重復的事件,過濾服務使用事件存儲存儲已經處理過的事件和結果。
當收到一條事件時,過濾服務首先查詢事件存儲,確定該條事件是否已經被處理過,如果事件已經被處理過,直接返回存儲的結果;否則調度業(yè)務服務執(zhí)行處理,并將處理完的結果存儲到事件存儲中。
一般情況下上面的方法能夠運行得很好,如果我們的微服務是 RPC 類的服務我們需要更加小心,可能出現的問題在于,(1)過濾服務在業(yè)務處理完成后才將事件結果存儲到事件存儲中,但是在業(yè)務處理完成前有可能就已經收到重復事件,由于是 RPC 服務也不能依賴數據庫的唯一性約束;(2)業(yè)務服務的處理結果可能出現位置狀態(tài),一般出現在正常提交請求但是沒有收到響應的時候。
對于問題(1)可以按步驟記錄事件處理過程,比如事件的記錄事件的處理過程為“接收”、“發(fā)送請求”、“收到應答”、“處理完成”。好處是過濾服務能及時的發(fā)現重復事件,進一步還能根據事件狀態(tài)作不同的處理。
對于問題(2)可以通過一次額外的查詢請求來確定事件的實際處理狀態(tài),要注意額外的查詢會帶來更長時間的延時,更進一步可能某些 RPC 服務根本不提供查詢接口。此時只能選擇接收暫時的不一致,時候采用對賬和人工接入的方式來保證一致性。
補償模式
為了描述方便,這里先定義兩個概念:
- 業(yè)務異常:業(yè)務邏輯產生錯誤的情況,比如賬戶余額不足、商品庫存不足等。
- 技術異常:非業(yè)務邏輯產生的異常,如網絡連接異常、網絡超時等。
補償模式使用一個額外的協(xié)調服務來協(xié)調各個需要保證一致性的微服務,協(xié)調服務按順序調用各個微服務,如果某個微服務調用異常(包括業(yè)務異常和技術異常)就取消之前所有已經調用成功的微服務。
補償模式建議僅用于不能避免出現業(yè)務異常的情況,如果有可能應該優(yōu)化業(yè)務模式,以避免要求補償事務。如賬戶余額不足的業(yè)務異常可通過預先凍結金額的方式避免,商品庫存不足可要求商家準備額外的庫存等。
我們通過一個實例來說明補償模式,一家旅行公司提供預訂行程的業(yè)務,可以通過公司的網站提前預訂飛機票、火車票、酒店等。
假設一位客戶規(guī)劃的行程是:
上海-北京6月19日9點的某某航班。
某某酒店住宿3晚。
北京-上海6月22日17點火車。
在客戶提交行程后,旅行公司的預訂行程業(yè)務按順序串行的調用航班預訂服務、酒店預訂服務、火車預訂服務。***的火車預訂服務成功后整個預訂業(yè)務才算完成。
如果火車票預訂服務沒有調用成功,那么之前預訂的航班、酒店都得取消。取消之前預訂的酒店、航班即為補償過程。
為了降低開發(fā)的復雜性和提高效率,協(xié)調服務實現為一個通用的補償框架。補償框架提供服務編排和自動完成補償的能力。
要實現補償過程,我們需要做到兩點:
首先要確定失敗的步驟和狀態(tài),從而確定需要補償的范圍。
在上面的例子中我們不僅要知道第 3 個步驟(預訂火車)失敗,還要知道失敗的原因。如果是因為預訂火車服務返回無票,那么補償過程只需要取消前兩個步驟就可以了;但是如果失敗的原因是因為網絡超時,那么補償過程除前兩個步驟之外還需要包括第 3 個步驟。
其次要能提供補償操作使用到的業(yè)務數據。
比如一個支付微服務的補償操作要求參數包括支付時的業(yè)務流水 id、賬號和金額。理論上說實際完成補償操作可以根據唯一的業(yè)務流水 id 就可以,但是提供更多的要素有益于微服務的健壯性,微服務在收到補償操作的時候可以做業(yè)務的檢查,比如檢查賬戶是否相等,金額是否一致等等。
實現補償模式的關鍵在于業(yè)務流水的記錄
做到上面兩點的辦法是記錄完整的業(yè)務流水,可以通過業(yè)務流水的狀態(tài)來確定需要補償的步驟,同時業(yè)務流水為補償操作提供需要的業(yè)務數據。
當客戶的一個預訂請求達到時,協(xié)調服務(補償框架)為請求生成一個全局唯一的業(yè)務流水號,并在調用各個工作服務的同時記錄完整的狀態(tài)。
- 記錄調用 bookFlight 的業(yè)務流水,調用 bookFlight 服務,更新業(yè)務流水狀態(tài)。
- 記錄調用 bookHotel 的業(yè)務流水,調用 bookHotel 服務,更新業(yè)務流水狀態(tài)。
- 記錄調用 bookTrain 的業(yè)務流水,調用 bookTrain 服務,更新業(yè)務流水狀態(tài)。
當調用某個服務出現異常時,比如第 3 步驟(預訂火車)異常。
協(xié)調服務(補償框架)同樣會記錄第 3 步的狀態(tài),同時會另外記錄一條事件,說明業(yè)務出現了異常。然后就是執(zhí)行補償過程了,可以從業(yè)務流水的狀態(tài)中知道補償的范圍,補償過程中需要的業(yè)務數據從記錄的業(yè)務流水中獲取。
對于一個通用的補償框架來說,預先知道微服務需要記錄的業(yè)務要素是不可能的。那么就需要一種方法來保證業(yè)務流水的可擴展性,這里介紹兩種方法:大表和關聯表。
大表顧明思議就是設計時除必須的字段外,還需要預留大量的備用字段,框架可以提供輔助工具來幫助將業(yè)務數據映射到備用字段中。
關聯表,分為框架表和業(yè)務表,技術表中保存為實現補償操作所需要的技術數據,業(yè)務表保存業(yè)務數據,通過在技術表中增加業(yè)務表名和業(yè)務表主鍵來建立和業(yè)務數據的關聯。
大表對于框架層實現起來簡單,但是也有一些難點,比如預留多少字段合適,每個字段又需要預留多少長度。另外一個難點是如果向從數據層面來查詢數據,很難看出備用字段的業(yè)務含義,維護過程不友好。
關聯表在業(yè)務要素上更靈活,能支持不同的業(yè)務類型記錄不同的業(yè)務要素;但是對于框架實現上難度更高,另外每次查詢都需要復雜的關聯動作,性能方面會受影響。
有了上面的完整的流水記錄,協(xié)調服務就可以根據工作服務的狀態(tài)在異常時完成補償過程。但是補償由于網絡等原因,補償操作并不一定能保證 100%成功,這時候我們還要做更多一點。
通過重試保證補償過程的完整,從而滿足最終一致性
補償過程作為一個服務調用過程同樣存在調用不成功的情況,這個時候需要通過重試的機制來保證補償的成功率。當然這也就要求補償操作本身具備冪等性。
關于冪等性的實現在前面做過討論。
如果只是一味的失敗就立即重試會給工作服務造成不必要的壓力,我們要根據服務執(zhí)行失敗的原因來選擇不同的重試策略。
1) 如果失敗的原因不是暫時性的,由于業(yè)務因素導致(如業(yè)務要素檢查失敗)的業(yè)務錯誤,這類錯誤是不會重發(fā)就能自動恢復的,那么應該立即終止重試。
2) 如果錯誤的原因是一些罕見的異常,比如因為網絡傳輸過程出現數據丟失或者錯誤,應該立即再次重試,因為類似的錯誤一般很少會再次發(fā)生。
3) 如果錯誤的原因是系統(tǒng)繁忙(比如 HTTP 協(xié)議返回的 500 或者另外約定的返回碼)或者超時,這個時候需要等待一些時間再重試。
重試操作一般會指定重試次數上線,如果重試次數達到了上限就不再進行重試了。這個時候應該通過一種手段通知相關人員進行處理。
對于等待重試的策略如果重試時仍然錯誤,可逐漸增加等待的時間,直到達到一個上限后,以上限作為等待時間。
如果某個時刻聚集了大量需要重試的操作,補償框架需要控制請求的流量,以防止對工作服務造成過大的壓力。
另外關于補償模式還有幾點補充說明。
微服務實現補償操作不是簡單的回退到業(yè)務發(fā)生時的狀態(tài),因為可能還有其他的并發(fā)的請求同時更改了狀態(tài)。一般都使用逆操作的方式完成補償。
補償過程不需要嚴格按照與業(yè)務發(fā)生的相反順序執(zhí)行,可以依據工作服務的重用程度優(yōu)先執(zhí)行,甚至是可以并發(fā)的執(zhí)行。
有些服務的補償過程是有依賴關系的,被依賴服務的補償操作沒有成功就要及時終止補償過程。
如果在一個業(yè)務中包含的工作服務不是都提供了補償操作,那我們編排服務時應該把提供補償操作的服務放在前面,這樣當后面的工作服務錯誤時還有機會補償。
設計工作服務的補償接口時應該以協(xié)調服務請求的業(yè)務要素作為條件,不要以工作服務的應答要素作為條件。因為還存在超時需要補償的情況,這時補償框架就沒法提供補償需要的業(yè)務要素。
TCC模式(Try-Confirm-Cancel)
一個完整的 TCC 業(yè)務由一個主業(yè)務服務和若干個從業(yè)務服務組成,主業(yè)務服務發(fā)起并完成整個業(yè)務活動,TCC 模式要求從服務提供三個接口:Try、Confirm、Cancel。
1. Try
完成所有業(yè)務檢查
預留必須業(yè)務資源
2. Confirm
真正執(zhí)行業(yè)務
不作任何業(yè)務檢查
只使用 Try 階段預留的業(yè)務資源
Confirm 操作滿足冪等性
3. Cancel:
釋放 Try 階段預留的業(yè)務資源
Cancel 操作滿足冪等性
整個 TCC 業(yè)務分成兩個階段完成。
***階段:主業(yè)務服務分別調用所有從業(yè)務的 try 操作,并在活動管理器中登記所有從業(yè)務服務。當所有從業(yè)務服務的 try 操作都調用成功或者某個從業(yè)務服務的 try 操作失敗,進入第二階段。
第二階段:活動管理器根據***階段的執(zhí)行結果來執(zhí)行 confirm 或 cancel 操作。
如果***階段所有 try 操作都成功,則活動管理器調用所有從業(yè)務活動的 confirm操作。否則調用所有從業(yè)務服務的 cancel 操作。
需要注意的是第二階段 confirm 或 cancel 操作本身也是滿足最終一致性的過程,在調用 confirm 或 cancel 的時候也可能因為某種原因(比如網絡)導致調用失敗,所以需要活動管理支持重試的能力,同時這也就要求 confirm 和 cancel 操作具有冪等性。
在補償模式中一個比較明顯的缺陷是,沒有隔離性。從***個工作服務步驟開始一直到所有工作服務完成(或者補償過程完成),不一致是對其他服務可見的。另外最終一致性的保證還充分的依賴了協(xié)調服務的健壯性,如果協(xié)調服務異常,就沒法達到一致性。
TCC模式在一定程度上彌補了上述的缺陷,在TCC模式中直到明確的confirm動作,所有的業(yè)務操作都是隔離的(由業(yè)務層面保證)。另外工作服務可以通過指定 try 操作的超時時間,主動的 cancel 預留的業(yè)務資源,從而實現自治的微服務。
TCC模式和補償模式一樣需要需要有協(xié)調服務和工作服務,協(xié)調服務也可以作為通用服務一般實現為框架。與補償模式不同的是 TCC 服務框架不需要記錄詳細的業(yè)務流水,完成 confirm 和 cancel 操作的業(yè)務要素由業(yè)務服務提供。
在第4步確認預訂之前,訂單只是pending狀態(tài),只有等到明確的confirm之后訂單才生效。
如果3個服務中某個服務try操作失敗,那么可以向TCC服務框架提交cancel,或者什么也不做由工作服務自己超時處理。
TCC 模式也不能***保證一致性,如果業(yè)務服務向 TCC 服務框架提交 confirm后,TCC 服務框架向某個工作服務提交 confirm 失敗(比如網絡故障),那么就會出現不一致,一般稱為 heuristic exception。
需要說明的是為保證業(yè)務成功率,業(yè)務服務向 TCC 服務框架提交 confirm 以及TCC 服務框架向工作服務提交 confirm/cancel 時都要支持重試,這也就要confirm/cancel 的實現必須具有冪等性。如果業(yè)務服務向 TCC 服務框架提交confirm/cancel 失敗,不會導致不一致,因為服務***都會超時而取消。
另外 heuristic exception 是不可杜絕的,但是可以通過設置合適的超時時間,以及重試頻率和監(jiān)控措施使得出現這個異常的可能性降低到很小。如果出現了heuristic exception 是可以通過人工的手段補救的。
對賬是***的***防線
如果有些業(yè)務由于瞬時的網絡故障或調用超時等問題,通過上文所講的 3 種模式一般都能得到很好的解決。但是在當今云計算環(huán)境下,很多服務是依賴于外部系統(tǒng)的可用性情況,在一些重要的業(yè)務場景下還需要周期性的對賬來保證真實的一致性。比如支付系統(tǒng)和銀行之間每天日終是都會有對賬過程。