1. 程式人生 > >分散式系統事務

分散式系統事務

在分散式系統中,同時滿足“一致性”、“可用性”和“分割槽容錯性”三者是不可能的。分散式系統的事務一致性是一個技術難題,各種解決方案孰優孰劣?

寫在前面

在 OLTP 系統領域,我們在很多業務場景下都會面臨事務一致性方面的需求,例如最經典的 Bob 給 Smith 轉賬的案例。傳統的企業開發,系統往往是以單體應用形式存在的,也沒有橫跨多個數據庫。

我們通常只需藉助開發平臺中特有資料訪問技術和框架(例如 Spring、JDBC、ADO.NET),結合關係型資料庫自帶的事務管理機制來實現事務性的需求。關係型資料庫通常具有 ACID 特性:原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)、永續性(Durability)。

而大型網際網路平臺往往是由一系列分散式系統構成的,開發語言平臺和技術棧也相對比較雜,尤其是在 SOA 和微服務架構盛行的今天,一個看起來簡單的功能,內部可能需要呼叫多個“服務”並操作多個數據庫或分片來實現,情況往往會複雜很多。單一的技術手段和解決方案,已經無法應對和滿足這些複雜的場景了。

分散式系統的特性

對分散式系統有過研究的讀者,可能聽說過“CAP 定律”、“Base 理論”等,非常巧的是,化學理論中 ACID 是酸、Base 恰好是鹼。這裡筆者不對這些概念做過多的解釋,有興趣的讀者可以檢視相關參考資料。CAP 定律如下圖:
這裡寫圖片描述

在分散式系統中,同時滿足“CAP 定律”中的“一致性”、“可用性”和“分割槽容錯性”三者是不可能的,這比現實中找物件需同時滿足“高、富、帥”或“白、富、美”更加困難。在網際網路領域的絕大多數的場景,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證“最終一致性”,只要這個最終時間是在使用者可以接受的範圍內即可。

分散式事務

提到分散式系統,必然要提到分散式事務。要想理解分散式事務,不得不先介紹一下兩階段提交協議。先舉個簡單但不精準的例子來說明:

第一階段,張老師作為“協調者”,給小強和小明(參與者、節點)發微信,組織他們倆明天 8 點在學校門口集合,一起去爬山,然後開始等待小強和小明答覆。

第二階段,如果小強和小明都回答沒問題,那麼大家如約而至。如果小強或者小明其中一人回答說“明天沒空,不行”,那麼張老師會立即通知小強和小明“爬山活動取消”。

細心的讀者會發現,這個過程中可能有很多問題的。如果小強沒看手機,那麼張老師會一直等著答覆,小明可能在家裡把爬山裝備都準備好了卻一直等著張老師確認資訊。更嚴重的是,如果到明天 8 點小強還沒有答覆,那麼就算“超時”了,那小明到底去還是不去集合爬山呢?

這就是兩階段提交協議的弊病,所以後來業界又引入了三階段提交協議來解決該類問題。

兩階段提交協議在主流開發語言平臺,資料庫產品中都有廣泛應用和實現的,下面來介紹一下 XOpen 組織提供的 DTP 模型圖:

這裡寫圖片描述

XA 協議指的是 TM(事務管理器)和 RM(資源管理器)之間的介面。目前主流的關係型資料庫產品都是實現了 XA 介面的。JTA(Java Transaction API) 是符合 X/Open DTP 模型的,事務管理器和資源管理器之間也使用了 XA 協議。 本質上也是藉助兩階段提交協議來實現分散式事務的,下面分別來看看 XA 事務成功和失敗的模型圖:

這裡寫圖片描述

在 JavaEE 平臺下,WebLogic、Webshare 等主流商用的應用伺服器提供了 JTA 的實現和支援。而在 Tomcat 下是沒有實現的(其實筆者並不認為 Tomcat 能算是 JavaEE 應用伺服器),這就需要藉助第三方的框架 Jotm、Atomikos 等來實現,兩者均支援 spring 事務整合。

