System Design·2026年6月12日·12 分鐘閱讀

分散式交易與最終一致性:Saga 與補償交易的實務

L
Louis Wu

後端工程師,在交易所與金流系統實作過 Saga 與補償交易,處理跨服務的最終一致性。

服務一旦拆開,最先碎掉的東西往往不是程式碼,而是「交易」這個概念。在單體時代,下訂單、扣庫存、扣款這三件事可以包在同一個資料庫交易裡,要嘛全成功、要嘛全回滾,乾淨俐落。但當訂單、庫存、金流變成三個獨立服務、各自有自己的資料庫,那個熟悉的 BEGIN 到 COMMIT 就再也圈不住它們了。這篇講我在交易所與金流系統裡,怎麼面對這個問題:為什麼最終一致性是被逼出來的選擇、Saga 兩種風格怎麼選、補償交易實際上怎麼設計,以及一個我反覆強調的原則——不要為了拆而拆。

為什麼微服務下不能用單一資料庫交易

單一資料庫交易之所以好用,是因為 ACID 那四個字母背後有一個前提:所有要被保護的資料,都在同一個資料庫引擎的掌控之下。引擎可以上鎖、可以維護一份統一的交易日誌、可以在任何一步失敗時把整批變更原子地撤回。

微服務把這個前提打掉了。訂單服務寫訂單庫、庫存服務寫庫存庫、金流服務寫金流庫,三個引擎彼此不認識對方。你沒辦法對「跨三個資料庫的一連串寫入」下一個 COMMIT,因為根本沒有一個元件同時握有這三份資料的鎖與日誌。

於是常見的錯誤就出現了:有人試圖在應用層手動串——先呼叫訂單服務寫入、再呼叫庫存服務扣減、再呼叫金流服務扣款,假裝這是一個交易。問題是中間任何一步失敗(網路斷、服務掛、逾時),前面已經成功的步驟並不會自己回去。訂單寫了、庫存扣了、款卻沒扣成,系統就停在一個誰也說不清的中間狀態。

跨服務的「一致」,沒辦法靠資料庫白送,你得自己設計流程去達成它。 這是整篇文章的起點。

2PC 為什麼在實務上少用

聽到跨多個資料庫要原子提交,很多人第一個想到的是兩階段提交,也就是 2PC。它的概念很直觀:有一個協調者,第一階段問所有參與者「你準備好了嗎」,大家都說準備好了,第二階段才一起喊 COMMIT;只要有一個說不行,就全部 ROLLBACK。理論上很美,但我在實務上幾乎不用它,原因有三個,每個都很致命。

  • 鎖被拉得很長:參與者在第一階段回報「準備好了」之後,必須一直把相關的資料鎖著,等協調者下最終指令。這段等待橫跨網路往返,在高併發系統裡,這種長時間持鎖會讓吞吐量直接崩掉。我做撮合、做搶購,最怕的就是鎖被拉長
  • 協調者是單點:整個流程的命運都壓在協調者身上。如果協調者在第二階段下指令前掛掉,所有參與者就卡在「我準備好了,但不知道該提交還是回滾」的狀態,鎖也放不掉,這叫「卡死」。要解決它又得再疊一層更複雜的容錯機制
  • 效能與耦合:兩階段、多次網路往返,延遲本來就高;而且它要求所有參與者都支援這套協定、在同一時間都活著、都願意配合。服務越多,這種「大家一起鎖、一起等」的同步協定就越脆弱

2PC 適合的是那種參與者少、在同一個資料中心、強一致性要求極高、又願意犧牲可用性的場景。但典型的網路服務追求的是高可用與高吞吐,2PC 的取捨方向正好相反。所以實務上,我們轉向另一條路:放棄「瞬間原子一致」,改追求「最終一致」。

最終一致性的思維轉換

這是最難的部分,難的不是技術,是觀念。

在單體交易裡,你習慣的是「一致是時時刻刻成立的」——任何一個瞬間去看資料,三張表都對得上。最終一致性要你接受一件事:會有一段時間,資料是不一致的,但只要流程正常推進,它終究會回到一致。

舉個訂單的例子。使用者按下結帳,我先把訂單建立成「處理中」狀態,這時候訂單存在了,但庫存還沒扣、款也還沒收。在這個瞬間,如果你問「這筆交易完成了嗎」,答案是還沒。系統處於一個合法的中間狀態。接著庫存扣了、款收了,訂單才翻成「已完成」。整個過程可能只有幾百毫秒,但那幾百毫秒裡,系統確實是「不一致」的。

