系統設計 · 田野手冊

分散式交易:在一致性與可用性之間鬆綁

當一筆業務操作橫跨多個服務、多個資料庫時,如何保證它「要嘛全部成功、要嘛全部失敗」?這份手冊用一條演化線串起 2PC、TCC、Saga 與訊息家族——它們不是並列的三選一,而是一路在強一致與最終一致之間逐步放手的取捨史。

2PC TCC Saga Outbox 面試導向
01

總覽與定位

是什麼、解決什麼痛、在系統設計版圖中的座標。

分散式交易要解的核心問題只有一句話:一筆業務操作橫跨多個服務或多個資料庫時,怎麼保證它的原子性(要嘛全成、要嘛全敗),不會卡在「一半成功一半失敗」的不一致狀態。

天真版:單體 + 單庫,ACID 直接搞定

在單一應用配單一資料庫的世界裡,這件事根本不需要你操心。你只要把所有寫入包進一個本地交易:

BEGIN;
  UPDATE account SET balance = balance - 100 WHERE id = 'A';
  UPDATE account SET balance = balance + 100 WHERE id = 'B';
COMMIT;   -- 兩個 UPDATE 一起成功,或一起失敗

資料庫的 ACID 保證了原子性與隔離性,你拿到的是免費的強一致。

問題:拆成微服務後,邊界把交易切斷了

一旦把系統拆成「訂單服務、帳務服務、庫存服務」各自擁有獨立資料庫,上面那個 BEGIN...COMMIT 就跨不過服務邊界了——帳務的 DB 連線無法 commit 庫存的 DB。每個服務只能管好自己的本地交易,沒有人手上握著一把能一次提交所有人的鎖。

座標分散式交易在版圖中的位置

它本質上是一個一致性協調問題,而非單一技術。所有方案都是在 CAP 的取捨下,用不同手段去逼近「跨服務的原子性」。理解它的關鍵,是先接受「沒有免費的強一致」這件事——這正是第 2 節的主題。

一句話答法分散式交易就是「跨多個獨立資料源、保證一組操作原子性」的協調機制;單庫靠 ACID,多服務只能靠 2PC / TCC / Saga 等方案在一致性與可用性之間做取捨。

02

為什麼沒有銀彈

先架好一致性光譜這個座標系,後面三個方案才知道自己站在哪。

很多人把 2PC / TCC / Saga 當成三個平行的選項,這是誤解。它們其實落在同一條取捨光譜上:愈往左愈強一致、但鎖得久、吞吐低、可用性差;愈往右愈鬆綁、無鎖、吞吐高、可用性好,代價是只能保證最終一致。

這背後是 CAP 的現實:當網路可能分區(P 無可避免),你只能在一致性(C)與可用性(A)之間偏向一邊。2PC 偏 CP,Saga 偏 AP,TCC 在中間。

光譜上的位置不是形容詞,而是可以估出來的成本。下面的 demo 只用四個參數,套一組刻意簡化的延遲模型,算出三個方案各自付出什麼——你會看到它們在「會不會被讀到一半」與「熱點資料列撐得住多少併發」這兩軸上此消彼長,而且落點會隨網路延遲與參與者數量移動。

互動 · 一致性光譜 調參數,看三個方案的取捨落點怎麼移動

模型刻意簡化(f 為協調者寫決議日誌,固定 2 ms):2PC 從 Prepare 的本地執行一路鎖到收到 Commit,熱點列被鎖 w + RTT + f;TCC 的 Try 與 Confirm 是兩次各自提交的本地交易,熱點列只被鎖 2w,但「凍結」狀態會被看見約 w + RTT + f;Saga 每步立刻提交,只鎖 w,代價是從第一步到最後一步提交之間 (N−1)(RTT + w) 的中間態。同步可用性是「交易當下必須同時在線的角色」的可用性相乘。試著把 RTT 拉到 1 ms:當 RTT + f 小於 w,2PC 的鎖反而比 TCC 短。

一句話答法沒有銀彈,只有取捨:選方案等於選你願意在「強一致 ↔ 高可用」這條軸上站哪裡。