而在 Windows .NET 平臺中,則可以藉助 ado.net 中的 TransactionScop API 來程式設計實現,還必須配置和藉助 Windows 作業系統中的 MSDTC 服務。如果你的資料庫使用的 mysql,並且 mysql 是部署在 Linux 平臺上的,那麼是無法支援分散式事務的。 由於篇幅關係,這裡不展開,感興趣的讀者可以自行查閱相關資料並實踐。

總結: 這種方式實現難度不算太高,比較適合傳統的單體應用,在同一個方法中存在跨庫操作的情況。但分散式事務對效能的影響會比較大,不適合高併發和高效能要求的場景。

提供回滾介面

在服務化架構中,功能 X,需要去協調後端的 A、B 甚至更多的原子服務。那麼問題來了,假如 A 和 B 其中一個呼叫失敗了,那可怎麼辦呢?

在筆者的工作中經常遇到這類問題,往往提供了一個 BFF 層來協調呼叫 A、B 服務。如果有些是需要同步返回結果的,我會盡量按照“序列”的方式去呼叫。如果呼叫 A 失敗,則不會盲目去呼叫 B。如果呼叫 A 成功,而呼叫 B 失敗,會嘗試去回滾剛剛對 A 的呼叫操作。

當然,有些時候我們不必嚴格提供單獨對應的回滾介面,可以通過傳遞引數巧妙的實現。

這樣的情況,我們會盡量把可提供回滾介面的服務放在前面。舉個例子說明:

我們的某個論壇網站,每天登入成功後會獎勵使用者 5 個積分,但是積分和使用者又是兩套獨立的子系統服務,對應不同的 DB,這控制起來就比較麻煩了。解決思路:

把登入和加積分的服務呼叫放在 BFF 層一個本地方法中。

當用戶請求登入介面時,先執行加積分操作,加分成功後再執行登入操作

如果登入成功,那當然最好了,積分也加成功了。如果登入失敗,則呼叫加積分對應的回滾介面(執行減積分的操作)。

總結: 這種方式缺點比較多,通常在複雜場景下是不推薦使用的,除非是非常簡單的場景,非常容易提供回滾,而且依賴的服務也非常少的情況。

這種實現方式會造成程式碼量龐大,耦合性高。而且非常有侷限性,因為有很多的業務是無法很簡單的實現回滾的,如果序列的服務很多,回滾的成本實在太高。

本地訊息表

這種實現方式的思路,其實是源於 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 事務訊息部分的程式碼也並未開源,需要自己去實現。

其他補償方式

做過支付寶交易介面的同學都知道,我們一般會在支付寶的回撥頁面和接口裡,解密引數,然後呼叫系統中更新交易狀態相關的服務,將訂單更新為付款成功。同時,只有當我們回撥頁面中輸出了 success 字樣或者標識業務處理成功相應狀態碼時,支付寶才會停止回撥請求。否則,支付寶會每間隔一段時間後,再向客戶方發起回撥請求,直到輸出成功標識為止。

其實這就是一個很典型的補償例子,跟一些 MQ 重試補償機制很類似。

一般成熟的系統中,對於級別較高的服務和介面,整體的可用性通常都會很高。如果有些業務由於瞬時的網路故障或呼叫超時等問題,那麼這種重試機制其實是非常有效的。

當然,考慮個比較極端的場景,假如系統自身有 bug 或者程式邏輯有問題,那麼重試 1W 次那也是無濟於事的。那豈不是就發生了“明明已經付款,卻顯示未付款不發貨”類似的悲劇?

其實為了交易系統更可靠,我們一般會在類似交易這種高級別的服務程式碼中,加入詳細日誌記錄的,一旦系統內部引發類似致命異常,會有郵件通知。同時,後臺會有定時任務掃描和分析此類日誌,檢查出這種特殊的情況,會嘗試通過程式來補償並郵件通知相關人員。

在某些特殊的情況下,還會有“人工補償”的,這也是最後一道屏障。

寫在最後

上訴的幾種方案中,筆者也大致總結了其設計思路,優勢,劣勢等,相信讀者已經有了一定的理解。其實分散式系統的事務一致性本身是一個技術難題,目前沒有一種很簡單很完美的方案能夠應對所有場景。具體還是要使用者根據不同的業務場景去抉擇。