分散式交易:在一致性與可用性之間鬆綁
當一筆業務操作橫跨多個服務、多個資料庫時,如何保證它「要嘛全部成功、要嘛全部失敗」?這份手冊用一條演化線串起 2PC、TCC、Saga 與訊息家族——它們不是並列的三選一,而是一路在強一致與最終一致之間逐步放手的取捨史。
總覽與定位
是什麼、解決什麼痛、在系統設計版圖中的座標。
分散式交易要解的核心問題只有一句話:一筆業務操作橫跨多個服務或多個資料庫時,怎麼保證它的原子性(要嘛全成、要嘛全敗),不會卡在「一半成功一半失敗」的不一致狀態。
天真版:單體 + 單庫,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 節的主題。
為什麼沒有銀彈
先架好一致性光譜這個座標系,後面三個方案才知道自己站在哪。
很多人把 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 短。
2PC 兩階段提交
最接近單庫直覺的解法——強一致、對業務無侵入,但代價也最重。
核心機制
引入一個協調者(Coordinator / TC),把提交切成兩個階段:
- Prepare(準備):協調者問所有參與者「準備好提交了嗎?」參與者執行操作但不提交、鎖住資源,回覆 Yes / No。
- Commit(提交):若全部回 Yes,協調者下令全體 Commit;只要有一個 No,就全體 Rollback。
先用預設值走一次正常流程:鎖從參與者執行 PREPARE 開始,到收到 COMMIT 才釋放。再把崩潰點切到「決議寫入日誌後、送出前」:兩個參與者都投了 YES、都沒收到決議,打開互詢也沒用——誰都不知道結果,只能鎖著等協調者回來,這就是 2PC 被稱為阻塞協定的原因。改成「決議只送到帳務」再打開互詢,庫存就能從帳務問到答案、提早解鎖;把任一方的投票改成 NO,崩潰也不再造成長時間阻塞,因為投 NO 的一方可以單方面中止,也答得出別人的詢問。
天真版 → 問題
2PC 的直覺很美:行為跟單庫的 commit 幾乎一樣,對業務透明(資料庫層用 XA 協議就能跑)。但它有三個致命缺陷:
- 同步阻塞:Prepare 後資源被鎖住,得等所有人回應才能釋放,併發吞吐極低。
- 協調者單點故障:協調者在 Commit 階段掛掉,參與者卡在「不知該 Commit 還是 Rollback」的懸置狀態。參與者之間互相詢問只在「還有人知道結果」時有用;全員投了 Yes 之後協調者消失,誰也不能自行決定。
- 資料不一致風險:Commit 指令只送達部分參與者時協調者崩潰或網路斷掉,在恢復之前一邊已提交、一邊仍懸而未決,外界會讀到不一致的中間狀態。
以為「3PC 解決了所有問題」。3PC 多了 CanCommit 階段與 timeout,緩解了阻塞與單點,但無法在網路分區下徹底消除不一致,且複雜度更高,實務上幾乎不用。
使用情境與限制
適合一致性要求極高、併發不大、且不跨組織的場景:傳統金融行內系統、跨庫但同一組織的操作。常搭配支援 XA 的關聯式資料庫與 JTA 交易管理員。微服務世界因為它的阻塞特性,已越來越少用。
TCC(Try-Confirm-Cancel)
把 2PC 的概念上提到業務層,用「資源預留」取代「資料庫長鎖」。
核心機制
每個服務要實作三個介面:
- Try:嘗試執行,做資源的預留 / 凍結(扣款先凍結額度、扣庫存先扣到「凍結庫存」),但不真正生效。
- Confirm:所有 Try 成功後,正式確認(把凍結的真正扣掉)。
- Cancel:任一 Try 失敗,釋放預留的資源。
它不靠資料庫鎖,而是用業務上的「軟狀態(凍結)」,所以沒有長時間的資源鎖定,效能比 2PC 好;又因為有 Try 階段確認可行性才提交,一致性比 Saga 強。
全域決策是二元且有順序的:協調者等所有參與者 Try 都成功才進入 Confirm;只要有一個 Try 失敗就全體 Cancel,此時還沒有任何服務 Confirm。你要嘛全 Confirm、要嘛全 Cancel。
可靠性從何而來?(服務掛了怎麼辦)
關鍵觀念:TCC 的可靠性不依賴單一服務不掛掉。若某服務 Try 成功、協調者決議 Cancel,但它在執行 Cancel 時異常關閉——此時資源停在「凍結」狀態,這是安全的中間態,不是不一致。可靠性來自三件事的組合:
- 協調者的持久化事務日誌:下達 Cancel 前先把全域決議寫進可靠儲存,重啟後一查就知道「這筆該 Cancel、但某服務尚未完成」。
- 重試機制:協調者對未成功的 Cancel 不放棄、持續重試,服務恢復後 Cancel 會被重新驅動到成功。
- Confirm / Cancel 必須冪等:因為有重試,同一指令可能被呼叫多次,必須執行幾次結果都一樣。
天真版只看金額欄位:「空回滾」的 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。
Saga
再鬆一步:連預留都不要,每步直接提交,靠補償回滾。
核心機制
把一個長交易拆成一串本地交易序列,每一步都立刻提交。每步都對應一個補償操作(Compensating Transaction),一旦某步失敗,就反向依序執行先前已提交步驟的補償,把它們「邏輯上撤銷」。
例如「建立訂單 → 扣款 → 扣庫存 → 安排出貨」若在扣庫存失敗,就反向補償「退款 → 取消訂單」(失敗的那步自身已在本地回滾,通常不需補償)。
注意失敗時補償鏈是反向跑的,且只補償「已成功提交」的步驟。切換編排/編舞,補償集合與順序完全不變,變的是協調成本:編排的每一跳是 command+reply 兩則訊息、流程狀態集中在協調器一處;編舞每一跳只有一則事件,但狀態分散在每個被觸及的服務裡。
兩種協調模式
- Choreography(編舞):沒有中央協調者,靠事件驅動——每個服務做完發出事件,下一個監聽。耦合低,但流程難追蹤、容易事件風暴。
- Orchestration(編排):有一個 Saga 協調器集中控制流程。清晰、好維護、好監控,協調器是核心元件。中大型系統多選這種。
限制與取捨
- 只保證最終一致:過程中會有中間狀態被外部看到(錢扣了但貨還沒出)。
- 沒有隔離性:需自己在業務層處理(狀態欄位、語意鎖)。
- 有些操作無法補償:例如已寄出的 Email——這時要靠「往前重試」而非「往後補償」。
實務上 Saga 常與 Transactional Outbox 一起用,確保「本地交易提交」與「事件發出」的可靠連動——這正是第 6 節的主題。
使用情境
跨多個微服務的長業務流程:電商下單、訂單履約、行程預訂等。一致性可接受最終一致、但需要高可用與高吞吐時的首選。
訊息驅動家族
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」。代價是有全域鎖、且依賴特定框架。
比較與選型
把所有方案放回同一張地圖,再用一條決策流程收斂。
多維度對照
| 維度 | 2PC | TCC | Saga |
|---|---|---|---|
| 一致性 | 強一致 | 較強(準強一致) | 最終一致 |
| 業務侵入 | 低(DB / XA 層) | 很高(三介面) | 中(要寫補償) |
| 資源鎖定 | 長(同步阻塞) | 短(業務軟凍結) | 無 |
| 效能 / 吞吐 | 低 | 中 | 高 |
| 隔離性 | 有 | 較有 | 無(需自理) |
| 複雜度 | 中 | 高 | 中~高 |
| 適用流程長度 | 短 | 短~中 | 長 |
| 典型場景 | 行內金融、跨庫同組織 | 支付、帳務扣款 | 電商下單、長履約 |
提示:滑鼠移到任一欄會高亮整欄,方便逐方案縱讀。
決策器會顯示這次實際走過的判斷路徑:選「支付/帳務扣款」時,前兩題不影響結論;選同組織+長流程+強一致,會撞上本質衝突的需求,它會誠實點出來,而不是硬給一個完美答案。
短流程 + 強一致 + 同組織 往 2PC / TCC 想;長流程 + 可接受最終一致 + 跨多微服務 往 Saga + Outbox 想。微服務下的主流組合其實是 Saga(Orchestration)+ Transactional Outbox,因為它在可用性與可維護性上最平衡。
面試速查
高頻追問、陷阱清單、一句話收尾。
高頻問答(點擊展開)
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)等業務層手段。