03

2PC 兩階段提交

最接近單庫直覺的解法——強一致、對業務無侵入,但代價也最重。

核心機制

引入一個協調者(Coordinator / TC),把提交切成兩個階段:

  • Prepare(準備):協調者問所有參與者「準備好提交了嗎?」參與者執行操作但不提交、鎖住資源,回覆 Yes / No。
  • Commit(提交):若全部回 Yes,協調者下令全體 Commit;只要有一個 No,就全體 Rollback。
互動 · 2PC 狀態機 設定投票與崩潰點,逐 tick 推進(1 則訊息走 1 tick)
參與者持鎖(已投 YES、等決議) 協調者不在線時仍持鎖/遺失的訊息 虛線箭頭:傳送中

先用預設值走一次正常流程:鎖從參與者執行 PREPARE 開始,到收到 COMMIT 才釋放。再把崩潰點切到「決議寫入日誌後、送出前」:兩個參與者都投了 YES、都沒收到決議,打開互詢也沒用——誰都不知道結果,只能鎖著等協調者回來,這就是 2PC 被稱為阻塞協定的原因。改成「決議只送到帳務」再打開互詢,庫存就能從帳務問到答案、提早解鎖;把任一方的投票改成 NO,崩潰也不再造成長時間阻塞,因為投 NO 的一方可以單方面中止,也答得出別人的詢問。

天真版 → 問題

2PC 的直覺很美:行為跟單庫的 commit 幾乎一樣,對業務透明(資料庫層用 XA 協議就能跑)。但它有三個致命缺陷:

  • 同步阻塞:Prepare 後資源被鎖住,得等所有人回應才能釋放,併發吞吐極低。
  • 協調者單點故障:協調者在 Commit 階段掛掉,參與者卡在「不知該 Commit 還是 Rollback」的懸置狀態。參與者之間互相詢問只在「還有人知道結果」時有用;全員投了 Yes 之後協調者消失,誰也不能自行決定。
  • 資料不一致風險:Commit 指令只送達部分參與者時協調者崩潰或網路斷掉,在恢復之前一邊已提交、一邊仍懸而未決,外界會讀到不一致的中間狀態。
陷阱常見誤區

以為「3PC 解決了所有問題」。3PC 多了 CanCommit 階段與 timeout,緩解了阻塞與單點,但無法在網路分區下徹底消除不一致,且複雜度更高,實務上幾乎不用。

使用情境與限制

適合一致性要求極高、併發不大、且不跨組織的場景:傳統金融行內系統、跨庫但同一組織的操作。常搭配支援 XA 的關聯式資料庫與 JTA 交易管理員。微服務世界因為它的阻塞特性,已越來越少用。

一句話答法2PC 用協調者把提交拆成 Prepare / Commit 兩步換取強一致,但代價是同步阻塞與協調者單點,吞吐差、微服務少用。

04

TCC(Try-Confirm-Cancel)

把 2PC 的概念上提到業務層,用「資源預留」取代「資料庫長鎖」。

核心機制

每個服務要實作三個介面:

  • Try:嘗試執行,做資源的預留 / 凍結(扣款先凍結額度、扣庫存先扣到「凍結庫存」),但不真正生效。
  • Confirm:所有 Try 成功後,正式確認(把凍結的真正扣掉)。
  • Cancel:任一 Try 失敗,釋放預留的資源。

它不靠資料庫鎖,而是用業務上的「軟狀態(凍結)」,所以沒有長時間的資源鎖定,效能比 2PC 好;又因為有 Try 階段確認可行性才提交,一致性比 Saga 強。

關鍵不會出現「Confirm 完又 Cancel」

全域決策是二元且有順序的:協調者等所有參與者 Try 都成功才進入 Confirm;只要有一個 Try 失敗就全體 Cancel,此時還沒有任何服務 Confirm。你要嘛全 Confirm、要嘛全 Cancel。

可靠性從何而來?(服務掛了怎麼辦)

