【CAP】(轉)分布式事務最佳實踐方案匯總

說到分布式事務,就會談到那個經典的”賬號轉賬”問題:2個賬號,分布處于2個不同的DB,對應2個不同的系統A,B。A要扣錢,B要加錢,如何保證原子性?

傳統方案 – 2PC

(1)2PC的理論層面:

2pc涉及到2個階段,3個操作:

階段1:“準備提交”。事務協調者向所有參與者發起prepare,所有參與者回答yes/no。

階段2:“正式提交”。如果所有參與者都回答yes,則向所有參與者發起commit;否則,向所有參與者發起rollback。

因此,要實現2pc,所有參與者,都得實現3個接口:prepare/commit/rollback。

(2)2PC的實現層面

對應的實現層面,也就是XA協議,通常的數據庫都實現了這個協議。

有一個Atomikos開源庫,提供了2PC的實現方案。有興趣的可以去看一下如何使用。

(3)2PC的問題

問題1:階段2,事務協調者掛了,則所有參與者接受不到commit/rollback指令,將處于“懸而不決”狀態

問題2:階段2,其中一個參與者超時或者出錯,那其他參與者,是commit,還是rollback呢? 也不能確定

為了解決2pc的問題,又引入3pc。3pc有類似的掛了如何解決的問題,因此還是沒能徹底解決問題,此處就不詳述了。

問題3:2PC的實現,目前主要是用在數據庫層面(數據庫實現了XA協議)。但目前,大家基本都是微服務架構,不會直接在2個業務DB之間搞一致性,而是想如何在2個服務上面實現一致性。

正因為2PC有上面諸多問題和不便,實踐中一般很少使用,而是采用下面將要講的各種方案。

最終一致性

一般的思路都是通過消息中間件來實現“最終一致性”:A系統扣錢,然后發條消息給中間件,B系統接收此消息,進行加錢。

但這里面有個問題:A是先update DB,后發送消息呢? 還是先發送消息,后update DB?

假設先update DB成功,發送消息網絡失敗,重發又失敗,怎么辦?

假設先發送消息成功,update DB失敗。消息已經發出去了,又不能撤回,怎么辦?

所以,這里下個結論: 只要發送消息和update DB這2個操作不是原子的,無論誰先誰后,都是有問題的。

那這個問題怎么解決呢??

錯誤的方案

有人可能想到了,我可以把“發送消息”這個網絡調用和update DB放在同1個事務里面,如果發送消息失敗,update DB自動回滾。這樣不就保證2個操作的原子性了嗎?

這個方案看似正確,其實是錯誤的,原因有2:

(1)網絡的2將軍問題:發送消息失敗,發送方并不知道是消息中間件真的沒有收到消息呢?還是消息已經收到了,只是返回response的時候失敗了?

如果是已經收到消息了,而發送端認為沒有收到,執行update db的回滾操作。則會導致A賬號的錢沒有扣,B賬號的錢卻加了。

(2)把網絡調用放在DB事務里面,可能會因為網絡的延時,導致DB長事務。嚴重的,會block整個DB。這個風險很大。

基于以上分析,我們知道,這個方案其實是錯誤的!

方案1 – 最終一致性(業務方自己實現)

假設消息中間件沒有提供“事務消息”功能,比如你用的是Kafka。那如何解決這個問題呢?

解決方案如下:

(1)Producer端準備1張消息表,把update DB和insert message這2個操作,放在一個DB事務里面。

(2)準備一個后臺程序,源源不斷的把消息表中的message傳送給消息中間件。失敗了,不斷重試重傳。允許消息重復,但消息不會丟,順序也不會打亂。

(3)Consumer端準備一個判重表。處理過的消息,記在判重表里面。實現業務的冪等。但這里又涉及一個原子性問題:如何保證消息消費 + insert message到判重表這2個操作的原子性?

消費成功,但insert判重表失敗,怎么辦?關于這個,在Kafka的源碼分析系列,第1篇, exactly once問題的時候,有過討論。

通過上面3步,我們基本就解決了這里update db和發送網絡消息這2個操作的原子性問題。

但這個方案的一個缺點就是:需要設計DB消息表,同時還需要一個后臺任務,不斷掃描本地消息。導致消息的處理和業務邏輯耦合額外增加業務方的負擔。

方案2 – 最終一致性(RocketMQ 事務消息)

為了能解決該問題,同時又不和業務耦合,RocketMQ提出了“事務消息”的概念。

具體來說,就是把消息的發送分成了2個階段:Prepare階段和確認階段。

具體來說,上面的2個步驟,被分解成3個步驟:

(1) 發送Prepared消息

(2) update DB

(3) 根據update DB結果成功或失敗,Confirm或者取消Prepared消息。

