數據一致性問題非常多樣,下面舉一些常見例子。比如在更新數據的時候,先更新了數據庫,后更新了緩存,一旦緩存更新失敗,此時數據庫和緩存數據會不一致。反過來,如果先更新緩存,再更新數據庫,一旦緩存更新成功,數據庫更新失敗,數據還是不一致;
比如數據庫中的參照完整性,從表引用了主表的主鍵,對從表來說,也就是外鍵。當主表的記錄刪除后,從表是字段置空,還是級聯刪除。同樣,當要創建從表記錄時,主表記錄是否要先創建,還是可以直接創建從表的記錄;
比如數據庫中的原子性:同時修改兩條記錄,一條記錄修改成功了,一條記錄沒有修改成功,數據就會不一致,此時必須回滾,否則會出現臟數據。
比如數據庫的Master-Slave異步復制,Master宕機切換到Slave,導致部分數據丟失,數據會不一致。
發送方發送了消息1、2、3、4、5,因為消息中間件的不穩定,導致丟了消息4,接收方只收到了消息1、2、3、5,發送方和接收方數據會不一致。
從以上案例可以看出,數據一致性問題幾乎無處不在。本書把一致性問題分為了兩大類:事務一致性和多副本一致性。這兩類一致性問題基本涵蓋了實踐中所遇到的絕大部分場景,本章和下一章將分別針對這兩類一致性問題進行詳細探討。
隨處可見的分布式事務問題
在“集中式”的架構中,很多系統用的是Oracle這種大型數據庫,把整個業務數據放在這樣一個強大的數據庫里面,利用數據庫的參照完整性機制、事務機制,避免出現數據一致性問題。這正是數據庫之所以叫“數據庫”而不是“存儲”的一個重要原因,就是數據庫強大的數據一致性保證。
但到了分布式時代,人們對數據庫進行了分庫分表,同時在上面架起一個個的服務。到了微服務時代,服務的粒度拆得更細,導致一個無法避免的問題:數據庫的事務機制不管用了,因為數據庫本身只能保證單機事務,對於分布式事務,只能靠業務系統解決。
例如做一個服務,最初底下只有一個數據庫,用數據庫本身的事務來保證數據一致性。隨着數據量增長到一定規模,進行了分庫,這時數據庫的事務就不管用了,如何保證多個庫之間的數據一致性呢?
再以電商系統為例,比如有兩個服務,一個是訂單服務,背后是訂單數據庫;一個是庫存服務,背后是庫存數據庫,下訂單的時候需要扣庫存。無論先創建訂單,后扣庫存,還是先扣庫存,后創建訂單,都無法保證兩個服務一定會調用成功,如何保證兩個服務之間的數據一致性呢?
這樣的案例在微服務架構中隨處可見:凡是一個業務操作,需要調用多個服務,並且都是寫操作的時候,就可能會出現有的服務調用成功,有的服務調用失敗,導致只部分數據寫入成功,也就出現了服務之間的數據不一致性。
分布式事務解決方案匯總
接下來,以一個典型的分布式事務問題——“轉賬”為例,詳細探討分布式事務的各種解決方案。
以支付寶為例,要把一筆錢從支付寶的余額轉賬到余額寶,支付寶的余額在系統A,背后有對應的DB1;余額寶在系統B,背后有對應的DB2;螞蟻借唄在系統C,背后有對應的DB3,這些系統之間都要支持相關轉賬。所謂“轉賬”,就是轉出方的系統里面賬號要扣錢,轉入方的系統里面賬號要加錢,如何保證兩個操作在兩個系統中同時成功呢?
1. 2PC
(1)2PC理論。在講MySQL Binlog和Redo Log的一致性問題時,已經用到了2PC。當然,那個場景只是內部的分布式事務問題,只涉及單機的兩個日志文件之間的數據一致性;2PC是應用在兩個數據庫或兩個系統之間。
2PC有兩個角色:事務協調者和事務參與者。具體到數據庫的實現來說,每一個數據庫就是一個參與者,調用方也就是協調者。2PC是指事務的提交分為兩個階段,如圖10-1所示。
階段1:准備階段。協調者向各個參與者發起詢問,說要執行一個事務,各參與者可能回復YES、NO或超時。
階段2:提交階段。如果所有參與者都回復的是YES,則事務協調者向所有參與者發起事務提交操作,即Commit操作,所有參與者各自執行事務,然后發送ACK。
如果有一個參與者回復的是NO,或者超時了,則事務協調者向所有參與者發起事務回滾操作,所有參與者各自回滾事務,然后發送ACK,如圖10-2所示。
所以,無論事務提交,還是事務回滾,都是兩個階段。
(2)2PC的實現。通過分析可以發現,要實現2PC,所有參與者都要實現三個接口:Prepare、Commit、Rollback,這也就是XA協議,在Java中對應的接口是javax.transaction.xa.XAResource,通常的數據庫也都實現了這個協議。開源的Atomikos也基於該協議提供了2PC的解決方案,有興趣的讀者可以進一步研究。
(3)2PC的問題。2PC在數據庫領域非常常見,但它存在幾個問題:
問題1:性能問題。在階段1,鎖定資源之后,要等所有節點返回,然后才能一起進入階段2,不能很好地應對高並發場景。
問題2:階段1完成之后,如果在階段2事務協調者宕機,則所有的參與者接收不到Commit或Rollback指令,將處於“懸而不決”狀態。
問題3:階段1完成之后,在階段2,事務協調者向所有的參與者發送了Commit指令,但其中一個參與者超時或出錯了(沒有正確返回ACK),則其他參與者提交還是回滾呢? 也不能確定。
為了解決2PC的問題,又引入了3PC。3PC存在類似宕機如何解決的問題,因此還是沒能徹底解決問題,
2PC除本身的算法局限外,還有一個使用上的限制,就是它主要用在兩個數據庫之間(數據庫實現了XA協議)。但以支付寶的轉賬為例,是兩個系統之間的轉賬,而不是底層兩個數據庫之間直接交互,所以沒有辦法使用2PC。
不僅支付寶,其他業務場景基本都采用了微服務架構,不會直接在底層的兩個業務數據庫之間做一致性,而是在兩個服務上面實現一致性。
正因為2PC有諸多問題和不便,在實踐中一般很少使用。
2. 3PC(三階段提交)
三階段提交協議(3PC)主要是為了解決兩階段提交協議的阻塞問題,2pc存在的問題是當協作者崩潰時,參與者不能做出最后的選擇。因此參與者可能在協作者恢復之前保持阻塞。三階段提交(Three-phase commit),是二階段提交(2PC)的改進版本。
與兩階段提交不同的是,三階段提交有兩個改動點。
也就是說,除了引入超時機制之外,3PC把2PC的准備階段再次一分為二,這樣三階段提交就有CanCommit、PreCommit、DoCommit三個階段。
1、CanCommit階段
之前2PC的一階段是本地事務執行結束后,最后不Commit,等其它服務都執行結束並返回Yes,由協調者發生commit才真正執行commit。而這里的CanCommit指的是 嘗試獲取數據庫鎖 如果可以,就返回Yes。
這階段主要分為2步
- 事務詢問 協調者 向 參與者 發送CanCommit請求。詢問是否可以執行事務提交操作。然后開始等待 參與者 的響應。
- 響應反饋 參與者 接到CanCommit請求之后,正常情況下,如果其自身認為可以順利執行事務,則返回Yes響應,並進入預備狀態。否則反饋No
2、PreCommit階段
在階段一中,如果所有的參與者都返回Yes的話,那么就會進入PreCommit階段進行事務預提交。這里的PreCommit階段 跟上面的第一階段是差不多的,只不過這里 協調者和參與者都引入了超時機制 (2PC中只有協調者可以超時,參與者沒有超時機制)。
3、DoCommit階段
這里跟2pc的階段二是差不多的。
總結
相比較2PC而言,3PC對於協調者(Coordinator)和參與者(Partcipant)都設置了超時時間,而2PC只有協調者才擁有超時機制。這解決了一個什么問題呢?
這個優化點,主要是避免了參與者在長時間無法與協調者節點通訊(協調者掛掉了)的情況下,無法釋放資源的問題,因為參與者自身擁有超時機制會在超時后,
自動進行本地commit從而進行釋放資源。而這種機制也側面降低了整個事務的阻塞時間和范圍。
另外,通過CanCommit、PreCommit、DoCommit三個階段的設計,相較於2PC而言,多設置了一個緩沖階段保證了在最后提交階段之前各參與節點的狀態是一致的。
以上就是3PC相對於2PC的一個提高(相對緩解了2PC中的前兩個問題),但是3PC依然沒有完全解決數據不一致的問題。
3. 最終一致性(消息中間件)
一般的思路是通過消息中間件來實現“最終一致性”,如圖10-3所示。
系統A收到用戶的轉賬請求,系統A先自己扣錢,也就是更新DB1;然后通過消息中間件給系統B發送一條加錢的消息,系統B收到此消息,對自己的賬號進行加錢,也就是更新DB2。
這里面有一個關鍵的技術問題:
系統A給消息中間件發消息,是一次網絡交互;更新DB1,也是一次網絡交互。系統A是先更新DB1,后發送消息,還是先發送消息,后更新DB1?
假設先更新DB1成功,發送消息網絡失敗,重發又失敗,怎么辦?又假設先發送消息成功,更新DB1失敗。消息已經發出去了,又不能撤回,怎么辦?或者消息中間件提供了消息撤回的接口,但是又調用失敗怎么辦?
因為這是兩次網絡調用,兩個操作不是原子的,無論誰先誰后,都是有問題的。
下面來看最終一致性的幾種具體實現思路:
a.最終一致性:錯誤的方案0
有人可能會想,可以把“發送加錢消息”這個網絡調用和更新DB1放在同一個事務里面,如果發送消息失敗,更新DB自動回滾。這樣不就可以保證兩個操作的原子性了嗎?
這個方案看似正確,其實是錯誤的,原因有兩點:
(1)網絡的2將軍問題:發送消息失敗,發送方並不知道是消息中間件沒有收到消息,還是消息已經收到了,只是返回response的時候失敗了?
如果已經收到消息了,而發送端認為沒有收到,執行update DB的回滾操作,則會導致賬戶A的錢沒有扣,賬戶B的錢卻被加了。
(2)把網絡調用放在數據庫事務里面,可能會因為網絡的延時導致數據庫長事務。嚴重的會阻塞整個數據庫,風險很大。
b.最終一致性:第1種實現方式(業務方自己實現)
假設消息中間件沒有提供“事務消息”功能,比如用的是Kafka。該如何解決這個問題呢?
消息中間件實現最終一致性示意圖如圖10-4所示。
(1)系統A增加一張消息表,系統A不再直接給消息中間件發送消息,而是把消息寫入到這張消息表中。把DB1的扣錢操作(表1)和寫入消息表(表2)這兩個操作放在一個數據庫事務里,保證兩者的原子性。
(2)系統A准備一個后台程序,源源不斷地把消息表中的消息傳送給消息中間件。如果失敗了,也不斷嘗試重傳。因為網絡的2將軍問題,系統A發送給消息中間件的消息網絡超時了,消息中間件可能已經收到了消息,也可能沒有收到。系統A會再次發送該消息,直到消息中間件返回成功。所以,系統A允許消息重復,但消息不會丟失,順序也不會打亂。
(3)通過上面的兩個步驟,系統A保證了消息不丟失,但消息可能重復。系統B對消息的消費要解決下面兩個問題:
問題1:丟失消費。系統B從消息中間件取出消息(此時還在內存里面),如果處理了一半,系統B宕機並再次重啟,此時這條消息未處理成功,怎么辦?
答案是通過消息中間件的ACK機制,凡是發送ACK的消息,系統B重啟之后消息中間件不會再次推送;凡是沒有發送ACK的消息,系統B重啟之后消息中間件會再次推送。
但這又會引發一個新問題,就是下面問題2的重復消費:即使系統B把消息處理成功了,但是正要發送ACK的時候宕機了,消息中間件以為這條消息沒有處理成功,系統B再次重啟的時候又會收到這條消息,系統B就會重復消費這條消息(對應加錢類的場景,賬號里面的錢就會加兩次)
問題2:重復消費。除了ACK機制,可能會引起重復消費;系統A的后台任務也可能給消息中間件重復發送消息。
為了解決重復消息的問題,系統B增加一個判重表。判重表記錄了處理成功的消息ID和消息中間件對應的offset(以Kafka為例),系統B宕機重啟,可以定位到offset位置,從這之后開始繼續消費。
每次接收到新消息,先通過判重表進行判重,實現業務的冪等。同樣,對DB2的加錢操作和消息寫入判重表兩個操作,要在一個DB的事務里面完成。
這里要補充的是,消息的判重不止判重表一種方法。如果業務本身就有業務數據,可以判斷出消息是否重復了,就不需要判重表了。
通過上面三步,實現了消息在發送方的不丟失、在接收方的不重復,聯合起來就是消息的不漏不重,嚴格實現了系統A和系統B的最終一致性。
但這種方案有一個缺點:系統A需要增加消息表,同時還需要一個后台任務,不斷掃描此消息表,會導致消息的處理和業務邏輯耦合,額外增加業務方的開發負擔。
c.最終一致性:第二種實現方式(基於RocketMQ事務消息)
為了能通過消息中間件解決該問題,同時又不和業務耦合,RocketMQ提出了“事務消息”的概念,如圖10-5所示。
RocketMQ不是提供一個單一的“發送”接口,而是把消息的發送拆成了兩個階段,Prepare階段(消息預發送)和Confirm階段(確認發送)。具體使用方法如下:
步驟1:系統A調用Prepare接口,預發送消息。此時消息保存在消息中間件里,但消息中間件不會把消息給消費方消費,消息只是暫存在那。
步驟2:系統A更新數據庫,進行扣錢操作。
步驟3:系統A調用Comfirm接口,確認發送消息。此時消息中間件才會把消息給消費方進行消費。
顯然,這里有兩種異常場景:
場景1:步驟1成功,步驟2成功,步驟3失敗或超時,怎么處理?
場景2:步驟1成功,步驟2失敗或超時,步驟3不會執行。怎么處理?
這就涉及RocketMQ的關鍵點:RocketMQ會定期(默認是1min)掃描所有的預發送但還沒有確認的消息,回調給發送方,詢問這條消息是要發出去,還是取消。發送方根據自己的業務數據,知道這條消息是應該發出去(DB更新成功了),還是應該取消(DB更新失敗)。
對比最終一致性的兩種實現方案會發現,RocketMQ最大的改變其實是把“掃描消息表”這件事不讓業務方做,而是讓消息中間件完成。
至於消息表,其實還是沒有省掉。因為消息中間件要詢問發送方事物是否執行成功,還需要一個“變相的本地消息表”,記錄事務執行狀態和消息發送狀態。
同時對於消費方,還是沒有解決系統重啟可能導致的重復消費問題,這只能由消費方解決。需要設計判重機制,實現消息消費的冪等。
d.人工介入
無論方案1,還是方案2,發送端把消息成功放入了隊列中,但如果消費端消費失敗怎么辦?
如果消費失敗了,則可以重試,但還一直失敗怎么辦?是否要自動回滾整個流程?
答案是人工介入。從工程實踐角度來講,這種整個流程自動回滾的代價是非常巨大的,不但實現起來很復雜,還會引入新的問題。比如自動回滾失敗,又如何處理?
對應這種發生概率極低的事件,采取人工處理會比實現一個高復雜的自動化回滾系統更加可靠,也更加簡單。
4. TCC
說起分布式事務的概念,不少人都會搞混淆,似乎好像分布式事務就是TCC。實際上TCC與2PC、3PC一樣,只是分布式事務的一種實現方案而已。
TCC(Try-Confirm-Cancel)又稱補償事務。其核心思想是:"針對每個操作都要注冊一個與其對應的確認和補償(撤銷操作)"。它分為三個操作:
-
Try階段:主要是對業務系統做檢測及資源預留。
-
Confirm階段:確認執行業務操作。
-
Cancel階段:取消執行業務操作。
2PC通常用來解決兩個數據庫之間的分布式事務問題,比較局限。現在企業采用的是各式各樣的SOA服務,更需要解決兩個服務之間的分布式事務問題。
為了解決SOA系統中的分布式事務問題,支付寶提出了TCC。TCC是Try、Confirm、Cancel三個單詞的縮寫,其實是一個應用層面的2PC協議,Confirm對應2PC中的事務提交操作,Cancel對應2PC中的事務回滾操作,如圖10-6所示。
(1)准備階段:調用方調用所有服務方提供的Try接口,該階段各調用方做資源檢查和資源鎖定,為接下來的階段2做准備。
(2)提交階段:如果所有服務方都返回YES,則進入提交階段,調用方調用各服務方的Confirm接口,各服務方進行事務提交。如果有一個服務方在階段1返回NO或者超時了,則調用方調用各服務方的Cancel接口,如圖10-7所示。
這里有一個關鍵問題:TCC既然也借鑒2PC的思路,那么它是如何解決2PC的問題的呢?也就是說,在階段2,調用方發生宕機,或者某個服務超時了,如何處理呢?
答案是:不斷重試!不管是Confirm失敗了,還是Cancel失敗了,都不斷重試。這就要求Confirm和Cancel都必須是冪等操作。注意,這里的重試是由TCC的框架來執行的,而不是讓業務方自己去做。
下面以一個轉賬的事件為例,來說明TCC的過程。假設有三個賬號A、B、C,通過SOA提供的轉賬服務操作。A、B同時分別要向C轉30元、50元,最后C的賬號+80元,A、B各減30元、50元。
階段1:分別對賬號A、B、C執行Try操作,A、B、C三個賬號在三個不同的SOA服務里面,也就是分別調用三個服務的Try接口。具體來說,就是賬號A鎖定30元,賬號B鎖定50元,檢查賬號C的合法性,比如賬號C是否違法被凍結,賬號C是否已注銷。
所以,在這個場景里面,對應的“扣錢”的Try操作就是“鎖定”,對應的“加錢”的Try操作就是檢查賬號合法性,為的是保證接下來的階段2扣錢可扣、加錢可加!
階段2:A、B、C的Try操作都成功,執行Confirm操作,即分別調用三個SOA服務的Confirm接口。A、B扣錢,C加錢。如果任意一個失敗,則不斷重試,直到成功為止。
從案例可以看出,Try操作主要是為了“保證業務操作的前置條件都得到滿足”,然后在Confirm階段,因為前置條件都滿足了,所以可以不斷重試保證成功。
TCC事務的處理流程與2PC兩階段提交類似,不過2PC通常都是在跨庫的DB層面,而TCC本質上就是一個應用層面的2PC,需要通過業務邏輯來實現。這種分布式事務的實現方式的優勢在於,可以讓應用自己定義數據庫操作的粒度,使得降低鎖沖突、提高吞吐量成為可能。
而不足之處則在於對應用的侵入性非常強,業務邏輯的每個分支都需要實現try、confirm、cancel三個操作。此外,其實現難度也比較大,需要按照網絡狀態、系統故障等不同的失敗原因實現不同的回滾策略。為了滿足一致性的要求,confirm和cancel接口還必須實現冪等。
TCC的具體原理圖如👇:
5. 事務狀態表+調用方重試+接收方冪等
同樣以轉賬為例,介紹一種類似於TCC的方法。TCC的方法通過TCC框架內部來做,下面介紹的方法是業務方自己實現的。
調用方維護一張事務狀態表(或者說事務日志、日志流水),在每次調用之前,落盤一條事務流水,生成一個全局的事務ID。事務狀態表的表結構如表1所示。
初始是狀態1,每調用成功1個服務則更新1次狀態,最后所有系統調用成功,狀態更新到狀態4,狀態2、3是中間狀態。當然,也可以不保存中間狀態,只設置兩個狀態:Begin和End。事務開始之前的狀態是Begin,全部結束之后的狀態是End。如果某個事務一直停留在Begin狀態,則說明該事務沒有執行完畢。
然后有一個后台任務,掃描狀態表,在過了某段時間后(假設1次事務執行成功通常最多花費30s),狀態沒有變為最終的狀態4,說明這條事務沒有執行成功。於是重新調用系統A、B、C。保證這條流水的最終狀態是狀態4(或End狀態)。當然,系統A、B、C根據全局的事務ID做冪等操作,所以即使重復調用也沒有關系。
補充說明:
(1)如果后台任務重試多次仍然不能成功,要為狀態表加一個Error狀態,通過人工介入干預。
(2)對於調用方的同步調用,如果部分成功,此時給客戶端返回什么呢?
答案是不確定,或者說暫時未知。只能告訴用戶該筆錢轉賬超時,請稍后再來確認。
(3)對於同步調用,調用方調用A或B失敗的時候,可以重試三次。如果重試三次還不成功,則放棄操作,再交由后台任務后續處理。
6 對賬
把上一節的方案擴展一下,豈止事務有狀態,系統中的各種數據對象都有狀態,或者說都有各自完整的生命周期,同時數據與數據之間存在着關聯關系。我們可以很好地利用這種完整的生命周期和數據之間的關聯關系,來實現系統的一致性,這就是“對賬”。
在前面,我們把注意力都放在了“過程”中,而在“對賬”的思路中,將把注意力轉移到“結果”中。什么意思呢?
在前面的方案中,無論最終一致性,還是TCC、事務狀態表,都是為了保證“過程的原子性”,也就是多個系統操作(或系統調用),要么全部成功,要么全部失敗。
但所有的“過程”都必然產生“結果”,過程是我們所說的“事務”,結果就是業務數據。一個過程如果部分執行成功、部分執行失敗,則意味着結果是不完整的。從結果也可以反推出過程出了問題,從而對數據進行修補,這就是“對賬”的思路!
下面舉幾個對賬的例子。
案例1:電商網站的訂單履約系統。一張訂單從“已支付”,到“下發給倉庫”,到“出倉完成”。假定從“已支付”到“下發給倉庫”最多用1個小時;從“下發給倉庫”到“出倉完成”最多用8個小時。意味着只要發現1個訂單的狀態過了1個小時之后還處於“已支付”狀態,就認為訂單下發沒有成功,需要重新下發,也就是“重試”。同樣,只要發現訂單過了8個小時還未出倉,這時可能會發出報警,倉庫的作業系統是否出了問題……諸如此類。
這個案例跟事務的狀態很類似:一旦發現系統中的某個數據對象過了一個限定時間生命周期仍然沒有走完,仍然處在某個中間狀態,就說明系統不一致了,要進行某種補償操作(比如重試或報警)。
更復雜一點:訂單有狀態,庫存系統的庫存也有狀態,優惠系統的優惠券也有狀態,根據業務規則,這些狀態之間進行比對,就能發現系統某個地方不一致,做相應的補償。
案例2:微博的關注關系。需要存兩張表,一張是關注表,一張是粉絲表,這兩張表各自都是分庫分表的。假設A關注了B,需要先以A為主鍵進行分庫,存入關注表;再以B為主鍵進行分庫,存入粉絲表。也就是說,一次業務操作,要向兩個數據庫中寫入兩條數據,如何保證原子性?
案例3:電商的訂單系統也是分庫分表的。訂單通常有兩個常用的查詢維度,一個是買家,一個是賣家。如果按買家分庫,按賣家查詢就不好做;如果按賣家分庫,按買家查詢就不好做。這種通常會把訂單數據冗余一份,按買家進行分庫分表存一份,按賣家再分庫分表存一份。和案例2存在同樣的問題:一個訂單要向兩個數據庫中寫入兩條數據,如何保證原子性?
如果把案例2、案例3的問題看作為一個分布式事務的話,可以用最終一致性、TCC、事務狀態表去實現,但這些方法都太重,一個簡單的方法是“對賬”。
因為兩個庫的數據是冗余的,可以先保證一個庫的數據是准確的,以該庫為基准校對另外一個庫。
對賬又分為全量對賬和增量對賬:
(1)全量對賬。比如每天晚上運作一個定時任務,比對兩個數據庫。
(2)增量對賬。可以是一個定時任務,基於數據庫的更新時間;也可以基於消息中間件,每一次業務操作都拋出一個消息到消息中間件,然后由一個消費者消費這條消息,對兩個數據庫中的數據進行比對(當然,消息可能丟失,無法百分之百地保證,還是需要全量對賬來兜底)。
總之,對賬的關鍵是要找出“數據背后的數學規律”。有些規律比較直接,誰都能看出來,比如案例2、案例3的冗余數據庫;有些規律隱含一些,比如案例1的訂單履約的狀態。找到了規律就可以基於規律進行數據的比對,發現問題,然后補償。
7. 妥協方案:弱一致性+基於狀態的補償
可以發現:
“最終一致性”是一種異步的方法,數據有一定延遲;
TCC是一種同步方法,但TCC需要兩個階段,性能損耗較大;
事務狀態表也是一種同步方法,但每次要記事務流水,要更新事務狀態,很煩瑣,性能也有損耗;
“對賬”也是一個事后過程。
如果需要一個同步的方案,既要讓系統之間保持一致性,又要有很高的性能,支持高並發,應該怎么處理呢?
如圖10-8所示,電商網站的下單至少需要兩個操作:創建訂單和扣庫存。訂單系統有訂單的數據庫和服務,庫存系統有庫存的數據庫和服務。先創建訂單,后扣庫存,可能會創建訂單成功,扣庫存失敗;反過來,先扣庫存,后創建訂單,可能會扣庫存成功,創建訂單失敗。如何保證創建訂單 + 扣庫存兩個操作的原子性,同時還要能抵抗線上的高並發流量?
如果用最終一致性方案,因為是異步操作,如果庫存扣減不及時會導致超賣,因此最終一致性的方案不可行;如果用TCC方案,則意味着一個用戶請求要調用兩次(Try和Confirm)訂單服務、兩次(Try和Confirm)庫存服務,性能又達不到要求。如果用事務狀態表,要寫事務狀態,也存在性能問題。
既要滿足高並發,又要達到一致性,魚和熊掌不能兼得。可以利用業務的特性,采用一種弱一致的方案。
對於該需求,有一個關鍵特性:對於電商的購物來講,允許少賣,但不能超賣。比如有100件東西,賣給99個人,有1件沒有賣出去,這是可以接受的;但如果賣給了101個人,其中1個人拿不到貨,平台違約,這就不能接受。而該處就利用了這個特性,具體做法如下。
方案1:先扣庫存,后創建訂單。
如表2所示,有三種情況:
(1)扣庫存成功,提交訂單成功,返回成功。
(2)扣庫存成功,提交訂單失敗,返回失敗,調用方重試(此處可能會多扣庫存)。
(3)扣庫存失敗,不再提交訂單,返回失敗,調用方重試(此處可能會多扣庫存)。
方案2:先創建訂單,后扣庫存。
如表3所示,也有三種情況:
(1)提交訂單成功,扣庫存成功,返回成功。
(2)提交訂單成功,扣庫存失敗,返回失敗,調用方重試(此處可能會多扣庫存)。
(3)提交訂單失敗,不再扣庫存,調用方重試。
無論方案1,還是方案2,只要最終保證庫存可以多扣,不能少扣即可。
但是,庫存多扣了,數據不一致,怎么補償呢?
庫存每扣一次,都會生成一條流水記錄。這條記錄的初始狀態是“占用”,等訂單支付成功后,會把狀態改成“釋放”。
對於那些過了很長時間一直是占用,而不釋放的庫存,要么是因為前面多扣造成的,要么是因為用戶下了單但沒有支付。
通過比對,得到庫存系統的“占用又沒有釋放的庫存流水”與訂單系統的未支付的訂單,就可以回收這些庫存,同時把對應的訂單取消。類似12306網站,過一定時間不支付,訂單會取消,將庫存釋放。
8. 妥協方案:重試+回滾+報警+人工修復
上文介紹了基於訂單的狀態 +庫存流水的狀態做補償(或者說叫對賬)。如果業務很復雜,狀態的維護也很復雜,就可以采用下面這種更加妥協而簡單的方法。
按方案1,先扣庫存,后創建訂單。不做狀態補償,為庫存系統提供一個回滾接口。創建訂單如果失敗了,先重試。如果重試還不成功,則回滾庫存的扣減。如回滾也失敗,則發報警,進行人工干預修復。
總之,根據業務邏輯,通過三次重試或回滾的方法,最大限度地保證一致。實在不一致,就發報警,讓人工干預。只要日志流水記錄得完整,人工肯定可以修復!通常只要業務邏輯本身沒問題,重試、回滾之后還失敗的概率會比較低,所以這種辦法雖然丑陋,但很實用。
9. 總結
本章總結了實踐中比較可靠的七種方法:兩種最終一致性的方案,兩種妥協辦法,兩種基於狀態 + 重試 + 冪等的方法(TCC,狀態機+重試+冪等),還有一種對賬方法。
在實現層面,妥協和對賬的辦法最容易,最終一致性次之,TCC最復雜。
原文:https://blog.csdn.net/uxiAD7442KMy1X86DtM3/article/details/88968532