從銀行轉賬失敗到分散式事務:總結與思考

tianxiaoxu發表於2018-06-26

思考這個問題的初衷,是有一次給朋友轉賬,結果我的錢被扣了,朋友沒收到錢。而我之前一直認為銀行轉賬一定是由事務保證強一致性的,於是學習、總結了一下分散式事務的各種理論、方法。

事務是一個非常廣義的詞彙,各行各業解讀都不一樣。對於程式設計師,事務等價於Transaction,是指一組連續的操作,這些操作組合成一個邏輯的、完整的操作。即這組操作執行前後,系統需要處於一個可預知的、一致的狀態。因此,這一組操作要麼都成功執行,要麼都不能執行;如果部分成功,部分失敗,成功的部分需要回滾(rollback)。

關係型資料庫事務

大多數人可能和我一樣,第一次聽說事務是在學習關係型資料庫(mysql、sql server、Oracle)的時候,在關係型資料庫中,如果一組操作滿足ACID特性,那麼稱之為一個事務。關於關係型資料庫的ACID特性,不管是教材還是網路上都有大量的資料,這裡只簡單介紹。

A(Atomic):原子性,構成事務的所有操作,要麼都執行完成,要麼全部不執行,不可能出現部分成功部分失敗的情況
C(Consistency):一致性,在事務執行前後,資料庫的一致性約束沒有被破壞。這裡的一致性含義後面會詳細解釋
I(Isolation):隔離性,資料庫中的事務一般都是併發的,隔離性是指併發的兩個事務的執行互不干擾,一個事務不能看到其他事務執行過程的中間狀態
D(Durability):永續性,事務完成之後,該事務對資料的更改會被持久化到資料庫,且不會被回滾。

我們舉一個簡單的轉賬的例子,使用者A給玩家B轉100塊錢,那麼涉及到兩個操作:玩家A的賬戶扣100元,玩家B的賬戶加100元。即

UserA.account -= 100
UserB.account += 100

原子性很好理解,這兩個操作要麼都成功,要麼都不執行(更準確的是從效果上來看等價於都沒有執行)。不可能出現使用者A的錢減少了而使用者B的錢沒增加的情況,使用者是不允許的;更不可能出現使用者B的錢增加 而 使用者A的錢沒有減少的情況,銀行是絕對不幹的。

一致性說一起來大家都懂,但是深究起來也是似懂非懂。ACID中的一致性,網路上的介紹都很模糊,都是說要處於一致的狀態,那什麼是一致的狀態呢,比如轉賬操作中,A扣錢,B加錢,AB的錢的綜合是一定的,這個是否屬於ACID中的Consistency呢?我覺得不是的,Wiki Transaction_processingWiki: ACID分別是這麼描述的

 Consistency: A transaction is a correct transformation of the state. The actions taken as a group do not violate any of the integrity constraints associated with the state.

The consistency property ensures that any transaction will bring the database from one valid state to another. Any data written to the database must be valid according to all defined rules, including constraints, cascades, triggers, and any combination thereof. This does not guarantee correctness of the transaction in all ways the application programmer might have wanted (that is the responsibility of application-level code), but merely that any programming errors cannot result in the violation of any defined rules.

上面黑色加粗的部分指出,ACID中的一致性是指完整性約束不被破壞,完整性包含實體完整性(主屬性不為空)、參照完整性(外來鍵必須存在原表中)、使用者自定義的完整性。使用者自定義的完整性比如列值非空(not null)、列值唯一(unique)、列值是否滿足一個bool表示式(check語句,如性別只能有兩個值、歲數是一定範圍內的整數等),例如age smallint CHECK (age >=0 AND age <= 120).資料庫保證age的值在[0, 120]的範圍,如果不在這個範文,那麼更新操作失敗,事務也會失敗。另外,向mysql中的cascade,以及觸發器(trigger)都屬於使用者自定義的完整性約束。在MongoDB3.2中document validation就是使用者自定義的完整性約束,在插入或者更新docuemnt的時候檢查,不過使用者可以自行設定validationAction,確定當資料不符合約束時的表現,預設為error,即拒絕資料寫操作。

因此,使用者A,B在這次事務操作前後,賬戶的總和一定,是應用層面的一致性,而不是資料庫保證的一致性,應用層面的一致性事實上是由原子性來保證的。

隔離性說起來簡單,但事實上背後的事情很複雜,資料庫的隔離性依賴於加鎖或者多版本控制。簡單來說,如果UserA.account初始值為500,執行完第一條指令(即減去100),但事務還沒有提交,其他的事務是不能讀到這個中間結果(UserA.account的值為400)的。這就是避免了髒讀(Drity Read),對應的隔離級別就是READ_COMMITTED。在SQL標準中,定義了四個隔離級別:

  READ_UNCOMMITTED