關鍵觀念:TCC 的可靠性不依賴單一服務不掛掉。若某服務 Try 成功、協調者決議 Cancel,但它在執行 Cancel 時異常關閉——此時資源停在「凍結」狀態,這是安全的中間態,不是不一致。可靠性來自三件事的組合:

  • 協調者的持久化事務日誌:下達 Cancel 前先把全域決議寫進可靠儲存,重啟後一查就知道「這筆該 Cancel、但某服務尚未完成」。
  • 重試機制:協調者對未成功的 Cancel 不放棄、持續重試,服務恢復後 Cancel 會被重新驅動到成功。
  • Confirm / Cancel 必須冪等:因為有重試,同一指令可能被呼叫多次,必須執行幾次結果都一樣。
互動 · TCC 抵達順序實驗室 自組抵達帳務服務的訊息序列,同時餵給兩種實作
加入抵達的訊息:
快速填入:
可用 凍結 已扣

天真版只看金額欄位:「空回滾」的 Cancel 找不到可釋放的凍結而報錯,協調者只能不斷重試;「懸掛」多一則遲到的 Try,錢被凍住後再也沒有人來收尾;「整組重送」會被扣兩次款。右欄每則訊息都先查事務控制表的狀態(none/tried/confirmed/cancelled)再動作——空回滾、冪等、懸掛三個坑,用的是同一招。預期終態由序列推導:出現過 Cancel 就該全額退回,只有 Confirm 就該恰好扣一次。

三個魔鬼細節

  • 空回滾:Cancel 被呼叫,但該服務從沒收到 Try。Cancel 必須能判斷「沒看過這筆 Try」→ 直接回成功、什麼都不做。
  • 冪等:Confirm / Cancel 可能被重複呼叫,必須靠記錄交易狀態保證重複執行無副作用。
  • 懸掛:延遲的 Try 比 Cancel 還晚到,凍結了一筆永遠不會被 Confirm / Cancel 的資源。Try 執行前必須檢查「這筆是否已被 Cancel」,若是就拒絕凍結。

要正確處理這三件事,每個服務通常要維護一張事務控制表(記錄每個全域交易 ID 的狀態:tried / confirmed / cancelled),三個方法動作前都先查表。這就是 TCC「業務侵入性高」的具體代價。

使用情境與限制

適合一致性要求高、又不能接受 2PC 長鎖的場景,典型如支付、帳務、交易扣款。代價是每個操作都要拆成三個方法並自理冪等/空回滾/懸掛,開發成本高、容易出 bug。

一句話答法TCC 把交易上提到業務層,用 Try 預留資源、Confirm / Cancel 收尾;可靠性靠協調者持久化決議 + 重試 + 冪等補償,難點是空回滾、冪等、懸掛三坑。

05

Saga

再鬆一步:連預留都不要,每步直接提交,靠補償回滾。

核心機制

把一個長交易拆成一串本地交易序列,每一步都立刻提交。每步都對應一個補償操作(Compensating Transaction),一旦某步失敗,就反向依序執行先前已提交步驟的補償,把它們「邏輯上撤銷」。

例如「建立訂單 → 扣款 → 扣庫存 → 安排出貨」若在扣庫存失敗,就反向補償「退款 → 取消訂單」(失敗的那步自身已在本地回滾,通常不需補償)。

互動 · Saga 補償鏈
注入失敗於:
選好失敗點後按「執行」,觀看正向推進與反向補償。
未執行 已提交 失敗 已補償(撤銷)

注意失敗時補償鏈是反向跑的,且只補償「已成功提交」的步驟。切換編排/編舞,補償集合與順序完全不變,變的是協調成本:編排的每一跳是 command+reply 兩則訊息、流程狀態集中在協調器一處;編舞每一跳只有一則事件,但狀態分散在每個被觸及的服務裡。

兩種協調模式

  • Choreography(編舞):沒有中央協調者,靠事件驅動——每個服務做完發出事件,下一個監聽。耦合低,但流程難追蹤、容易事件風暴。
  • Orchestration(編排):有一個 Saga 協調器集中控制流程。清晰、好維護、好監控,協調器是核心元件。中大型系統多選這種。