思維轉換的核心是:你不再保證任何瞬間的一致,你保證的是流程的收斂。 一旦接受這點,你就會開始用完全不同的方式設計系統——你會去定義中間狀態、設計每一步失敗時的補救、思考「如果卡在中間,誰來把它推完或拉回」。這套東西,就是 Saga。

Saga 是什麼

Saga 的概念其實樸素:把一個大的分散式交易,拆成一連串各自獨立的本地交易。每一個本地交易只動自己服務的資料庫,可以乖乖用單一資料庫交易保證自己這一小步的原子性。這些小步驟串起來,組成完整的業務流程。

關鍵在於:因為沒有全域的回滾,所以每一個會改變狀態的步驟,都必須準備一個對應的「補償動作」,用來在後續步驟失敗時,把這一步造成的影響反向抵銷掉。正常路徑是一步步往前走;出事了,就反過來一步步補償回來。

Saga 有兩種實作風格,差別在於「誰來決定下一步該做什麼」。一種是編排,一種是協調。這兩個中文詞很容易搞混,我習慣用它們的英文來區分:choreography 與 orchestration。

Saga 風格一:編排(Choreography,事件驅動)

編排的精神是「沒有指揮,大家看舞步自己接」。沒有中央控制者,每個服務只做兩件事:完成自己的本地交易、然後發出一個事件;同時訂閱別人的事件,被觸發時就做自己該做的下一步。

以訂單流程為例:

  • 訂單服務建立「處理中」訂單,發出「訂單已建立」事件
  • 庫存服務訂閱到這個事件,扣減庫存,發出「庫存已預留」事件
  • 金流服務訂閱到「庫存已預留」,發起扣款,發出「付款成功」事件
  • 訂單服務訂閱到「付款成功」,把訂單翻成「已完成」

整條鏈就靠事件一棒接一棒地推進,服務之間沒有直接呼叫,彼此只認得事件。

編排的優點:

  • 耦合低:服務不需要知道彼此的存在,只認得事件。要加一個新步驟(例如加一個「發送通知」服務),它訂閱既有事件就好,不用改別人
  • 沒有單點:沒有一個中央協調器,不會因為它掛掉整條流程癱瘓

編排真實的坑:

  • 流程被打散,沒人看得到全貌:業務邏輯散落在各個服務的事件訂閱裡,沒有任何一個地方能一眼看出「這個下單流程到底有幾步、現在走到哪」。除錯時你得在好幾個服務的日誌之間跳來跳去拼湊
  • 容易踩到循環依賴:A 的事件觸發 B,B 的事件又回頭觸發 A,繞成一圈,這種隱性的環很難察覺
  • 補償鏈更難追:當第三步失敗,要靠失敗事件一路往回觸發前兩步的補償,這條反向鏈同樣是隱性的,出錯很難定位

我的經驗是:步驟少、流程穩定、團隊邊界清楚的時候,編排很舒服。 步驟一多、流程常改,那種「沒人看得到全貌」的痛就會慢慢浮現。

Saga 風格二:協調(Orchestration,中央協調器驅動)

協調的精神是「有一個指揮,按譜下令」。你寫一個中央協調器,由它持有整個流程的劇本:第一步呼叫訂單服務、成功了呼叫庫存服務、再成功了呼叫金流服務;任何一步失敗,就由它負責倒著呼叫前面的補償動作。

各個服務退回成單純的執行者,只負責「被叫到時做好自己這一步、回報成功或失敗」,完全不需要知道自己在整個流程裡的位置。

協調的優點:

  • 流程一目了然:整個交易的步驟、順序、失敗時怎麼補償,全部集中在協調器這一份程式碼裡。要看懂這個業務流程,讀協調器就夠了
  • 好除錯、好監控:協調器天然是個記錄流程狀態的好地方——走到第幾步、哪一步失敗、補償做到哪,都在它手上,要做狀態追蹤與告警都方便
  • 補償邏輯集中:反向補償的順序由協調器掌控,不像編排那樣靠隱性的事件鏈

協調真實的坑:

  • 協調器會變胖:業務邏輯都往它身上集中,流程一複雜,它就容易長成一個巨大、什麼都管的元件,反而變成新的維護負擔
  • 它是個要顧好的關鍵元件:雖然不像 2PC 協調者那樣會把大家鎖死,但它畢竟掌管流程推進。它需要把流程狀態持久化下來,這樣自己重啟後才能接著把卡在半路的交易推完。這點一定要做,否則協調器一掛,所有進行中的交易就懸在空中

