分散式事務的這些常見用法都有坑,來看看正確姿勢

yedf 發表於 2021-11-30

隨著微服務架構的流行,隨之而來就必然遇到跨服務的分散式事務這個難題。分散式事務之所以難,主要是因為分散式系統中的各個節點都可能發生各種非預期的情況。本文先介紹分散式系統中的異常問題,然後介紹這些問題帶給分散式事務的挑戰,接下來指出現有各種常見用法的問題,最後給出正確的方案。

NPC 的挑戰

分散式系統最大的敵人可能就是 NPC 了,在這裡它是 Network Delay, Process Pause, Clock Drift 的首字母縮寫。我們先看看具體的 NPC 問題是什麼:

  • Network Delay,網路延遲。雖然網路在多數情況下工作的還可以,雖然 TCP 保證傳輸順序和不會丟失,但它無法消除網路延遲問題。
  • Process Pause,程式暫停。有很多種原因可以導致程式暫停:比如程式語言中的 GC(垃圾回收機制)會暫停所有正在執行的執行緒;再比如,我們有時會暫停雲伺服器,從而可以在不重啟的情況下將雲伺服器從一臺主機遷移到另一臺主機。我們無法確定性預測程式暫停的時長,你以為持續幾百毫秒已經很長了,但實際上持續數分鐘之久程式暫停並不罕見。
  • Clock Drift,時鐘漂移。現實生活中我們通常認為時間是平穩流逝,單調遞增的,但在計算機中不是。計算機使用時鐘硬體計時,通常是石英鐘,計時精度有限,同時受機器溫度影響。為了在一定程度上同步網路上多個機器之間的時間,通常使用 NTP 協議將本地裝置的時間與專門的時間伺服器對齊,這樣做的一個直接結果是裝置的本地時間可能會突然向前或向後跳躍。

分散式事務既然是分散式的系統,自然也有 NPC 問題。因為沒有涉及時間戳,帶來的困擾主要是 NP。

TCC 的空補償與懸掛

我們以分散式事務中的 TCC(如果是對 TCC 還不瞭解的同學,可以參考這篇文章,分散式事務最經典的七種解決方案,瞭解分散式事務相關的基礎知識)作為例子,看看 NP 帶來的影響。

一般情況下,一個 TCC 回滾時的執行順序是,先執行完 Try,再執行 Cancel,但是由於 N,則有可能 Try 的網路延遲大,導致先執行 Cancel,再執行 Try。

這種情況就引入了分散式事務中的兩個難題:

  1. 空補償:Cancel 執行時,Try 未執行,事務分支的 Cancel 操作需要判斷出 Try 未執行,這時需要忽略 Cancel 中的業務資料更新,直接返回
  2. 懸掛:Try 執行時,Cancel 已執行完成,事務分支的 Try 操作需要判斷出 Cancel 一致性,這時需要忽略 Try 中的業務資料更新,直接返回

分散式事務還有一類需要處理的常見問題,就是重複請求,業務需要做冪等處理。因為空補償、懸掛、重複請求都跟 NP 有關,我們把他們統稱為子事務亂序問題。在業務處理中,需要小心處理好這三種問題,否則會出現錯誤資料。

現有方案的問題

我們看到開源專案https://github.com/yedf/dtm之外,包括各雲廠商,各開源專案,他們給出的業務實現建議大多類似如下:

  • 空補償:“針對該問題,在服務設計時,需要允許空補償,即在沒有找到要補償的業務主鍵時,返回補償成功,並將原業務主鍵記錄下來,標記該業務流水已補償成功。”
  • 防懸掛:“需要檢查當前業務主鍵是否已經在空補償記錄下來的業務主鍵中存在,如果存在則要拒絕執行該筆服務,以免造成資料不一致。”

上述的這種實現,能夠在大部分情況下正常執行,但是上述做法中的 “先查後改” 在併發情況下是容易掉坑裡的,我們分析一下如下場景:

  • 正常執行順序下,Try 執行時,在查完沒有空補償記錄的業務主鍵之後,事務提交之前,如果發生了程式暫停 P,或者事務內部進行網路請求出現了擁塞,導致本地事務等待較久
  • 全域性事務超時後,Cancel 執行,因為沒有查到要補償的業務主鍵,因此判斷是空補償,直接返回
  • Try 的程式暫停結束,最後提交本地事務
  • 全域性事務回滾完成後,Try 分支的業務操作沒有被回滾,產生了懸掛

事實上,NPC 裡的 P 和 C,以及 P 和 C 的組合,有很多種的場景,都可以導致上述競態情況,就不一一贅述了。

