分佈式事務詳解

目前可找到很多成熟的開源分佈式事務解決方案,比較典型的方案如阿里的fescar,螞蟻金服的Seata,LCN(https://github.com/codingapi/tx-lcn)的2pc型無侵入事務。還有如TCC型事務實現hmily(https://github.com/yu199195/hmily)、tcc-transaction(https://github.com/changmingxie/tcc-transaction)等
Seata: https://github.com/seata/seata
fescar:https://github.com/alibaba/fescar
tcc-transaction: https://github.com/changmingxie/tcc-transaction
Hmily: https://github.com/yu199195/hmily
LCN: https://github.com/codingapi/tx-lcn

分佈式事務有個註明的CAP理論:C,A,P無法同時全部滿足,最多滿足兩個。Cassandra、Dynamo 等,默認優先選擇AP,弱化C;HBase、MongoDB 等,默認優先選擇CP,弱化A。
分佈式事務詳解

BASE模型包含個三個元素:
BA:Basically Available,基本可用
S:Soft State,軟狀態,狀態可以有一段時間不同步
E:Eventually Consistent,最終一致,最終數據是一致的就可以了,而不是時時保持強一致
BASE模型與ACID模型截然不同,滿足CAP理論,通過犧牲強一致性,獲得可用性,一般應用在服務化系統的應用層或者大數據處理系統,通過達到最終一致性來儘量滿足業務的絕大部分需求。

分佈式事務的目的是保障分佈式存儲中數據一致性,而跨庫事務會遇到各種不可控制的問題,如個別節點宕機,像單機事務一樣的ACID是無法奢望的。

1、Two/Three Phase Commit

2PC,中文叫兩階段提交。在分佈式系統中,每個節點雖然可以知曉自己的操作時成功或者失敗,卻無法知道其他節點的操作的成功或失敗。當一個事務跨越多個節點時,爲了保持事務的ACID特性,需要引入一個作爲協調者的組件來統一掌控所有節點(稱作參與者)的操作結果並最終指示這些節點是否要把操作結果進行真正的提交。 兩階段提交的算法如下:

第一階段:

協調者會問所有的參與者結點,是否可以執行提交操作。
各個參與者開始事務執行的準備工作:如:爲資源上鎖,預留資源。
參與者響應協調者,如果事務的準備工作成功,則迴應“可以提交”,否則迴應“拒絕提交”。
第二階段:

如果所有的參與者都回應“可以提交”,那麼,協調者向所有的參與者發送“正式提交”的命令。參與者完成正式提交,並釋放所有資源,然後迴應“完成”,協調者收集各結點的“完成”迴應後結束這個Global Transaction。
如果有一個參與者迴應“拒絕提交”,那麼,協調者向所有的參與者發送“回滾操作”,並釋放所有資源,然後迴應“回滾完成”,協調者收集各結點的“回滾”迴應後,取消這個Global Transaction。
兩段提交最大的問題就是第3)項,如果第一階段完成後,參與者在第二階沒有收到決策,那麼數據結點會進入“不知所措”的狀態,這個狀態會block住整個事務。也就是說,協調者Coordinator對於事務的完成非常重要,Coordinator的可用性是個關鍵。

因些,我們引入三段提交,三段提交在Wikipedia上的描述如下,他把二段提交的第一個段break成了兩段:詢問,然後再鎖資源。最後真正提交。三段提交的核心理念是:在詢問的時候並不鎖定資源,除非所有人都同意了,纔開始鎖資源。但三階段提交也存在一些缺陷,要徹底從協議層面避免數據不一致,可以採用Paxos或者Raft 算法。

目前兩階段提交、三階段提交存在如下的侷限性,並不適合在微服務架構體系下使用:

所有的操作必須是事務性資源(比如數據庫、消息隊列、EJB組件等),存在使用侷限性(微服務架構下多數使用HTTP協議),比較適合傳統的單體應用;

由於是強一致性,資源需要在事務內部等待,性能影響較大,吞吐率不高,不適合高併發與高性能的業務場景;

2、Try Confirm Cancel(TCC)

一個完整的TCC業務由一個主業務服務和若干個從業務服務組成,主業務服務發起並完成整個業務活動,TCC模式要求從服務提供三個接口:Try、Confirm、Cancel。

Try:完成所有業務檢查,預留必須業務資源。
Confirm:真正執行業務,不作任何業務檢查;只使用Try階段預留的業務資源;Confirm操作滿足冪等性。

Cancel:釋放Try階段預留的業務資源;Cancel操作滿足冪等性。

整個TCC業務分成兩個階段完成:

分佈式事務詳解

第一階段:主業務服務分別調用所有從業務的try操作,並在活動管理器中登記所有從業務服務。當所有從業務服務的try操作都調用成功或者某個從業務服務的try操作失敗,進入第二階段。