可能有人會問了,前2步執行成功了,最后1步失敗了怎么辦?這里就涉及到了RocketMQ的關鍵點:RocketMQ會定期(默認是1分鐘)掃描所有的Prepared消息,詢問發送方,到底是要確認這條消息發出去?還是取消此條消息?

總結:對比方案2和方案1,RocketMQ最大的改變,其實就是把“掃描消息表”這個事情,不讓業務方做,而是消息中間件幫著做了。

至于消息表,其實還是沒有省掉。因為消息中間件要詢問發送方,事物是否執行成功,還是需要一個“變相的本地消息表”,記錄事物執行狀態。

人工介入

可能有人又要說了,無論方案1,還是方案2,發送端把消息成功放入了隊列,但消費端消費失敗怎么辦?

消費失敗了,重試,還一直失敗怎么辦?是不是要自動回滾整個流程?

答案是人工介入。從工程實踐角度講,這種整個流程自動回滾的代價是非常巨大的,不但實現復雜,還會引入新的問題。比如自動回滾失敗,又怎么處理?

對應這種極低概率的case,采取人工處理,會比實現一個高復雜的自動化回滾系統,更加可靠,也更加簡單。

方案3:TCC

為了解決SOA系統中的分布式事務問題,支付寶提出了TCC。2PC通常都是在跨庫的DB層面,而TCC本質就是一個應用層面的2PC。

同樣,TCC中,每個參與者需要3個操作:Try/Confirm/Cancel,也是2個階段。

階段1:”資源預留/資源檢查“,也就是事務協調者調用所有參與者的Try操作

階段2:“一起提交”。如果所有的Try成功,一起執行Confirm。否則,所有的執行Cancel.

TCC是如何解決2PC的問題呢?

關鍵:Try階段成功之后,Confirm如果失敗(不管是協調者掛了,還是某個參與者超時),不斷重試!!

同樣,Cancel失敗了,也是不斷重試。這就要求Confirm/Cancel都必須是冪等操作。

下面以1個轉賬case為例,來說明TCC的過程:

有3個賬號A, B, C,通過SOA提供的轉賬服務操作。A, B同時分別要向C轉30, 50元,最后C的賬號+80,A, B各減30, 50。

階段1:A賬號鎖定30,B賬號鎖定50,檢查C賬號的合法性(比如C賬號是否違法被凍結,C賬號是否已注銷。。。)。

所以,對應的“扣錢”的Try操作就是”鎖定”,對應的“加錢”的Try操作就是檢查賬號合法性

階段2:A, B, C都Try成功,執行Confirm。即A, B減錢,C加錢。如果任意一個失敗,不斷重試!

從上面的案例可以看出,Try操作主要是為了“保證業務操作的前置條件都得到滿足”,然后在Confirm階段,因為前置條件都滿足了,所以可以不斷重試保證成功。

方案4:事務狀態表 + 調用方重試 + 接收方冪等 (同步 + 異步)

同樣以上面的轉賬為例:調用方調系統A扣錢,系統B加錢,如何保證2個同時成功?

調用方維護1張事務狀態表(或者說事務日志,日志流水),每次調用之前,落盤1條事務流水,生成1個全局的事務ID。

初始狀態是Init,每調用成功1個系統更新1次狀態(這里就2個系統),最后所有系統調用成功,狀態更新為Success。

當然,你也可以不保存中間狀態,簡單一點,你也可以只設置2個狀態:Init/Success,或者說begin/end。

然后有個后臺任務,發現某條流水,在過了某個時間之后(假設1次事務執行成功通常最多花費30s),狀態仍然是Init,那就說明這條流水有問題。就重新調用系統A,系統B,保證這條流水的最終狀態是Success。當然,系統A, 系統B根據這個全局的事務ID,做冪等,所以重復調用也沒關系。

這就是通過同步調用 + 后臺任務異步補償,最終保證系統一致性。

補充說明:

(1)如果后臺任務重試多次,仍然不能成功,那要為狀態表加1個Error狀態,要人工介入干預了。

(2)對于調用方的同步調用,如果部分成功,此時給客戶端返回什么呢?

答案是不確定,或者說暫時未知。你只能告訴用戶,該筆轉賬超時,稍后再來確認。

(3)對于同步調用,調用方調用A,或者B失敗的時候,可以重試3次。重試3次還不成功,放棄操作。再交由后臺任務后續處理。

方案4的擴展:狀態機 + 對賬

把方案4擴展一下,豈止事務有狀態,系統中的各種數據對象都有狀態,或者說都有各自完整的生命周期。

這種完整的生命周期,天生就具有校驗功能!!!我們可以很好的利用這個特性,來實行系統的一致性。