雖然這種情況發生的概率不高,但是在金融領域,一旦涉及金錢賬目,那麼帶來的影響可能是巨大的。

PS:冪等控制如果也採用 “先查再改”,也是一樣很容易出現類似的問題。解決這一類問題的關鍵點是要利用唯一索引,“以改代查” 來避免競態條件。

正確姿勢

下面我們來詳解 yedf/dtm 是如何解決這個問題的。

dtm 首創了子事務屏障技術,用於同時解決空補償、防懸掛、冪等這三個問題,對於 TCC 事務,他的詳細工作過程如下:

  1. 在本地資料庫中建立好子事務屏障表 dtm_barrier.barrier,唯一索引為 gid-branchid-branchop
  2. 對於 Try、Confirm、Cancel 操作,insert ignore 一條記錄 gid-branchid-try|confirm|cancel,如果影響行數為 0(重複請求、懸掛),直接提交返回
  3. 對於 Cancel 操作額外再 insert ingore 一條記錄 gid-branchid-try,如果影響行數為 1(空補償),直接提交返回
  4. 執行業務邏輯並提交返回,如果業務發生錯誤則回滾

假如 Try 和 Cancel 的執行時間沒有重疊,那麼讀者容易分析出上述過程能夠解決空補償和懸掛問題。如果出現了 Try 和 Cancel 執行時間重疊的情況,我們看看會發生什麼。

假設 Try 和 Cancel 併發執行,Cancel 和 Try 都會插入同一條記錄 gid-branchid-try,由於唯一索引衝突,那麼兩個操作中只有一個能夠成功,而另一個則會等持有鎖的事務完成後返回。

  • 情況 1,Try 插入 gid-branchid-try 失敗,Cancel 操作插入 gid-branchid-try 成功,此時就是典型的空補償和懸掛場景,按照子事務屏障演算法,Try 和 Cancel 都會直接返回
  • 情況 2,Try 插入 gid-branchid-try 成功,Cancel 操作插入 gid-branchid-try 失敗,按照上述子事務屏障演算法,會正常執行業務,而且業務執行的順序是 Try 在 Cancel 前
  • 情況 3,Try 和 Cancel 的操作在重疊期間又遇見當機等情況,那麼至少 Cancel 會被 dtm 重試,那麼最終會走到情況 1 或 2。

綜上各種情況的詳細論述,子事務屏障能夠在各種 NP 情況下,保證最終結果的正確性。

事實上,子事務屏障有大量優點,包括:

  • 兩個 insert 判斷解決空補償、防懸掛、冪等這三個問題,比其他方案的三種情況分別判斷,邏輯複雜度大幅降低
  • dtm 的子事務屏障是 SDK 層解決這三個問題,業務完全不需要關心
  • 效能高,對於正常完成的事務(一般失敗的事務不超過 1%),子事務屏障的額外開銷是每個分支操作一個 SQL,比其他方案代價更小。

上述的理論與分析過程也同樣適用於 SAGA 分散式事務。dtm 裡面的子事務屏障同時支援了 TCC 和 SAGA 兩種事務模式。

完整的解決方案

DTM 是一款 golang 開發的分散式事務管理器,解決了跨資料庫、跨服務、跨語言棧更新資料的一致性問題。

下面是 dtm 和阿里開源的 seata 的主要特性對比:

特性 DTM SEATA 備註
支援語言 Go、Java、python、php、c#... Java dtm 可輕鬆接入一門新語言
異常處理 子事務屏障自動處理 手動處理 dtm 解決了冪等、懸掛、空補償
TCC 事務
XA 事務
AT 事務 建議使用 XA AT 與 XA 類似,效能更好,但有髒回滾
SAGA 事務 支援併發 狀態機模式
事務訊息 dtm 提供類似 rocketmq 的事務訊息
單服務多資料來源
通訊協議 HTTP、gRPC dubbo 等協議 dtm 對雲原生更加友好

如果您的語言棧包含了 Java 之外的語言,那麼 dtm 是您的首選。如果您的語言棧是 Java,您也可以選擇接入 dtm,使用子事務屏障技術,簡化您的業務編寫,可以參考用 Java 輕鬆完成一個 TCC 分散式事務,自動處理空補償、懸掛、冪等

如果您想要學習分散式事務相關的知識,dtm 的文件備受好評,能夠讓讀者快速入門分散式事務,理論結合實踐,讓讀者逐步深入。

歡迎大家訪問https://github.com/yedf/dtm,歡迎 Issue、PR、Star

更多原創文章乾貨分享,請關注公眾號
  • 分散式事務的這些常見用法都有坑,來看看正確姿勢
  • 加微信實戰群請加微信(註明:實戰群):gocnio