第二階段:活動管理器根據第一階段的執行結果來執行confirm或cancel操作。如果第一階段所有try操作都成功,則活動管理器調用所有從業務活動的confirm操作。否則調用所有從業務服務的cancel操作。

與2PC比較:

位於業務服務層而非資源層。
沒有單獨的準備(prepare)階段,Try操作兼備資源操作與準備能力。
Try操作可以靈活選擇業務資源的鎖定粒度。
開發成本較高。
缺點:

Canfirm和Cancel的冪等性很難保證。
這種方式缺點比較多,通常在複雜場景下是不推薦使用的,除非是非常簡單的場景,非常容易提供回滾Cancel,而且依賴的服務也非常少的情況。
這種實現方式會造成代碼量龐大,耦合性高。而且非常有侷限性,因爲有很多的業務是無法很簡單的實現回滾的,如果串行的服務很多,回滾的成本實在太高。
3、異步確保最終一致性

核心思想:

eBay 的架構師Dan Pritchett,曾在一篇解釋BASE 原理的論文《Base:An Acid Alternative》中提到一個eBay 分佈式系統一致性問題的解決方案。它的核心思想是將需要分佈式處理的任務通過消息或者日誌的方式來異步執行,消息或日誌可以存到本地文件、數據庫或消息隊列,再通過業務規則進行失敗重試,它要求各服務的接口是冪等的。
本地消息表

其基本的設計思想是將遠程分佈式事務拆分成一系列的本地事務。如果不考慮性能及設計優雅,藉助關係型數據庫中的表即可實現。

舉個經典的跨行轉賬的例子來描述。

第一步僞代碼如下,扣款100,通過本地事務保證了憑證消息插入到消息表中:

begin transaction:
  update User set account = account - 100 where userId = 'A'
  insert into message(msgId, userId, amount, status) values('123','A', 100, 1)
commit transaction

第二步,通知對方銀行賬戶上加100了。那問題來了,如何通知到對方呢?

通常採用兩種方式:

採用時效性高的MQ,由對方訂閱消息並監聽,有消息時自動觸發事件。
採用定時輪詢掃描的方式,去檢查消息表的數據。
兩種方式其實各有利弊,僅僅依靠MQ,可能會出現通知失敗的問題。而過於頻繁的定時輪詢,效率也不是最佳的(90%是無用功)。所以,我們一般會把兩種方式結合起來使用。

解決了通知的問題,又有新的問題了。萬一這消息有重複被消費,往用戶帳號上多加了錢,那豈不是後果很嚴重?其實我們可以消息消費方也通過一個“消費狀態表”來記錄消費狀態。在執行“加款”操作之前,檢測下該消息(提供標識)是否已經消費過,消費完成後,通過本地事務控制來更新這個“消費狀態表”。這樣子就避免重複消費的問題:

get msgId = '123';
check if mgsId is in message_applied(msgId);
if not applied:
    begin transaction:
        update User set account = account + 100 where userId = 'B'
        insert into message_applied(msgId) values('123')
    commit transaction

上訴的方式是一種非常經典的實現,基本避免了分佈式事務,實現了“最終一致性”。但是,關係型數據庫的吞吐量和性能方面存在瓶頸,頻繁的讀寫消息會給數據庫造成壓力。所以,在真正的高併發場景下,該方案也會有瓶頸和限制的。

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的事務消息(transactional message)。

總結:

閱讀了不少這方面的文章,在此基礎上,總結一下分佈式事務一致性的解決方案。分佈式系統的事務一致性本身就是一個技術難題,目前沒有一種很簡單很完美的方案能夠應對所有場景。分佈式系統的一個難點就是因爲“網絡通信的不可靠”,只能通過“確認機制”、“重試機制”、“補償機制”等各方面來解決一些問題。在綜合考慮可用性、性能、實現複雜度等各方面的情況上,比較好的選擇是“異步確保最終一致性”,只是具體實現方式上有一些差異。

參考文獻:
https://www.cnblogs.com/luxiaoxun/p/8832915.html
https://www.cnblogs.com/lori/p/9318892.html
分佈式系統的事務處理
https://coolshell.cn/articles/10910.html
用消息隊列和消息應用狀態表來消除分佈式事務
https://my.oschina.net/picasso/blog/35306
常用的分佈式事務解決方案介紹有多少種?
https://www.zhihu.com/question/64921387/answer/225784480
分佈式事務:不過是在一致性、吞吐量和複雜度之間,做一個選擇
https://mp.weixin.qq.com/s?__biz=MjM5MDE0Mjc4MA==&mid=2650994325&idx=1&sn=afe66f9cf65ec61aaaf8422a12618fb2

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章