從銀行轉(zhuǎn)賬失敗到分布式事務(wù):總結(jié)與思考
正文
思考這個問題的初衷,是有一次給朋友轉(zhuǎn)賬,結(jié)果我的錢被扣了,朋友沒收到錢。而我之前一直認為銀行轉(zhuǎn)賬一定是由事務(wù)保證強一致性的,于是學習、總結(jié)了一下分布式事務(wù)的各種理論、方法。
事務(wù)是一個非常廣義的詞匯,各行各業(yè)解讀都不一樣。對于程序員,事務(wù)等價于Transaction,是指一組連續(xù)的操作,這些操作組合成一個邏輯的、完整的操作。即這組操作執(zhí)行前后,系統(tǒng)需要處于一個可預知的、一致的狀態(tài)。因此,這一組操作要么都成功執(zhí)行,要么都不能執(zhí)行;如果部分成功,部分失敗,成功的部分需要回滾(rollback)。
關(guān)系型數(shù)據(jù)庫事務(wù)
大多數(shù)人可能和我一樣,***次聽說事務(wù)是在學習關(guān)系型數(shù)據(jù)庫(mysql、sql server、Oracle)的時候,在關(guān)系型數(shù)據(jù)庫中,如果一組操作滿足ACID特性,那么稱之為一個事務(wù)。關(guān)于關(guān)系型數(shù)據(jù)庫的ACID特性,不管是教材還是網(wǎng)絡(luò)上都有大量的資料,這里只簡單介紹。
- A(Atomic):原子性,構(gòu)成事務(wù)的所有操作,要么都執(zhí)行完成,要么全部不執(zhí)行,不可能出現(xiàn)部分成功部分失敗的情況
- C(Consistency):一致性,在事務(wù)執(zhí)行前后,數(shù)據(jù)庫的一致性約束沒有被破壞。這里的一致性含義后面會詳細解釋
- I(Isolation):隔離性,數(shù)據(jù)庫中的事務(wù)一般都是并發(fā)的,隔離性是指并發(fā)的兩個事務(wù)的執(zhí)行互不干擾,一個事務(wù)不能看到其他事務(wù)運行過程的中間狀態(tài)
- D(Durability):持久性,事務(wù)完成之后,該事務(wù)對數(shù)據(jù)的更改會被持久化到數(shù)據(jù)庫,且不會被回滾。
我們舉一個簡單的轉(zhuǎn)賬的例子,用戶A給玩家B轉(zhuǎn)100塊錢,那么涉及到兩個操作:玩家A的賬戶扣100元,玩家B的賬戶加100元。即
- UserA.account -= 100
- UserB.account += 100
原子性很好理解,這兩個操作要么都成功,要么都不執(zhí)行(更準確的是從效果上來看等價于都沒有執(zhí)行)。不可能出現(xiàn)用戶A的錢減少了而用戶B的錢沒增加的情況,用戶是不允許的;更不可能出現(xiàn)用戶B的錢增加 而 用戶A的錢沒有減少的情況,銀行是絕對不干的。
一致性說一起來大家都懂,但是深究起來也是似懂非懂。ACID中的一致性,網(wǎng)絡(luò)上的介紹都很模糊,都是說要處于一致的狀態(tài),那什么是一致的狀態(tài)呢,比如轉(zhuǎn)賬操作中,A扣錢,B加錢,AB的錢的綜合是一定的,這個是否屬于ACID中的Consistency呢?我覺得不是的,Wiki Transaction_processing和Wiki: 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語句,如性別只能有兩個值、歲數(shù)是一定范圍內(nèi)的整數(shù)等),例如age smallint CHECK (age >=0 AND age <= 120).數(shù)據(jù)庫保證age的值在[0, 120]的范圍,如果不在這個范文,那么更新操作失敗,事務(wù)也會失敗。另外,向mysql中的cascade,以及觸發(fā)器(trigger)都屬于用戶自定義的完整性約束。在MongoDB3.2中document validation就是用戶自定義的完整性約束,在插入或者更新docuemnt的時候檢查,不過用戶可以自行設(shè)定validationAction,確定當數(shù)據(jù)不符合約束時的表現(xiàn),默認為error,即拒絕數(shù)據(jù)寫操作。
因此,用戶A,B在這次事務(wù)操作前后,賬戶的總和一定,是應用層面的一致性,而不是數(shù)據(jù)庫保證的一致性,應用層面的一致性事實上是由原子性來保證的。
隔離性說起來簡單,但事實上背后的事情很復雜,數(shù)據(jù)庫的隔離性依賴于加鎖或者多版本控制。簡單來說,如果UserA.account初始值為500,執(zhí)行完***條指令(即減去100),但事務(wù)還沒有提交,其他的事務(wù)是不能讀到這個中間結(jié)果(UserA.account的值為400)的。這就是避免了臟讀(Drity Read),對應的隔離級別就是READ_COMMITTED。在SQL標準中,定義了四個隔離級別:
- READ_UNCOMMITTED
- READ_COMMITTED
- REPEATABLE_READ
- SERIALIZABLE
來解決事務(wù)并發(fā)中帶來的一下幾個問題臟讀(Dirty Read)、不可重復讀(Non-repeatable Read)、幻讀(Phantom Read)
不同的數(shù)據(jù)庫或者說存儲引擎默認支持不同的隔離級別,比如InnoDB存儲引擎默認支持REPEATABLE_READ,而Mongodb只支持READ_UNCOMMITTED
持久性需要考慮到一個事務(wù)在執(zhí)行過程中的各種情況的異常。一個事務(wù)的流程是這樣的:
- 開啟一個事務(wù)
- 執(zhí)行一組操作
- 如果都執(zhí)行成功,那么提交并結(jié)束事務(wù)
- 如果任何操作失敗,那么回滾已經(jīng)執(zhí)行的操作,結(jié)束事務(wù)
- 在事務(wù)執(zhí)行過程中,如果出現(xiàn)故障,比如斷電、宕機,這個時候就要利用日志(redo log或者undo log) 加上 checkpoint來保證事務(wù)的完整結(jié)束。
分布式事務(wù)
當數(shù)據(jù)的規(guī)模越來越大,超出了單個關(guān)系型數(shù)據(jù)庫的處理能力,這個時候就出現(xiàn)了關(guān)系型數(shù)據(jù)的垂直分表或者分表,也出現(xiàn)了天然支持水平擴展(sharding)的NoSql。另外,大型網(wǎng)站的服務(wù)化(SOA)以及這兩年非?;鸬奈⒎?wù),往往將服務(wù)進行拆分,單獨部署,自然也使用獨立的數(shù)據(jù)庫,甚至是異構(gòu)的數(shù)據(jù)庫。這個時候,關(guān)系型數(shù)據(jù)庫保證事務(wù)的手段,比如加鎖、日志就行不通了。當然,本文討論的不僅僅是數(shù)據(jù)庫,也包含分布式存儲、消息隊列,以及任何要保證原子性、持久性的邏輯。
分布式事務(wù)的***挑戰(zhàn)在于CAP,在《CAP理論與MongoDB一致性、可用性的一些思考》一文中有詳細介紹。簡而言之,由于網(wǎng)絡(luò)分割(P: Network Partition)的存在,用戶不得不在一致性(C Consistency)與可用性(A: Avaliable)之前做權(quán)衡。如果要保證強一致性(主要是應用層面的強一致性),那么在網(wǎng)絡(luò)分割的時候,系統(tǒng)就不可用;如果要保證高可用性,那么就只能提供弱一致性,保證最終一致。下面提到的各種實現(xiàn)分布式事務(wù)的方法、協(xié)議都需要在一致性與可用性之間權(quán)衡。
2PC
提到分布式事務(wù),首先想到的肯定是兩階段提交(2pc, two-phase commit protocol),2pc是非常經(jīng)典的強一致性、中心化的原子提交協(xié)議。中心化是指協(xié)議中有兩類節(jié)點:一個中心化協(xié)調(diào)者節(jié)點(coordinator)和N個參與者節(jié)點(participant、cohort)。
顧名思義,兩階段提交協(xié)議的每一次事務(wù)提交分為兩個階段:
- 在***階段,協(xié)調(diào)者詢問所有的參與者是否可以提交事務(wù)(請參與者投票),所有參與者向協(xié)調(diào)者投票。
- 在第二階段,協(xié)調(diào)者根據(jù)所有參與者的投票結(jié)果做出是否事務(wù)可以全局提交的決定,并通知所有的參與者執(zhí)行該決定。在一個兩階段提交流程中,參與者不能改變自己的投票結(jié)果。兩階段提交協(xié)議的可以全局提交的前提是所有的參與者都同意提交事務(wù),只要有一個參與者投票選擇放棄(abort)事務(wù),則事務(wù)必須被放棄。
wiki上給出了簡要流程:
注意,上圖中洗下面一行也表明,兩階段提交協(xié)議也依賴與日志,只要存儲介質(zhì)不出問題,兩階段協(xié)議就能最終達到一致的狀態(tài)(成功或者回滾)
而下圖(來自slideshare)詳細描述了整個流程:
在劉杰的《分布式原理介紹中》,有非常詳細的流程介紹,可以配合上圖一起看,另外還介紹了在各種異常情況下(比如Coordinator、Participant宕機,網(wǎng)絡(luò)分割導致的超時)兩階段協(xié)議的工作情況。在這里只討論2PC的優(yōu)缺點:
- 優(yōu)點:強一致性,只要節(jié)點或者網(wǎng)絡(luò)最終恢復正常,協(xié)議就能保證順利結(jié)束;部分關(guān)系型數(shù)據(jù)庫(Oracle)、框架直接支持
- 缺點:兩階段提交協(xié)議的容錯能力較差,比如在節(jié)點宕機或者超時的情況下,無法確定流程的狀態(tài),只能不斷重試;兩階段提交協(xié)議的性能較差, 消息交互多,且受最慢節(jié)點影響
這篇文章描述了為什么兩階段提交協(xié)議在分布式系統(tǒng)中不適用:
系統(tǒng)“水平”伸縮的死敵?;趦呻A段提交的分布式事務(wù)在提交事務(wù)時需要在多個節(jié)點之間進行協(xié)調(diào),***限度地推后了提交事務(wù)的時間點,客觀上延長了事務(wù)的執(zhí)行時間,這會導致事務(wù)在訪問共享資源時發(fā)生沖突和死鎖的概率增高,隨著數(shù)據(jù)庫節(jié)點的增多,這種趨勢會越來越嚴重,從而成為系統(tǒng)在數(shù)據(jù)庫層面上水平伸縮的”枷鎖”, 這是很多Sharding系統(tǒng)不采用分布式事務(wù)的主要原因。
所言甚是!
3PC
三階段提交協(xié)議(3pc Three-phase_commit_protocol)主要是為了解決兩階段提交協(xié)議的阻塞問題,從原來的兩個階段擴展為三個階段,并且增加了超時機制。
3PC只是解決了在異常情況下2PC的阻塞問題,但導致一次提交要傳遞6條消息,延時很大。具體流程描述可參見《關(guān)于分布式事務(wù)、兩階段提交協(xié)議、三階提交協(xié)議 》一文。
TCC
TCC是Try、Commit、Cancel的縮寫,在國內(nèi)由于支付寶的布道而廣為人知,TCC在保證強一致性的同時,***限度提高系統(tǒng)的可伸縮性與可用性。
我們假設(shè)一個完整的為業(yè)務(wù)包含一組子業(yè)務(wù),Try操作完成所有的子業(yè)務(wù)檢查,預留必要的業(yè)務(wù)資源,實現(xiàn)與其他事務(wù)的隔離;Confirm使用Try階段預留的業(yè)務(wù)資源真正執(zhí)行業(yè)務(wù),而且Confirm操作滿足冪等性,以遍支持重試;Cancel操作釋放Try階段預留的業(yè)務(wù)資源,同樣也滿足冪等性。“一次完整的交易由一系列微交易的Try 操作組成,如果所有的Try 操作都成功,最終由微交易框架來統(tǒng)一Confirm,否則統(tǒng)一Cancel,從而實現(xiàn)了類似經(jīng)典兩階段提交協(xié)議(2PC)的強一致性。”
與2PC協(xié)議比較 ,TCC擁有以下特點:
- 位于業(yè)務(wù)服務(wù)層而非資源層 ,由業(yè)務(wù)層保證原子性
- 沒有單獨的準備(Prepare)階段,降低了提交協(xié)議的成本
- Try操作 兼?zhèn)滟Y源操作與準備能力
- Try操作可以靈活選擇業(yè)務(wù)資源的鎖定粒度,而不是鎖住整個資源,提高了并發(fā)度
當然,TCC需要較的高開發(fā)成本,每個子業(yè)務(wù)都需要有響應的comfirm、Cancel操作,即實現(xiàn)相應的補償邏輯。
基于消息的分布式事務(wù)
這類事務(wù)機制將分布式事務(wù)分成多個本地事務(wù),這里稱之為主事務(wù)與從事務(wù)。首先主事務(wù)本地先行提交,然后通過消息通知從事務(wù),從事務(wù)從消息中獲取信息進行本地提交??梢钥闯鲞@是一種異步事務(wù)機制、只能保證最終一致性;但可用性非常高,不會因為故障而發(fā)生阻塞。另外,主事務(wù)已經(jīng)先行提交,如果因為從事務(wù)無法提交,要回滾主事務(wù)還是比較麻煩,所以這種模式只適用于理論上大概率等成功的業(yè)務(wù)情況,即從事務(wù)的提交失敗可能是由于故障,而不大可能是邏輯錯誤。
基于異步消息的事務(wù)機制主要有兩種方式:本地消息表與事務(wù)消息。二者的區(qū)別在于:怎么保證主事務(wù)的提交與消息發(fā)送這兩個操作的原子性。
如果用異步消息實現(xiàn)轉(zhuǎn)賬的例子,那么操作分為四部:用戶A扣錢,發(fā)消息,用戶B收消息,用戶B扣錢。前兩步必須保證原子性,如果A扣錢成功但是沒有發(fā)出消息,那么用戶A損失了;如果發(fā)消息成功,但是沒有扣錢,那么用戶B就多得了一筆錢,銀行肯定不干。
本地消息表
基于本地消息表的方案是指將消息寫入本地數(shù)據(jù)庫,通過本地事務(wù)保證主事務(wù)與消息寫入的原子性。例如銀行轉(zhuǎn)賬的例子,偽碼如下:
- 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模式,從業(yè)務(wù)獲取消息并執(zhí)行。如果是push模式,那么一般使用具有持久化功能的消息隊列,從事務(wù)務(wù)訂閱消息。如果是pull模式,那么從事務(wù)定時去拉取消息,然后執(zhí)行。
mongodb的寫入就很像本地消息表,在WriteConcern為w:1的情況下,更新操作只要寫到oplog以及primary就可以向客戶端返回。secondary異步拉取oplog并本地記錄執(zhí)行。
事務(wù)消息:
事務(wù)消息依賴于支持“事務(wù)消息”的消息隊列,其基本思想是 利用消息中間間實施兩階段提交,將本地事務(wù)和發(fā)消息放在了一個分布式事務(wù)里,保證要么本地操作成功成功并且對外發(fā)消息成功,要么兩者都失敗。流程如下:
- 主事務(wù)向消息隊列發(fā)送預備消息主事務(wù)收到ACK之后本地執(zhí)行主事務(wù)
- 根據(jù)執(zhí)行的結(jié)果(成功或失敗)向消息隊列發(fā)送提交或者回滾消息
詳細的流程如下圖(圖片來源見水印)所示:
不難看到,相比本地消息表的方式,事務(wù)消息由消息中間件保證本地事務(wù)與消息的原子性,不依賴于本地數(shù)據(jù)庫存儲消息。但實現(xiàn)了“事務(wù)消息”的消息隊列比較少,還不夠通用。
不管是本地消息表還是事務(wù)消息,都需要保證從事務(wù)執(zhí)行且僅僅執(zhí)行一次,exact once。如果失敗,需要重試,但也不可能***次的重試,當從事務(wù)最終失敗的情況下,需要通知主業(yè)務(wù)回滾嗎?但是此時,主事務(wù)已經(jīng)提交,因此只能通過補償,實現(xiàn)邏輯上的回滾,而當前時間點距主事務(wù)的提交已經(jīng)有一定時間,回滾也可能失敗。因此,***是保證從事務(wù)邏輯上不會失敗,萬一失敗,記錄log并報警,人工介入。
1PC
1PC(one phase commit)這個概念,我是在《Distributed systems for fun and profit》一文中看到的,應該是對標2PC,3PC。在wiki中并沒有正式的詞條,在google上的文章也不是很多。在我的理解中,1PC適用于分布式存儲系統(tǒng)的復制集,即復制集中多個節(jié)點的數(shù)據(jù)提交,。一般來說,這些節(jié)點存儲同樣的數(shù)據(jù),只要單個節(jié)點能提交,其他節(jié)點理論上也應該可以提交。 在《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.
即對于分布式存儲中使用非常廣泛的中心化復制集協(xié)議Primary Secondary,在部分節(jié)點失敗、部分節(jié)點成功的情況下沒有回滾操作,可能會導致不一致。不過這些分布式存儲系統(tǒng)都竭力保證,這些不一致是暫時的,會通過重試等手段保證最終的一致。
1PC的優(yōu)點是性能非常好,而且只有在出現(xiàn)物理故障的時候才會出現(xiàn)不一致。
比如在MongoDB中,更新操作會寫入Primary節(jié)點以及oplog collection,Secondary節(jié)點從Primary節(jié)點的oplog collection拉取操作日志并執(zhí)行,這是一個異步的過程。及時Secondary節(jié)點因為故障執(zhí)行oplog失敗,Promary節(jié)點的數(shù)據(jù)也不會回滾。在《帶著問題學習分布式系統(tǒng)之中心化復制集》中也提到過,為了提高數(shù)據(jù)可靠性(避免極端情況下數(shù)據(jù)被回滾),設(shè)定WriteConcern為w:Majority,(shard有一個Primary 一個Secondary 一個Arbiter組成)。如果這個時候由于其中一個secondary掛掉,寫入操作是不可能成功的。因此,在超時時間到達之后,會向客戶端返回出錯信息。但是在這個時候數(shù)據(jù)是持久化到了primary節(jié)點,不會被回滾。如果此時Secondary重啟,那么是會從Primary拉取日志并執(zhí)行。所以當客戶端返回的出錯信息包含WriteResult.writeConcernError 時,應該謹慎處理
對于分布式文件系統(tǒng)GFS、haystack,如果Secondary節(jié)點失敗,也會采取簡單粗暴的重試,并通過一些機制(cheksum,offset)來保證最終能讀到正確的數(shù)據(jù)
思考與總結(jié)
更多的時候,分布式事務(wù)只需要保證原子性,這個原子性也保證了應用層面上的一致性,而由本地事務(wù)來保證隔離性、持久性。
原子性這個東西,即使不是分布式,僅僅是單進程單線程也是需要考慮的,這就是C++中的RAII,python中的with statement,以及各種語言的try…finally…。當涉及到跨進程、異步通信的時候,就很難通過語言層面的機制保證原子性了。
在分布式領(lǐng)域,由于網(wǎng)絡(luò)或者機器故障,經(jīng)常需要重試,因此冪等性非常重要
很多場景,比如電商、網(wǎng)絡(luò)購票,首先要保證的是高可用,不大可能采用強一致性,因此我們也會看到‘正在處理中…‘這種中間狀態(tài),后臺很可能是異步處理的,在12306買過票的話都知道,下單成功到***是否能出票由很長一段時間。
在筆者的業(yè)務(wù)領(lǐng)域,并沒有涉及到強一致性的場景,只要最終一致性就行了。上面的提到的各種辦法,不管是2PC、TCC、本地消息表、事務(wù)消息,都需要引入額外的框架或者組件。所以更多的時候是采取業(yè)務(wù)補償?shù)姆绞?,比如一個涉及兩個進程的操作需要保證原子性,進程間RPC通信,那么一般是A進程先執(zhí)行,然后RPC調(diào)用B進程接口,根據(jù)B進程的返回結(jié)果,絕對是否回滾(補償);但如果涉及到異步RPC、或者多線程、或者兩個以上進程的串聯(lián)時,那么就不一定能補償、甚至很難補償了,這個時候只記錄一個error log,然后通知人工排查。因此,事務(wù)補償只適合業(yè)務(wù)比較簡單的常見,而且很難形成通用的框架,或者說實用性不強。
之前一直以為像銀行轉(zhuǎn)賬這種場景,一定是強一致性的。后來自己遇到這么一回事,我給朋友轉(zhuǎn)賬,我這邊顯示轉(zhuǎn)賬成功,但朋友并沒有收到錢。我以為是需要一定時間,結(jié)果24小時之后還沒有收到。我自己重新比對轉(zhuǎn)賬單,才發(fā)現(xiàn)是把對方的開戶銀行寫錯了。因此可見,轉(zhuǎn)賬這個操作肯定不是強一致性,具體怎么搞的在網(wǎng)上也沒有查到。更坑爹的是,轉(zhuǎn)賬失敗,我的錢被扣了,朋友也沒有收到錢,但是我沒有收到任何消息,也沒有給我把錢退回來,在我打電話到銀行去咨詢之后才退回來。這個體驗真的很差,但銀行是大爺,沒辦法!