分散式事務 -- 最佳實踐方案匯總 -- 看這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最複雜。
後記
後記:有興趣朋友也可以進一步關注公眾號「架構之道與術」, 獲取更多乾貨文章。
推薦閱讀: