• 區分「原子提交」與「共識」兩個問題,說明為何 2PC 需要全體同意而 Paxos 只需多數。

  • 完整描述兩階段提交(2PC)的訊息、日誌寫入點與每一種失效情境下的行為,理解它為何會阻塞。

  • 說明 3PC 的改進與它在網路分割下失效的原因。

  • 解釋現代系統如何用共識複寫協調者狀態(Paxos Commit、Spanner)、以 Percolator/parallel commit 減少往返,以及全域時間戳(TSO、HLC、TrueTime)如何提供跨分片的一致快照。

  • 以 Saga 模式分解長交易,知道它放棄了什麼(隔離)以及補償、冪等、outbox 的配套做法。

  • 正確使用 PostgreSQL 的 ,並知道孤兒 prepared 交易的危害。

問題:跨節點的原子性與隔離

資料被分割到多個節點後,一筆交易可能同時修改分片 A 與分片 B(轉帳:A 扣款、B 入帳)。我們需要:

  • 原子提交(atomic commit):所有參與節點要嘛全部 commit、要嘛全部 abort——即使有節點在中途當機。

  • 跨分片隔離:其他交易不能看到「A 已扣、B 未入」的中間狀態,也就是需要跨節點的一致快照或鎖。

共識(第 15 章):節點各自提議值,多數同意任一提議值即可,少數節點掛掉無妨。 原子提交:每個參與者投「commit」或「abort」,任何一票 abort 就必須 abort,所以需要全部的票;一個參與者失聯就無法決定 commit。 這個「需要全體」的本質讓 2PC 無法像 Paxos 一樣容錯——除非把每個參與者本身也用共識複寫(Spanner 的做法)。

兩階段提交(2PC)

  1. Phase 1(投票):協調者送 。每個參與者把交易做到「只差 commit」——寫下所有修改與一筆 PREPARE 日誌並 fsync, 之後即使當機也能 commit;然後回 (並承諾不再單方面 abort)或 。

  2. Phase 2(決定):全部 YES 則協調者寫 COMMIT 日誌並 fsync——這是整筆交易的 commit point;之後送 給所有參與者。任一 NO 或逾時則寫 ABORT 並送 。 參與者執行、寫日誌、釋放鎖、回 ;協調者收齊後寫 END。

失效分析

P3.6cmP10.4cm 情境 & 行為
參與者在投票前當機 & 協調者逾時 \(\to\) ABORT。參與者重啟後沒有 PREPARE 記錄,自行 abort。
參與者投 YES 後當機 & 重啟後看到 PREPARE 記錄但沒有結果:必須詢問協調者(或其他參與者)才能決定;在此之前持續持有鎖。
協調者在寫 COMMIT 前當機 & 參與者處於 prepared 狀態無限期等待(阻塞):它們不能 abort(已承諾)也不能 commit(不知結果)。鎖被持有,其他交易被擋住。這是 2PC 最大的弱點。
協調者在寫 COMMIT 後、送出前當機 & 重啟後讀日誌,重送 COMMIT。結果已確定,只是延遲。
網路分割 & 等同上述失聯情境;協調者與參與者都只能等待。

代價:正常路徑 2 輪往返、每個節點至少 2 次 fsync(參與者 PREPARE + COMMIT;協調者 COMMIT),鎖的持有時間延長至跨網路的兩個階段。 Presumed abort 最佳化:協調者不記錄 abort 決定,查無記錄即視為 abort,省下 abort 路徑的日誌與 ACK。 唯讀參與者可在 Phase 1 直接回 READ-ONLY 並退出。

PostgreSQL 提供 2PC 的參與者端(協調者由外部交易管理器如 JTA/XA、Citus、應用程式負責):

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'xfer-20261008-0001';   -- 交易狀態持久化到 pg_twophase/,釋放連線但保留鎖
-- 協調者收齊所有 YES 後:
COMMIT PREPARED 'xfer-20261008-0001';        -- 或 ROLLBACK PREPARED

預設 (功能關閉),因為孤兒 prepared 交易非常危險:它持有鎖、佔住 xmin 水位讓 VACUUM 無法清理、阻止 XID 凍結—— 一筆忘了 commit 的 prepared 交易可以在幾週後讓整個叢集因回繞保護而停機。使用時必須監控 的 時間並設定告警。 對遠端表的修改不是 2PC(各遠端各自 commit,可能部分成功);Citus 的跨 shard 寫入則以 2PC 執行,並由 表在協調者重啟後復原懸而未決的交易。

三階段提交(3PC)

Skeen(1981)在 PREPARE 與 COMMIT 之間加入 PRE-COMMIT 階段:協調者收齊 YES 後先廣播「準備 commit」,收齊確認後才送 COMMIT。 好處:任何存活的參與者只要看到 PRE-COMMIT 就知道所有人都投了 YES,可以在協調者當機時自行推進(不阻塞);若沒人收到 PRE-COMMIT 則可安全 abort。 代價:多一輪往返,而且它假設失效只有當機、沒有網路分割——分割時兩邊可能各自做出相反決定。因此 3PC 幾乎不被實際系統採用; 真正解決阻塞的方法是把協調者的決定放進共識。

以共識消除阻塞

Paxos Commit 與 Spanner

Gray 與 Lamport(2006)指出:2PC 的阻塞源於協調者是單點。把「每個參與者的投票」與「協調者的決定」都以 Paxos 複寫到 \(2f+1\) 個 acceptor, 協調者當機後任何人都能從 acceptors 讀出已決定的結果——Paxos Commit。\(f = 0\) 時退化為 2PC。

Google Spanner 把這個想法產品化:每個分片(tablet)由一個 Paxos 群組複寫,分散式交易在Paxos 群組之間跑 2PC, 協調者是其中一個群組的 leader;參與者的 PREPARE 與協調者的 COMMIT 記錄都寫進各自的 Paxos 日誌,所以任何單一節點失效都不會阻塞。 鎖以 2PL 管理(寫鎖),唯讀交易則用 TrueTime 時間戳走無鎖的快照讀。

Percolator:以資料本身當協調者日誌

Google Percolator(2010;TiDB/TiKV 採用)建在 Bigtable 的單列原子操作上,不需要獨立的協調者日誌:

  1. 交易從 TSO(timestamp oracle)取得 ,所有讀取以此快照進行,寫入先緩衝在客戶端。

  2. Prewrite:選一個寫入鍵為 primary,對每個鍵寫入資料(版本 = start_ts)與一把鎖(secondary 的鎖指向 primary);任一鍵已有衝突的鎖或更新的寫入則 abort。

  3. 取得 ,對 primary 寫入 commit 記錄並刪除其鎖——這個單列原子操作就是 commit point。

  4. 非同步地 commit 所有 secondary(寫 commit 記錄、刪鎖)。

讀者遇到 secondary 的鎖時,查看 primary:primary 已 commit 則幫忙把 secondary 補完(roll forward),primary 的鎖已逾時則清理(roll back)。 協調者(客戶端)當機不會阻塞——狀態都在資料裡。TiDB 另有「async commit」與「1PC」最佳化縮短關鍵路徑。

CockroachDB 的 parallel commits

CockroachDB 每個 range 一個 Raft 群組;交易寫入先以 write intent(帶交易 id 的暫定版本)落到各 range,交易狀態存在一筆 transaction record。 傳統做法要等所有 intent 寫完再把 record 改為 COMMITTED(兩輪共識延遲)。Parallel commits(2019)讓 record 進入 STAGING 狀態並列出所有 intent 的位置, 與 intent 寫入平行進行;只要全部成功,交易就隱含地 commit 了——客戶端只需等一輪往返。之後非同步把 record 標為 COMMITTED、解析 intents。 遇到 STAGING 記錄的讀者會自行驗證所有 intent 是否存在,以確定交易結果(status recovery)。

P2.6cmP3.6cmP3.6cmP4.2cm & 2PC(XA) & Spanner & Percolator / CockroachDB
協調者狀態 & 單點日誌 & Paxos 群組 & 寫在資料裡(primary / txn record),以 Raft 複寫
協調者失效 & 阻塞 & 不阻塞(群組選新 leader) & 不阻塞(任何讀者可解析)
commit 延遲 & 2 輪往返 + 2 次 fsync & 2PC over Paxos(每輪跨群組) + commit wait & Percolator 2 輪;parallel commits 1 輪
隔離 & 依各參與者(通常 2PL) & Strict serializable(2PL + TrueTime) & SI(TiDB)/serializable(CockroachDB,HLC + 讀刷新)

跨分片的一致快照:全域時間戳

單機 MVCC 的快照靠一個遞增的 XID 計數器(第 9 章);跨節點沒有這種東西,三種解法:

  • 集中式 TSO:一個(以 Raft 複寫、批次配發的)時間戳服務——TiDB 的 PD、Percolator、OceanBase。簡單、嚴格單調;跨地域部署時每筆交易多一次到 TSO 的往返。

  • HLC + 不確定區間:CockroachDB/YugabyteDB 以混合邏輯時鐘配發時間戳,並假設時鐘偏差 \(\le\) (500 ms)。讀到時間戳落在「我的 ts 與 ts + max_offset 之間」的值時無法確定先後,必須讀刷新(read refresh)或重啟。不需特殊硬體,但只保證單鍵 linearizable、整體 serializable。

  • TrueTime:Spanner 用 GPS/原子鐘把不確定區間縮到幾毫秒,commit 時等待區間過去(commit wait),確保時間戳順序與真實時間一致——external consistency(strict serializability)。

Calvin(2012;FaunaDB 的基礎)走另一條路:確定性資料庫——先用共識把所有交易的輸入排成全序(sequencing layer),再讓每個分片以相同順序確定性地執行, 執行期不需要 2PC(結果由順序決定,節點不會「改變主意」)。代價是交易的讀寫集合必須事先知道(或先做偵察讀),互動式交易不適用。

Saga:放棄隔離的長交易

Garcia-Molina 與 Salem(1987)為「跑數小時的長交易」提出 Saga:把交易拆成一串本地交易 \(T_1, T_2, \ldots, T_n\),每個都立即 commit; 為每個 \(T_i\) 定義補償交易 \(C_i\)(語意上的反操作,如「取消訂位」而非「回滾」)。若 \(T_j\) 失敗,依序執行 \(C_{j-1}, \ldots, C_1\)(向後復原); 或者對可重試的步驟一直重試直到成功(向前復原)。微服務架構把它當作跨服務「交易」的標準模式。

Saga 保留原子性(最終全部完成或全部補償),但沒有隔離:步驟之間的中間狀態對外可見,可能出現 lost update(兩個 saga 交錯更新)、dirty read(讀到稍後被補償的狀態)。 常見對策:語意鎖(訂單標為 pending,其他流程看到 pending 就等待或拒絕)、可交換的更新(加減而非設值)、悲觀視角(把風險高的步驟放最後)、 重讀驗證。工程配套:每一步與補償都必須冪等(會被重送)、補償本身不能失敗(或需人工介入佇列)、 以 outbox pattern 在同一個本地交易中寫入業務資料與待發事件,再由 CDC(PostgreSQL 邏輯解碼)或輪詢可靠地發出,避免「資料寫了、事件沒發」。 協調方式分編排(orchestration)(一個 saga 協調器狀態機,如 Temporal、AWS Step Functions)與編舞(choreography)(各服務訂閱事件接力)。 TCC(Try-Confirm-Cancel)是帶預留步驟的 Saga 變體,更接近 2PC 的語意但由應用程式實作。

選擇與實務建議

P2.6cmP4.0cmP3.4cmP4.0cm & 2PC / XA & 共識式提交 & Saga
原子性 & 是 & 是 & 最終(透過補償)
隔離 & 是(鎖持有到 Phase 2) & 是 & 否
協調者失效 & 阻塞 & 不阻塞 & 協調器狀態需持久化;可恢復
延遲 & 2 輪 + fsync & 1–2 輪跨群組共識 & 多個獨立交易,總時間長但不持鎖
耦合 & 所有參與者需支援 XA、同時在線 & 需同一個資料庫系統 & 鬆耦合、跨異質服務
適用 & 同一組織內少數資料庫、短交易 & NewSQL 內部 & 微服務、跨組織流程、長時間流程

Pat Helland(2007)的建議至今有效:以實體(entity)為單位設計資料,讓一筆業務操作盡量落在單一分片/單一資料庫的交易內(Citus 的 co-location、DynamoDB 的 single-table design 都是這個精神); 跨實體的操作以訊息與冪等處理達成最終一致。真的需要跨分片原子性時,優先選擇把它內建的系統(Spanner、CockroachDB、TiDB、YugabyteDB、Citus),而不是在應用層自己拼 XA。

在兩個 MiniDB 節點上實作 2PC:加入 狀態(持久化 PREPARE 記錄並保留鎖)、一個簡單的協調者(含日誌)與 。 測試:在協調者寫 COMMIT 記錄之前與之後分別 kill 它,觀察參與者的阻塞與復原;然後把協調者日誌改放到 v7 的 Raft 群組,證明阻塞消失。 最後實作一個 Saga 範例(訂單/庫存/付款三個表)並寫測試重現它的隔離異常。

  1. 參與者 A 投了 YES 之後與所有人失聯 30 分鐘。它能否自行決定?如果它聯絡上另一個參與者 B 且 B 說「我投了 NO」呢?若 B 說「我投了 YES」呢?

  2. 為什麼協調者「寫 COMMIT 日誌並 fsync」是 commit point,而不是「收到最後一個 YES」或「送出第一個 COMMIT」?

  3. 計算一筆跨 3 個分片的交易在 2PC(協調者為其中一個分片)、Spanner 式 2PC over Paxos(每個群組 3 副本、跨機房往返 2 ms)、CockroachDB parallel commits 下的大致 commit 延遲(以往返次數與 fsync 計)。

  4. Percolator 中,若客戶端在 primary commit 之後、secondary commit 之前當機,另一個讀者讀到 secondary 的鎖時如何處理?若在 primary commit 之前當機呢?鎖的「逾時」判斷為何需要 TSO?

  5. 說明 Calvin 為何不需要 2PC;它如何處理「交易要先讀某值才知道要寫哪些鍵」的情況?

  6. 把「使用者下單」設計成 Saga:列出步驟、補償、每一步的冪等鍵;指出哪兩個 saga 並行時會出現 lost update,並用語意鎖修正。

  7. (動手)啟用 ,執行 後中斷連線,觀察 、 與其他 session 的阻塞;放著幾小時後觀察 的「dead but not yet removable」。