一旦我們發現系統中的某個數據對象,過了一個限定時間,生命周期仍然沒有走完,仍然處在某個中間狀態,那就說明系統不一致了,可以執行某種操作。

舉個電商系統的訂單的例子:一張訂單,從“已支付”,到“下發給倉庫”,到“出倉完成”。假定從“已支付”到“下發給倉庫”,最多用1個小時;從“下發給倉庫”到“出倉完成”,最多用8個小時。

那意味著:只要我發現1個訂單的狀態,過了1個小時之后,還是“已支付”,我就認為訂單下發沒有成功,我就重新下發,也就是上面所說的“重試”;

同樣,只要我發現訂單過了8個小時,還未出倉,我這個時候可能就會發報警出來,是不是倉庫的作業系統出了問題。。。諸如此類。

更復雜一點:訂單有狀態,庫存系統的庫存也有狀態,優惠系統的優惠券也有狀態,根據業務規則,這些狀態之間進行比對,就能發現系統某個地方不一致,做相應的補償行為。

上面說的“最終一致性”和TCC、狀態機+對賬,都是比較“完美”的方案,能完全保證數據的一致性。

但是呢,最終一致性這個方案是異步的;

TCC需要2個階段,性能損耗大;

事務狀態表,或者狀態機,每次要記事務流水,要更新狀態,性能也有損耗。

如果我需要1個同步的方案,可以立馬得到結果,同時又要有很高的性能,支持高并發,那怎么處理呢?

方案5:妥協方案 – 弱一致性 + 基于狀態的補償

舉個典型場景:

電商網站的下單,扣庫存。訂單系統有訂單的DB,訂單的服務;庫存系統有庫存的DB,庫存的服務。 如何保證下單 + 扣庫存,2個的原子性呢?

如果用上面的最終一致性方案,因為是異步的,庫存扣減不及時,會導致超賣,因此最終一致性的方案不可行;

如果用TCC的方案,性能可能又達不到。

這里,就采用了一種弱一致的方案,什么意思呢?

對于該需求,有1個關鍵特性:對于電商的購物來講,允許少賣,但不能超賣。你有100件東西,賣給99個人,有1件沒有賣出去,這個可以接受;但是賣給了101個人,其中1個人拿不到貨,平臺違約,這個就不能接受。

而該處就利用了這個特性,具體是這么做的:

先扣庫存,再提交訂單。

(1)扣庫存失敗,不提交訂單了,直接返回失敗,調用方重試(此處可能會多扣庫存)

(2)扣庫存成功,提交訂單失敗,返回失敗,調用方重試(此處可能會多扣庫存)

(3)扣庫存成功,提交訂單成功,返回成功。

反過來,你先提交訂單,后扣庫存,也是按照類似的這個思路。

最終,只要保證1點:庫存可以多扣,不能少扣!!!

但是,庫存多扣了,這個數據不一致,怎么補償呢?

庫存每扣1次,都會生成1條流水記錄。這條記錄的初始狀態是“占用”,等訂單支付成功之后,會把狀態改成“釋放”。

對于那些過了很長時間,一直是占用,而不釋放的庫存。要么是因為前面多扣造成的,要么是因為用戶下了單,但不支付。

通過比對,庫存系統的“占用又沒有釋放的庫存流水“與訂單系統的未支付的訂單,我們就可以回收掉這些庫存,同時把對應的訂單取消掉。(就類似12306網站一樣,過多長時間,你不支付,訂單就取消了,庫存釋放)

方案6: 妥協方案 – 重試 + 回滾 + 監控報警 + 人工修復

對于方案5,我們是基于訂單的狀態 + 庫存流水的狀態,做補償(或者說叫對賬)。

如果業務很復雜,狀態的維護也很復雜。方案5呢,就是1種更加妥協而簡單的辦法。

提交訂單不是失敗了嘛!

先重試!

重試還不成功,回滾庫存的扣減!

回滾也失敗,發報警出來,人工干預修復!

總之,根據業務邏輯,通過重試3次,或者回滾的辦法,盡最大限度,保證一致。實在不一致,就發報警,讓人工干預。只要日志流水記錄的完整,人工肯定可以修復! (通常只要業務邏輯本身沒問題,重試、回滾之后,還失敗的概率會比較低,所以這種辦法雖然丑陋,但蠻實用)

后話

其他的,諸如狀態機驅動、1PC之類的辦法,只是說法不一,個人認為本質上都是方案4/方案5的做法。

總結

在上文中,總結了實踐中比較靠譜的6種方法:2種最終一致性的方案,2種妥協辦法,2種基于狀態 + 重試的方法(TCC,狀態機 + 重試 + 冪等)。

實現層面,妥協的辦法肯定最容易,TCC最復雜。

体彩25选5走势图