READ_COMMITTED
REPEATABLE_READ
SERIALIZABLE

來解決事務併發中帶來的一下幾個問題髒讀(Dirty Read)、不可重複讀(Non-repeatable Read)、幻讀(Phantom Read)

不同的資料庫或者說儲存引擎預設支援不同的隔離級別,比如InnoDB儲存引擎預設支援REPEATABLE_READ,而Mongodb只支援READ_UNCOMMITTED

永續性需要考慮到一個事務在執行過程中的各種情況的異常。一個事務的流程是這樣的:

開啟一個事務
執行一組操作
如果都執行成功,那麼提交併結束事務
如果任何操作失敗,那麼回滾已經執行的操作,結束事務

在事務執行過程中,如果出現故障,比如斷電、當機,這個時候就要利用日誌(redo log或者undo log) 加上 checkpoint來保證事務的完整結束。

分散式事務

當資料的規模越來越大,超出了單個關係型資料庫的處理能力,這個時候就出現了關係型資料的垂直分表或者分表,也出現了天然支援水平擴充套件(sharding)的NoSql。另外,大型網站的服務化(SOA)以及這兩年非常火的微服務,往往將服務進行拆分,單獨部署,自然也使用獨立的資料庫,甚至是異構的資料庫。這個時候,關係型資料庫保證事務的手段,比如加鎖、日誌就行不通了。當然,本文討論的不僅僅是資料庫,也包含分散式儲存、訊息佇列,以及任何要保證原子性、永續性的邏輯。

分散式事務的最大挑戰在於CAP,在《CAP理論與MongoDB一致性、可用性的一些思考》一文中有詳細介紹。簡而言之,由於網路分割(P: Network Partition)的存在,使用者不得不在一致性(C Consistency)與可用性(A: Avaliable)之前做權衡。如果要保證強一致性(主要是應用層面的強一致性),那麼在網路分割的時候,系統就不可用;如果要保證高可用性,那麼就只能提供弱一致性,保證最終一致。下面提到的各種實現分散式事務的方法、協議都需要在一致性與可用性之間權衡。

2PC

提到分散式事務,首先想到的肯定是兩階段提交(2pc, two-phase commit protocol),2pc是非常經典的強一致性中心化的原子提交協議。中心化是指協議中有兩類節點:一箇中心化協調者節點(coordinator)和N個參與者節點(participant、cohort)。

顧名思義,兩階段提交協議的每一次事務提交分為兩個階段:

在第一階段,協調者詢問所有的參與者是否可以提交事務(請參與者投票),所有參與者向協調者投票。

在第二階段,協調者根據所有參與者的投票結果做出是否事務可以全域性提交的決定,並通知所有的參與者執行該決定。在一個兩階段提交流程中,參與者不能改變自己的投票結果。兩階段提交協議的可以全域性提交的前提是所有的參與者都同意提交事務,只要有一個參與者投票選擇放棄(abort)事務,則事務必須被放棄。

wiki上給出了簡要流程:

注意,上圖中洗下面一行也表明,兩階段提交協議也依賴與日誌,只要儲存介質不出問題,兩階段協議就能最終達到一致的狀態(成功或者回滾)

而下圖(來自slideshare)詳細描述了整個流程:

在劉傑的《分散式原理介紹中》,有非常詳細的流程介紹,可以配合上圖一起看,另外還介紹了在各種異常情況下(比如Coordinator、Participant當機,網路分割導致的超時)兩階段協議的工作情況。另外,在這篇文章中也有比較清晰的流程介紹。在這裡只討論2PC的優缺點:

優點:強一致性,只要節點或者網路最終恢復正常,協議就能保證順利結束;部分關係型資料庫(Oracle)、框架直接支援

缺點:兩階段提交協議的容錯能力較差,比如在節點當機或者超時的情況下,無法確定流程的狀態,只能不斷重試;兩階段提交協議的效能較差, 訊息互動多,且受最慢節點影響

這篇文章描述了為什麼兩階段提交協議在分散式系統中不適用:

  系統“水平”伸縮的死敵。基於兩階段提交的分散式事務在提交事務時需要在多個節點之間進行協調,最大限度地推後了提交事務的時間點,客觀上延長了事務的執行時間,這會導致事務在訪問共享資源時發生衝突和死鎖的概率增高,隨著資料庫節點的增多,這種趨勢會越來越嚴重,從而成為系統在資料庫層面上水平伸縮的”枷鎖”, 這是很多Sharding系統不採用分散式事務的主要原因。

所言甚是!

3PC

三階段提交協議(3pc Three-phase_commit_protocol)主要是為了解決兩階段提交協議的阻塞問題,從原來的兩個階段擴充套件為三個階段,並且增加了超時機制。