限制與取捨

  • 只保證最終一致:過程中會有中間狀態被外部看到(錢扣了但貨還沒出)。
  • 沒有隔離性:需自己在業務層處理(狀態欄位、語意鎖)。
  • 有些操作無法補償:例如已寄出的 Email——這時要靠「往前重試」而非「往後補償」。
搭配Saga 幾乎不單獨出現

實務上 Saga 常與 Transactional Outbox 一起用,確保「本地交易提交」與「事件發出」的可靠連動——這正是第 6 節的主題。

使用情境

跨多個微服務的長業務流程:電商下單、訂單履約、行程預訂等。一致性可接受最終一致、但需要高可用與高吞吐時的首選。

一句話答法Saga 把長交易拆成一連串「本地交易 + 補償」,失敗就反向補償;無鎖、高可用、最終一致,代價是沒有隔離性、且有些操作無法補償。

06

訊息驅動家族

Outbox / 可靠訊息 / AT 模式——補上實務拼圖,回答「還有沒有其他做法」。

先看痛點:雙寫問題(Dual Write)

微服務常見需求是「改完自己的 DB,還要發一則訊息通知別人」。天真寫法是「先 commit DB,再發 MQ」——但這兩個動作不是原子的:若在中間崩潰,DB 改了、訊息卻沒發出去,下游永遠不知道,資料就不一致了。反過來「先發 MQ 再 commit DB」也不行:訊息發出後交易若失敗或崩潰,下游會收到一筆資料庫裡根本不存在的訂單——幽靈訊息。

互動 · 崩潰點枚舉

每一列是「做完這一步就崩潰」的後果,全部由同一套規則推導:崩潰時未提交的寫入全部消失,重啟後 Relay 會把還沒標為已送的 outbox 列再發一次。先寫 DB 的策略會在 COMMIT 之後遺失訊息,先發 MQ 的策略會送出幽靈訊息;切到 Outbox,這兩種都消失了,但「已發出、未標記」那一列會重複投遞——沒開消費端冪等,同一個事件就會被套用兩次。

三種常見做法

① Transactional Outbox(事務型發件匣)

把「業務寫入」和「要發送的訊息」放進同一個本地交易、寫進同一個 DB(多一張 outbox 表);再由背景 relay 程序輪詢 outbox,把訊息可靠投遞出去。這是解雙寫問題的主流做法,常配 Saga。

要注意 Relay 是 at-least-once:它把訊息發出去之後、還沒把 outbox 列標成已送就崩潰,重啟後會再發一次。所以 Outbox 並沒有消滅失敗,而是把「訊息遺失」換成「訊息重複」——消費端必須冪等(例如以事件 ID 建去重表)才吸收得掉。上面的 demo 切到 Outbox、關掉冪等,就能看到這一列。

② 可靠訊息最終一致(基於 MQ)

靠訊息佇列達成最終一致——例如 RocketMQ 的事務訊息,或 Kafka + 消費端冪等。本質上是 Outbox / Saga 思路的訊息化實作。

③ AT 模式(如 Seata Automatic Transaction)

框架在執行 SQL 時自動產生反向 SQL 當補償,對業務近乎無侵入,可看成「自動化的 Saga」。代價是有全域鎖、且依賴特定框架。

一句話答法Outbox 把「寫 DB」和「發訊息」綁進同一個本地交易來消滅雙寫問題,再用背景 relay 投遞;它與可靠訊息、AT 模式都是最終一致家族,常與 Saga 搭配。

07

比較與選型

把所有方案放回同一張地圖,再用一條決策流程收斂。

多維度對照

維度2PCTCCSaga
一致性強一致較強(準強一致)最終一致
業務侵入低(DB / XA 層)很高(三介面)中(要寫補償)
資源鎖定長(同步阻塞)短(業務軟凍結)無
效能 / 吞吐低中高
隔離性有較有無(需自理)
複雜度中高中~高
適用流程長度短短~中長
典型場景行內金融、跨庫同組織支付、帳務扣款電商下單、長履約