我的選擇原則: 流程簡單、步驟少、想要鬆耦合,用編排;流程複雜、步驟多、需要清楚掌控與監控(金流尤其是),我傾向協調。在交易與金流相關的流程,我幾乎都選協調,因為「看得清楚、好對帳、好查問題」這幾件事,比那一點耦合度更重要。錢的事情,可觀測性永遠優先。

補償交易怎麼設計

Saga 的靈魂在補償。沒有全域回滾,所以每一個改了狀態的正向步驟,都要配一個能把它抵銷掉的補償步驟。

對應前面的訂單流程,補償大概長這樣:

  • 扣庫存的補償是「把庫存加回去」
  • 扣款的補償是「退款」
  • 建立訂單的補償是「把訂單標記為已取消」

流程走到一半失敗,協調器(或失敗事件)就從失敗點往回,依序執行已完成步驟的補償,把系統拉回一個乾淨的狀態。

但補償遠不只是「反向操作」這麼單純,有幾個原則是我用血換來的。

補償一定要冪等

這是最重要的一條。在分散式系統裡,重試是常態:補償指令可能因為網路逾時而你不確定它到底成沒成功,於是你重送一次。如果補償不是冪等的,「退款」這個補償被執行兩次,就退了兩次錢——本來要救火,結果放了把更大的火。

補償本身必須冪等:執行一次和執行多次,結果要一樣。 實作上常見的做法是給每個補償帶一個唯一的識別,服務端記錄「這個補償我處理過了」,再收到同一個就直接忽略、回報成功。退款尤其要這樣保護,重複退款在金流業務裡是嚴重事故。

補償不見得能完美還原

有些動作天生沒辦法乾淨地倒回去。最典型的是「已經發出去的東西」——通知寄出去了、簡訊發出去了,你沒辦法把它收回來。這種情況補償只能做「語意上的抵銷」,例如再補一封「前一筆交易已取消」的通知,而不是假裝前一封不存在。

設計補償時要誠實面對這點:有些步驟是不可逆的,那就盡量把不可逆的步驟往後排。 能晚做的副作用就晚做,讓它落在流程的最後,這樣前面萬一要補償,需要面對的不可逆動作就最少。把「寄通知」這種不可逆動作擺在「扣款成功」之後,就是這個道理。

正向步驟也要為補償著想

有時候補償難做,是因為正向步驟設計得太絕。比方說扣庫存時如果直接把數字減掉、不留任何痕跡,補償時你只能盲目加回去,無法判斷這次加是不是合法的、會不會重複加。如果正向步驟扣庫存時順手記下「這筆訂單預留了幾件」,補償時就能根據這筆記錄精準歸還,也天然支援了冪等判斷。正向與補償是一組來設計的,不是寫完正向才回頭補。

語意鎖與中間狀態

最終一致性意味著系統會經過一段「事情做到一半」的時期。如果不把這段時期明確標示出來,併發進來就會亂。

解法是語意鎖:用業務上的狀態欄位,把資源標記成一個中間狀態,告訴系統「這個東西正在被處理,別人先別動」。

最直白的例子就是訂單的「處理中」狀態。訂單一建立就是處理中,這個狀態本身就是一把語意鎖——它告訴所有人這筆訂單還沒塵埃落定。在這個狀態下,系統可以拒絕某些操作(例如不准對處理中的訂單再次發起結帳),等流程跑完翻成「已完成」或「已取消」,鎖才解除。

幾個搭配中間狀態要注意的點:

  • 狀態流轉要明確且單向:處理中可以往已完成或已取消走,但已完成不該再退回處理中。把合法的狀態轉換畫清楚,非法的轉換一律擋掉
  • 中間狀態要能查、要會逾時:如果一筆訂單卡在處理中太久(例如某個服務掛了沒人推它),要有機制能掃出這些「卡住的中間狀態」,觸發補償或人工處理。不能讓它無限期地懸著
  • 庫存的預留也是語意鎖:扣庫存時不要直接把實體庫存減掉,而是先標成「已預留」。預留是一種中間狀態,付款成功才轉成真正的扣減,付款失敗就釋放預留。這比「先硬扣、失敗再加回」更安全,因為預留天然帶著「這是暫時的、可被回收的」語意