3PC只是解決了在異常情況下2PC的阻塞問題,但導致一次提交要傳遞6條訊息,延時很大。具體流程描述可參見《關於分散式事務、兩階段提交協議、三階提交協議 》一文。

TCC

TCC是Try、Commit、Cancel的縮寫,在國內由於支付寶的佈道而廣為人知,TCC在保證強一致性的同時,最大限度提高系統的可伸縮性與可用性

我們假設一個完整的為業務包含一組子業務,Try操作完成所有的子業務檢查,預留必要的業務資源,實現與其他事務的隔離;Confirm使用Try階段預留的業務資源真正執行業務,而且Confirm操作滿足冪等性,以遍支援重試;Cancel操作釋放Try階段預留的業務資源,同樣也滿足冪等性。“一次完整的交易由一系列微交易的Try 操作組成,如果所有的Try 操作都成功,最終由微交易框架來統一Confirm,否則統一Cancel,從而實現了類似經典兩階段提交協議(2PC)的強一致性。”

與2PC協議比較 ,TCC擁有以下特點:

  位於業務服務層而非資源層 ,由業務層保證原子性

沒有單獨的準備(Prepare)階段,降低了提交協議的成本

Try操作 兼備資源操作與準備能力

Try操作可以靈活選擇業務資源的鎖定粒度,而不是鎖住整個資源,提高了併發度

當然,TCC需要較的高開發成本,每個子業務都需要有響應的comfirm、Cancel操作,即實現相應的補償邏輯。

基於訊息的分散式事務

這類事務機制將分散式事務分成多個本地事務,這裡稱之為主事務與從事務。首先主事務本地先行提交,然後通過訊息通知從事務,從事務從訊息中獲取資訊進行本地提交。可以看出這是一種非同步事務機制、只能保證最終一致性;但可用性非常高,不會因為故障而發生阻塞。另外,主事務已經先行提交,如果因為從事務無法提交,要回滾主事務還是比較麻煩,所以這種模式只適用於理論上大概率等成功的業務情況,即從事務的提交失敗可能是由於故障,而不大可能是邏輯錯誤。

基於非同步訊息的事務機制主要有兩種方式:本地訊息表與事務訊息。二者的區別在於:怎麼保證主事務的提交與訊息傳送這兩個操作的原子性。

如果用非同步訊息實現轉賬的例子,那麼操作分為四部:使用者A扣錢,發訊息,使用者B收訊息,使用者B扣錢。前兩步必須保證原子性,如果A扣錢成功但是沒有發出訊息,那麼使用者A損失了;如果發訊息成功,但是沒有扣錢,那麼使用者B就多得了一筆錢,銀行肯定不幹。

本地訊息表

基於本地訊息表的方案是指將訊息寫入本地資料庫,通過本地事務保證主事務與訊息寫入的原子性。例如銀行轉賬的例子,偽碼如下:

 begin transaction:

update User set account = account – 100 where userId = ‘A’
insert into message(userId, amount, status) values(‘A’, 100, 1)

commit transaction

然後通過pull或者push模式,從業務獲取訊息並執行。如果是push模式,那麼一般使用具有持久化功能的訊息佇列,從事務務訂閱訊息。如果是pull模式,那麼從事務定時去拉取訊息,然後執行。

mongodb的寫入就很像本地訊息表,在WriteConcern為w:1的情況下,更新操作只要寫到oplog以及primary就可以向客戶端返回。secondary非同步拉取oplog並本地記錄執行。

事務訊息:

事務訊息依賴於支援“事務訊息”的訊息佇列,其基本思想是 利用訊息中間間實施兩階段提交,將本地事務和發訊息放在了一個分散式事務裡,保證要麼本地操作成功成功並且對外發訊息成功,要麼兩者都失敗。流程如下:

主事務向訊息佇列傳送預備訊息

主事務收到ACK之後本地執行主事務

根據執行的結果(成功或失敗)向訊息佇列傳送提交或者回滾訊息

詳細的流程如下圖(圖片來源見水印)所示:

不難看到,相比本地訊息表的方式,事務訊息由訊息中介軟體保證本地事務與訊息的原子性,不依賴於本地資料庫儲存訊息。但實現了“事務訊息”的訊息佇列比較少,還不夠通用。

不管是本地訊息表還是事務訊息,都需要保證從事務執行且僅僅執行一次,exact once。如果失敗,需要重試,但也不可能無限次的重試,當從事務最終失敗的情況下,需要通知主業務回滾嗎?但是此時,主事務已經提交,因此只能通過補償,實現邏輯上的回滾,而當前時間點距主事務的提交已經有一定時間,回滾也可能失敗。因此,最好是保證從事務邏輯上不會失敗,萬一失敗,記錄log並報警,人工介入。

1PC

