前言:
在OLTP系統領域,我們在很多業務場景下都會面臨事務一致性方面的需求,例如最經典的Bob給Smith轉賬的案例。傳統的企業開發,系統往往是以單體應用形式存在的,也沒有橫跨多個數據庫。我們通常只需借助開發平台中特有數據訪問技術和框架(例如Spring、JDBC、http://ADO.NET),結合關系型數據庫自帶的事務管理機制來實現事務性的需求。關系型數據庫通常具有ACID特性:原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)、持久性(Durability)。
而大型互聯網平台往往是由一系列分布式系統構成的,開發語言平台和技術棧也相對比較雜,尤其是在SOA和微服務架構盛行的今天,一個看起來簡單的功能,內部可能需要調用多個“服務”並操作多個數據庫或分片來實現,情況往往會復雜很多。單一的技術手段和解決方案,已經無法應對和滿足這些復雜的場景了。
分布式系統的特性
對分布式系統有過研究的讀者,可能聽說過“CAP定律”、“Base理論”等,非常巧的是,化學理論中ACID是酸、Base恰好是鹼。這里筆者不對這些概念做過多的解釋,有興趣的讀者可以查看相關參考資料。在分布式系統中,同時滿足“CAP定律”中的“一致性”、“可用性”和“分區容錯性”三者是不可能的,這比現實中找對象需同時滿足“高、富、帥”或“白、富、美”更加困難。在互聯網領域的絕大多數的場景,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證“最終一致性”,只要這個最終時間是在用戶可以接受的范圍內即可。
解決方案:
一:提供回滾接口
在服務化架構中,功能X,需要去協調后端的A、B甚至更多的原子服務。那么問題來了,假如A和B其中一個調用失敗了,那可怎么辦呢?
在筆者的工作中經常遇到這類問題,往往提供了一個BFF層來協調調用A、B服務。如果有些是需要同步返回結果的,我會盡量按照“串行”的方式去調用。如果調用A失敗,則不會盲目去調用B。如果調用A成功,而調用B失敗,會嘗試去回滾剛剛對A的調用操作。
當然,有些時候我們不必嚴格提供單獨對應的回滾接口,可以通過傳遞參數巧妙的實現。
這樣的情況,我們會盡量把可提供回滾接口的服務放在前面。舉個例子說明:
我們的某個論壇網站,每天登錄成功后會獎勵用戶5個積分,但是積分和用戶又是兩套獨立的子系統服務,對應不同的DB,這控制起來就比較麻煩了。解決思路:
- 把登錄和加積分的服務調用放在BFF層一個本地方法中。
- 當用戶請求登錄接口時,先執行加積分操作,加分成功后再執行登錄操作
- 如果登錄成功,那當然最好了,積分也加成功了。如果登錄失敗,則調用加積分對應的回滾接口(執行減積分的操作)。
總結:這種方式缺點比較多,通常在復雜場景下是不推薦使用的,除非是非常簡單的場景,非常容易提供回滾,而且依賴的服務也非常少的情況。
二、補償事務(TCC)
TCC 其實就是采用的補償機制,其核心思想是:針對每個操作,都要注冊一個與其對應的確認和補償(撤銷)操作。它分為三個階段:
- Try 階段主要是對業務系統做檢測及資源預留
- Confirm 階段主要是對業務系統做確認提交,Try階段執行成功並開始執行 Confirm階段時,默認 Confirm階段是不會出錯的。即:只要Try成功,Confirm一定成功。
- Cancel 階段主要是在業務執行錯誤,需要回滾的狀態下執行的業務取消,預留資源釋放。
舉個例子,假入 Bob 要向 Smith 轉賬,思路大概是:
我們有一個本地方法,里面依次調用
1、首先在 Try 階段,要先調用遠程接口把 Smith 和 Bob 的錢給凍結起來。
2、在 Confirm 階段,執行遠程調用的轉賬的操作,轉賬成功進行解凍。
3、如果第2步執行成功,那么轉賬成功,如果第二步執行失敗,則調用遠程凍結接口對應的解凍方法 (Cancel)。
優點: 跟2PC比起來,實現以及流程相對簡單了一些,但數據的一致性比2PC也要差一些
缺點: 缺點還是比較明顯的,在2,3步中都有可能失敗。TCC屬於應用層的一種補償方式,所以需要程序員在實現的時候多寫很多補償的代碼,在一些場景中,一些業務流程可能用TCC不太好定義及處理。
三、本地消息表
這種實現方式的思路,其實是源於ebay,后來通過支付寶等公司的布道,在業內廣泛使用。其基本的設計思想是將遠程分布式事務拆分成一系列的本地事務。如果不考慮性能及設計優雅,借助關系型數據庫中的表即可實現。
舉個經典的跨行轉賬的例子來描述。
第一步偽代碼如下,扣款1W,通過本地事務保證了憑證消息插入到消息表中。
第二步,通知對方銀行賬戶上加1W了。那問題來了,如何通知到對方呢?
通常采用兩種方式:
- 采用時效性高的MQ,由對方訂閱消息並監聽,有消息時自動觸發事件
- 采用定時輪詢掃描的方式,去檢查消息表的數據。
兩種方式其實各有利弊,僅僅依靠MQ,可能會出現通知失敗的問題。而過於頻繁的定時輪詢,效率也不是最佳的(90%是無用功)。所以,我們一般會把兩種方式結合起來使用。
解決了通知的問題,又有新的問題了。萬一這消息有重復被消費,往用戶帳號上多加了錢,那豈不是后果很嚴重?
仔細思考,其實我們可以消息消費方,也通過一個“消費狀態表”來記錄消費狀態。在執行“加款”操作之前,檢測下該消息(提供標識)是否已經消費過,消費完成后,通過本地事務控制來更新這個“消費狀態表”。這樣子就避免重復消費的問題。
總結:上訴的方式是一種非常經典的實現,基本避免了分布式事務,實現了“最終一致性”。但是,關系型數據庫的吞吐量和性能方面存在瓶頸,頻繁的讀寫消息會給數據庫造成壓力。所以,在真正的高並發場景下,該方案也會有瓶頸和限制的。
四、MQ(非事務消息)
通常情況下,在使用非事務消息支持的MQ產品時,我們很難將業務操作與對MQ的操作放在一個本地事務域中管理。通俗點描述,還是以上述提到的“跨行轉賬”為例,我們很難保證在扣款完成之后對MQ投遞消息的操作就一定能成功。這樣一致性似乎很難保證。
先從消息生產者這端來分析,請看偽代碼:
根據上述代碼及注釋,我們來分析下可能的情況:
- 操作數據庫成功,向MQ中投遞消息也成功,皆大歡喜
- 操作數據庫失敗,不會向MQ中投遞消息了
- 操作數據庫成功,但是向MQ中投遞消息時失敗,向外拋出了異常,剛剛執行的更新數據庫的操作將被回滾
從上面分析的幾種情況來看,貌似問題都不大的。那么我們來分析下消費者端面臨的問題:
- 消息出列后,消費者對應的業務操作要執行成功。如果業務執行失敗,消息不能失效或者丟失。需要保證消息與業務操作一致
- 盡量避免消息重復消費。如果重復消費,也不能因此影響業務結果
如何保證消息與業務操作一致,不丟失?
主流的MQ產品都具有持久化消息的功能。如果消費者宕機或者消費失敗,都可以執行重試機制的(有些MQ可以自定義重試次數)。
如何避免消息被重復消費造成的問題?
- 保證消費者調用業務的服務接口的冪等性
- 通過消費日志或者類似狀態表來記錄消費狀態,便於判斷(建議在業務上自行實現,而不依賴MQ產品提供該特性)
總結:這種方式比較常見,性能和吞吐量是優於使用關系型數據庫消息表的方案。如果MQ自身和業務都具有高可用性,理論上是可以滿足大部分的業務場景的。不過在沒有充分測試的情況下,不建議在交易業務中直接使用。
五、MQ(事務消息)
舉個例子,Bob向Smith轉賬,那我們到底是先發送消息,還是先執行扣款操作?
好像都可能會出問題。如果先發消息,扣款操作失敗,那么Smith的賬戶里面會多出一筆錢。反過來,如果先執行扣款操作,后發送消息,那有可能扣款成功了但是消息沒發出去,Smith收不到錢。除了上面介紹的通過異常捕獲和回滾的方式外,還有沒有其他的思路呢?
下面以阿里巴巴的RocketMQ中間件為例,分析下其設計和實現思路。
RocketMQ第一階段發送Prepared消息時,會拿到消息的地址,第二階段執行本地事物,第三階段通過第一階段拿到的地址去訪問消息,並修改狀態。細心的讀者可能又發現問題了,如果確認消息發送失敗了怎么辦?RocketMQ會定期掃描消息集群中的事物消息,這時候發現了Prepared消息,它會向消息發送者確認,Bob的錢到底是減了還是沒減呢?如果減了是回滾還是繼續發送確認消息呢?RocketMQ會根據發送端設置的策略來決定是回滾還是繼續發送確認消息。這樣就保證了消息發送與本地事務同時成功或同時失敗。如下圖:
總結:據筆者的了解,各大知名的電商平台和互聯網公司,幾乎都是采用類似的設計思路來實現“最終一致性”的。這種方式適合的業務場景廣泛,而且比較可靠。不過這種方式技術實現的難度比較大。目前主流的開源MQ(ActiveMQ、RabbitMQ、Kafka)均未實現對事務消息的支持,所以需二次開發或者新造輪子。比較遺憾的是,RocketMQ事務消息部分的代碼也並未開源,需要自己去實現。
六、Sagas 事務模型
Saga事務模型又叫做長時間運行的事務(Long-running-transaction), 它是由普林斯頓大學的H.Garcia-Molina等人提出,它描述的是另外一種在沒有兩階段提交的的情況下解決分布式系統中復雜的業務事務問題。你可以在這里看到 Sagas 相關論文。
我們這里說的是一種基於 Sagas 機制的工作流事務模型,這個模型的相關理論目前來說還是比較新的,以至於百度上幾乎沒有什么相關資料。
該模型其核心思想就是拆分分布式系統中的長事務為多個短事務,或者叫多個本地事務,然后由 Sagas 工作流引擎負責協調,如果整個流程正常結束,那么就算是業務成功完成,如果在這過程中實現失敗,那么Sagas工作流引擎就會以相反的順序調用補償操作,重新進行業務回滾。
比如我們一次關於購買旅游套餐業務操作涉及到三個操作,他們分別是預定車輛,預定賓館,預定機票,他們分別屬於三個不同的遠程接口。可能從我們程序的角度來說他們不屬於一個事務,但是從業務角度來說是屬於同一個事務的。
他們的執行順序如上圖所示,所以當發生失敗時,會依次進行取消的補償操作。
因為長事務被拆分了很多個業務流,所以 Sagas 事務模型最重要的一個部件就是工作流或者你也可以叫流程管理器(Process Manager),工作流引擎和Process Manager雖然不是同一個東西,但是在這里,他們的職責是相同的。在選擇工作流引擎之后,最終的代碼也許看起來是這樣的
SagaBuilder saga = SagaBuilder.newSaga("trip") .activity("Reserve car", ReserveCarAdapter.class) .compensationActivity("Cancel car", CancelCarAdapter.class) .activity("Book hotel", BookHotelAdapter.class) .compensationActivity("Cancel hotel", CancelHotelAdapter.class) .activity("Book flight", BookFlightAdapter.class) .compensationActivity("Cancel flight", CancelFlightAdapter.class) .end() .triggerCompensationOnAnyError(); camunda.getRepositoryService().createDeployment() .addModelInstance(saga.getModel()) .deploy();
這里有一個 C# 相關示例,有興趣的同學可以看一下。
優缺點這里我們就不說了,因為這個理論比較新,目前市面上還沒有什么解決方案,即使是 Java 領域,我也沒有搜索的太多有用的信息。
其他補償方式
做過支付寶交易接口的同學都知道,我們一般會在支付寶的回調頁面和接口里,解密參數,然后調用系統中更新交易狀態相關的服務,將訂單更新為付款成功。同時,只有當我們回調頁面中輸出了success字樣或者標識業務處理成功相應狀態碼時,支付寶才會停止回調請求。否則,支付寶會每間隔一段時間后,再向客戶方發起回調請求,直到輸出成功標識為止。
其實這就是一個很典型的補償例子,跟一些MQ重試補償機制很類似。
一般成熟的系統中,對於級別較高的服務和接口,整體的可用性通常都會很高。如果有些業務由於瞬時的網絡故障或調用超時等問題,那么這種重試機制其實是非常有效的。
當然,考慮個比較極端的場景,假如系統自身有bug或者程序邏輯有問題,那么重試1W次那也是無濟於事的。那豈不是就發生了“明明已經付款,卻顯示未付款不發貨”類似的悲劇?
其實為了交易系統更可靠,我們一般會在類似交易這種高級別的服務代碼中,加入詳細日志記錄的,一旦系統內部引發類似致命異常,會有郵件通知。同時,后台會有定時任務掃描和分析此類日志,檢查出這種特殊的情況,會嘗試通過程序來補償並郵件通知相關人員。
在某些特殊的情況下,還會有“人工補償”的,這也是最后一道屏障。
小結
上訴的幾種方案中,筆者也大致總結了其設計思路,優勢,劣勢等,相信讀者已經有了一定的理解。其實分布式系統的事務一致性本身是一個技術難題,目前沒有一種很簡單很完美的方案能夠應對所有場景。具體還是要使用者根據不同的業務場景去抉擇。