提示:滑鼠移到任一欄會高亮整欄,方便逐方案縱讀。

互動 · 選型決策器
1. 這筆交易的流程有多長?
2. 一致性要求?
3. 範圍與性質?

決策器會顯示這次實際走過的判斷路徑:選「支付/帳務扣款」時,前兩題不影響結論;選同組織+長流程+強一致,會撞上本質衝突的需求,它會誠實點出來,而不是硬給一個完美答案。

直覺選型心法

短流程 + 強一致 + 同組織 往 2PC / TCC 想;長流程 + 可接受最終一致 + 跨多微服務 往 Saga + Outbox 想。微服務下的主流組合其實是 Saga(Orchestration)+ Transactional Outbox,因為它在可用性與可維護性上最平衡。


08

面試速查

高頻追問、陷阱清單、一句話收尾。

高頻問答(點擊展開)

Q1 為什麼微服務不直接用 2PC 就好?▸

2PC 在 Prepare 後會同步鎖住所有參與者的資源直到提交完成,併發吞吐極低;協調者又是單點,掛在 Commit 階段會讓參與者卡死。微服務追求高可用與高吞吐,這兩點都踩雷,所以多改用 Saga / TCC。

Q2 TCC 的服務在 Cancel 途中崩潰,資料會不一致嗎?▸

不會。崩潰時資源停在「凍結」這個安全中間態。協調者已把「該 Cancel」的決議持久化,會持續重試;服務恢復後 Cancel 被重新驅動到成功。可靠性來自持久化決議 + 重試 + 冪等補償,而非服務本身不死。

Q3 什麼是空回滾與懸掛?怎麼解?▸

空回滾:Cancel 來了但根本沒收過 Try(Try 遺失、協調者超時直接取消)。懸掛:延遲的 Try 比 Cancel 還晚到,凍結了沒人會收尾的資源。兩者都靠事務控制表解:Cancel 先查「有沒有 Try 過」,Try 先查「是否已被 Cancel」。

Q4 Saga 失敗時,補償鏈怎麼跑?▸

反向、依序補償已成功提交的步驟。失敗的那一步自身已在本地回滾,通常不需補償。注意補償操作本身也要冪等,且有些動作(已發 Email)無法補償,要改用往前重試。

Q5 Outbox 解決了什麼,為何不直接「先寫 DB 再發 MQ」?▸

「先寫 DB 再發 MQ」是兩個非原子動作,中間崩潰會 DB 改了但訊息丟失(雙寫問題)。Outbox 把訊息和業務資料寫進同一個本地交易,再由背景 relay 投遞,保證「資料改了,訊息一定發得出去」。

Q6 Saga 沒有隔離性會出什麼問題?怎麼緩解?▸

中間狀態會被別的交易讀到(讀到「已扣款但未出貨」的訂單)。常見緩解:加狀態欄位(pending / confirmed)、語意鎖(semantic lock)、版本號 / 重讀值校驗(reread value)等業務層手段。

陷阱清單

坑把 2PC / TCC / Saga 講成「三選一的平行選項」——它們其實在同一條一致性光譜上,差別是取捨位置。
坑以為 TCC 會「Confirm 完再 Cancel」——標準流程是全 Try 成功才 Confirm,否則全 Cancel,不混用。
坑把 Confirm / Cancel / 補償寫成非冪等——只要有重試機制,非冪等遲早出事。
坑用 Saga 卻假設它有隔離性——它沒有,中間態會被看到,要在業務層自理。
坑忽略「無法補償的操作」(已寄出的信、已撥出的款)——這類要設計成往前重試或加人工介入。

一句話收尾

2PC協調者主導 Prepare / Commit 換強一致,代價是阻塞與單點。
TCC業務層 Try 預留 + Confirm / Cancel 收尾,靠持久化決議 + 重試 + 冪等求可靠。
Saga本地交易序列 + 反向補償,無鎖、最終一致,無隔離性。
選型短 + 強一致 + 同組織 → 2PC / TCC;長 + 最終一致 + 跨服務 → Saga + Outbox。