1. 程式人生 > >分散式事務 -- 最佳實踐方案彙總 -- 看這1篇就夠了

分散式事務 -- 最佳實踐方案彙總 -- 看這1篇就夠了

說到分散式事務,就會談到那個經典的”賬號轉賬”問題: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個操作不是原子的,無論誰先誰後,都是有問題的。

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

錯誤的方案0
有人可能想到了,我可以把“傳送訊息”這個網路呼叫和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最複雜。

有興趣朋友也可以進一步關注公眾號“架構之道與術”, 獲取原文。
或掃描如下二維碼:
這裡寫圖片描述