補償失敗怎麼辦

這是真正考驗系統成熟度的問題,也是很多人設計 Saga 時跳過、然後在正式環境吃大虧的地方。補償也是一次跨網路的呼叫,它一樣會失敗。退款打過去逾時了、庫存服務正好掛了——補償本身失敗,怎麼辦?這時候系統就卡在一個「想回滾卻回不掉」的尷尬處境。我的處理是分層次的。

第一層:重試

大多數補償失敗是暫時性的——網路抖一下、服務忙不過來。所以第一道防線是自動重試,而且要用退避策略:失敗後等一下再試,間隔逐漸拉長,避免一直猛打一個已經喘不過氣的服務。這也正是前面強調補償必須冪等的原因——沒有冪等,重試本身就是新的災難來源。

第二層:人工介入

重試到一定次數還是失敗,就不能再傻試了。這種補償我會把它丟進一個「死信」之類的待處理區,並且發告警。這種狀態必須是顯眼的、會吵人的,不能默默吞掉。 我寧可半夜被叫醒處理一筆卡住的退款,也不要它無聲無息地爛在那裡,等使用者來客訴才發現。死信佇列裡的每一筆,都該被當成需要人去看的事件。

第三層:對帳兜底

這是金流系統的最後一道防線,也是我認為任何認真的金流系統都不能省的。無論前面的 Saga、補償、重試做得多好,分散式系統就是會有漏網之魚——某筆交易莫名其妙地兩邊對不上。所以一定要有獨立的對帳機制,定期把各個服務、各個外部金流管道的紀錄拉出來互相比對,找出「訂單顯示已付款,但金流方查無此筆」這類不一致,然後修正或人工處理。

對帳的價值在於:它不依賴流程當下有沒有跑對,它是事後的、獨立的真相校驗。 Saga 負責讓事情盡量當下就對,對帳負責把漏掉的撈回來。我做金流,這兩層一定都要有,缺一不可。流程內的補償是即時防線,對帳是終局防線。

什麼時候其實一個本地交易就夠

講了這麼多分散式交易的機制,我必須用力踩一下煞車,因為這是我看過最多人犯的錯:為了微服務而微服務,把本來能放在同一個服務、同一個資料庫交易裡的東西,硬拆成需要 Saga 的跨服務流程。

每一個 Saga 都是有成本的:你要設計補償、保證冪等、處理中間狀態、做死信與對帳。這套東西的複雜度遠高於一個 BEGIN 到 COMMIT。如果一個業務操作裡的幾張表,本來就高度相關、總是一起變動、又屬於同一個業務領域,那它們很可能就該待在同一個服務裡,用一個老老實實的本地交易解決,乾淨、原子、零補償。

我的判斷標準大致是:

  • 這些資料是不是同一個業務領域、同一個一致性邊界? 是的話,別拆。硬拆只是把本來資料庫白送的原子性,換成一堆你要自己維護的補償程式碼
  • 它們是不是真的需要獨立部署、獨立擴展、獨立演進? 如果只是「架構圖上看起來比較潮」,那不是拆的理由
  • 拆開後得到的好處,是否值得 Saga 的長期維護成本? 補償、冪等、對帳,每一樣都是要長期養的負擔

我看過團隊把一個簡單的下單流程拆成五個微服務,然後花大把力氣去維護一套複雜的 Saga,只為了完成一件單一資料庫交易十行內就能搞定的事。那不是進步,那是自找麻煩。最好的分散式交易,是你根本不需要分散式交易。 能用一個本地交易解決的,就別請出 Saga。

小結

微服務拆掉了單一資料庫交易那層保護,於是跨服務的一致只能自己設計。2PC 因為長鎖、單點、效能問題,在追求高可用的場景幾乎不被採用,於是我們轉向最終一致性,用 Saga 把大交易拆成一連串本地交易加補償:編排靠事件鬆耦合但難看全貌,協調靠中央協調器清楚可控但元件會變胖;補償必須冪等、要面對不可逆、要和正向步驟一起設計;中間狀態當語意鎖,補償失敗則靠重試、人工介入、對帳三層兜底。

但所有這些機制,都比不上一開始就把邊界畫對。先問清楚這真的是一個分散式交易,還是你把一個本地交易拆壞了——這個問題想明白,比任何 Saga 框架都值錢。

#分散式系統#Saga#最終一致性#微服務#金流#系統設計

相關文章