1PC(one phase commit)這個概念,我是在《Distributed systems for fun and profit》一文中看到的,應該是對標2PC,3PC。在wiki中並沒有正式的詞條,在google上的文章也不是很多。在我的理解中,1PC適用於分散式儲存系統的複製集,即複製集中多個節點的資料提交,。一般來說,這些節點儲存同樣的資料,只要單個節點能提交,其他節點理論上也應該可以提交。 在《Distributed systems for fun and profit》中是這麼描述的:

 Having a second phase in place before the commit is considered permanent is useful, because it allows the system to roll back an update when a node fails. In contrast, in primary/backup (“1PC”), there is no step for rolling back an operation that has failed on some nodes and succeeded on others, and hence the replicas could diverge.

即對於分散式儲存中使用非常廣泛的中心化複製集協議Primary Secondary,在部分節點失敗、部分節點成功的情況下沒有回滾操作,可能會導致不一致。不過這些分散式儲存系統都竭力保證,這些不一致是暫時的,會通過重試等手段保證最終的一致。

1PC的優點是效能非常好,而且只有在出現物理故障的時候才會出現不一致。

比如在MongoDB中,更新操作會寫入Primary節點以及oplog collection,Secondary節點從Primary節點的oplog collection拉取操作日誌並執行,這是一個非同步的過程。及時Secondary節點因為故障執行oplog失敗,Promary節點的資料也不會回滾。在《帶著問題學習分散式系統之中心化複製集》中也提到過,為了提高資料可靠性(避免極端情況下資料被回滾),設定WriteConcern為w:Majority,(shard有一個Primary 一個Secondary 一個Arbiter組成)。如果這個時候由於其中一個secondary掛掉,寫入操作是不可能成功的。因此,在超時時間到達之後,會向客戶端返回出錯資訊。但是在這個時候資料是持久化到了primary節點,不會被回滾。如果此時Secondary重啟,那麼是會從Primary拉取日誌並執行。所以當客戶端返回的出錯資訊包含WriteResult.writeConcernError  時,應該謹慎處理

對於分散式檔案系統GFS、haystack,如果Secondary節點失敗,也會採取簡單粗暴的重試,並通過一些機制(cheksum,offset)來保證最終能讀到正確的資料

思考與總結

更多的時候,分散式事務只需要保證原子性,這個原子性也保證了應用層面上的一致性,而由本地事務來保證隔離性、永續性。

原子性這個東西,即使不是分散式,僅僅是單程式單執行緒也是需要考慮的,這就是C++中的RAII,python中的with statement,以及各種語言的try…finally…。當涉及到跨程式、非同步通訊的時候,就很難通過語言層面的機制保證原子性了。

在分散式領域,由於網路或者機器故障,經常需要重試,因此冪等性非常重要

很多場景,比如電商、網路購票,首先要保證的是高可用,不大可能採用強一致性,因此我們也會看到‘正在處理中…‘這種中間狀態,後臺很可能是非同步處理的,在12306買過票的話都知道,下單成功到最後是否能出票由很長一段時間。

在筆者的業務領域,並沒有涉及到強一致性的場景,只要最終一致性就行了。上面的提到的各種辦法,不管是2PC、TCC、本地訊息表、事務訊息,都需要引入額外的框架或者元件。所以更多的時候是採取業務補償的方式,比如一個涉及兩個程式的操作需要保證原子性,程式間RPC通訊,那麼一般是A程式先執行,然後RPC呼叫B程式介面,根據B程式的返回結果,絕對是否回滾(補償);但如果涉及到非同步RPC、或者多執行緒、或者兩個以上程式的串聯時,那麼就不一定能補償、甚至很難補償了,這個時候只記錄一個error log,然後通知人工排查。因此,事務補償只適合業務比較簡單的常見,而且很難形成通用的框架,或者說實用性不強。

之前一直以為像銀行轉賬這種場景,一定是強一致性的。後來自己遇到這麼一回事,我給朋友轉賬,我這邊顯示轉賬成功,但朋友並沒有收到錢。我以為是需要一定時間,結果24小時之後還沒有收到。我自己重新比對轉賬單,才發現是把對方的開戶銀行寫錯了。因此可見,轉賬這個操作肯定不是強一致性,具體怎麼搞的在網上也沒有查到。更坑爹的是,轉賬失敗,我的錢被扣了,朋友也沒有收到錢,但是我沒有收到任何訊息,也沒有給我把錢退回來,在我打電話到銀行去諮詢之後才退回來。這個體驗真的很差,但銀行是大爺,沒辦法!

references

Wiki Transaction_processing

Wiki: ACID

Wiki:two-phase commit protocol

關於分散式事務、兩階段提交協議、三階提交協議 

劉傑:分散式原理介紹

Distributed systems for fun and profit

相關文章