Home › Distributed systems › Coordination
algorithm medium

Two-phase commit

Problem A transaction touching several shards or databases must either take effect everywhere or nowhere, even if a participant or the coordinator crashes mid-way. This gives that all-or-nothing outcome.

Atomic commit across shards: participants durably vote yes, then the coordinator logs and broadcasts one decision; blocks if the coordinator is lost.

2 round trips (client reply after 1 RTT + 2 sequential forced log writes); 3-4n messages for n participants
transactionsatomic-commitdistributed-transactionsxacoordination
Overview

A transaction across shards must commit everywhere or nowhere. Phase 1: the coordinator asks participants to prepare; a yes means the changes are durable and the participant may no longer abort on its own. Phase 2: if all said yes, the coordinator durably logs COMMIT (the commit point) and broadcasts it; otherwise ABORT. A prepared participant that loses the coordinator is stuck.

Key facts

Use when

  • Transactions span shards of one system you control: distributed SQL databases (Spanner, CockroachDB, TiDB), message streams (Kafka transactions), application servers coordinating several resource managers (XA / Java EE).

Avoid when

Deep dive

How it works

Parts:

  • coordinator — drives both phases and owns the decision record
  • participants — resource managers (shards, databases, queues) that vote and apply
  • coordinator log — durable COMMIT/ABORT/END records; the commit point
  • participant log — durable PREPARED record with redo data
  • recovery procedure — in-doubt participants ask the coordinator for the outcome

Operations: prepare, decide, commit, abort, recovery (steps below).

  1. Client finishes the transaction's reads/writes on participants (under locks or write intents).
  2. Prepare: coordinator sends PREPARE to all participants.
  3. Each participant validates (constraints, conflicts), forces a PREPARED record with its redo data to its log, replies YES; or rolls back and replies NO.
  4. Decide: all YES → coordinator forces COMMIT(tx, participants) to its log. This write is the commit point; it can now reply to the client.
  5. Commit: coordinator sends COMMIT; participants apply, force a commit record, release locks, ACK. When all ACK, the coordinator writes END (lazily) and forgets the transaction.
  6. Any NO or timeout before step 4 → ABORT; under presumed abort nothing is forced.
  7. Recovery: a participant with PREPARED but no outcome asks the coordinator; the coordinator answers COMMIT if it has the record, otherwise ABORT.
states: participant: Active ─PREPARE/YES─► Prepared ─COMMIT─► Committed
                       └──────NO/ABORT───► Aborted (Prepared leaves only on the decision)

coord        P1            P2
  ├─PREPARE──►│─fsync YES──►│
  ├─PREPARE───┼────────────►│─fsync YES
  │◄──YES─────┤◄────YES─────┤
  fsync COMMIT  ← commit point, reply to client
  ├─COMMIT───►│ apply, unlock ...

Code

use std::collections::{HashMap, HashSet};

pub type TxId = u64;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Decision { Commit, Abort }

#[derive(Clone, Debug)]
pub enum Rec { Prepared(TxId), Outcome(TxId, Decision), CommitDecision(TxId, Vec<u32>), End(TxId) }

/// Stand-in for a write-ahead log; `force` = append + fsync before returning.
#[derive(Default)]
pub struct Log { pub recs: Vec<Rec> }
impl Log { pub fn force(&mut self, r: Rec) { self.recs.push(r); } pub fn lazy(&mut self, r: Rec) { self.recs.push(r); } }

pub struct Participant { pub id: u32, pub log: Log, pub locked: HashSet<TxId>, pub can_commit: bool }

impl Participant {
    pub fn prepare(&mut self, tx: TxId) -> bool {
        if !self.can_commit { self.locked.remove(&tx); return false; } // presumed abort: no log
        self.log.force(Rec::Prepared(tx)); // redo data must be durable with this record
        true                                // from here on we may NOT abort unilaterally
    }
    pub fn finish(&mut self, tx: TxId, d: Decision) {
        match d {
            Decision::Commit => self.log.force(Rec::Outcome(tx, d)), // forced, then ACK
            Decision::Abort => self.log.lazy(Rec::Outcome(tx, d)),   // no force, no ACK needed
        }
        self.locked.remove(&tx);
    }
    /// After a crash: prepared transactions with no outcome are in doubt and keep their locks.
    pub fn in_doubt(&self) -> Vec<TxId> {
        let done: HashSet<TxId> = self.log.recs.iter()
            .filter_map(|r| if let Rec::Outcome(t, _) = r { Some(*t) } else { None }).collect();
        self.log.recs.iter().filter_map(|r| match r {
            Rec::Prepared(t) if !done.contains(t) => Some(*t), _ => None }).collect()
    }
}

#[derive(Default)]
pub struct Coordinator { pub log: Log, acks: HashMap<TxId, HashSet<u32>> }

impl Coordinator {
    pub fn commit(&mut self, tx: TxId, parts: &mut [Participant]) -> Decision {
        // Phase 1 (sent in parallel in a real system; any timeout counts as NO).
        let all_yes = parts.iter_mut().all(|p| p.prepare(tx));
        if !all_yes {
            for p in parts.iter_mut() { p.finish(tx, Decision::Abort); }
            return Decision::Abort;               // nothing forced: presumed abort
        }
        let ids: Vec<u32> = parts.iter().map(|p| p.id).collect();
        self.log.force(Rec::CommitDecision(tx, ids)); // COMMIT POINT; may reply to client now
        for p in parts.iter_mut() {                   // Phase 2, retried until every ACK arrives
            p.finish(tx, Decision::Commit);
            self.acks.entry(tx).or_default().insert(p.id);
        }
        if self.acks[&tx].len() == parts.len() {
            self.log.lazy(Rec::End(tx));
            self.acks.remove(&tx);
        }
        Decision::Commit
    }
    /// Answer an in-doubt participant. No record => it never reached the commit point.
    pub fn query(&self, tx: TxId) -> Decision {
        let committed = self.log.recs.iter().any(|r| matches!(r, Rec::CommitDecision(t, _) if *t == tx));
        if committed { Decision::Commit } else { Decision::Abort }
    }
}

With presumed abort the coordinator may forget a transaction only after END; a COMMIT record must survive until every participant has acknowledged.

Complexity and constants

  • Messages: PREPARE + vote + COMMIT (+ ACK) per participant = 3-4n.
  • Forced writes on the critical path: participant PREPARED (parallel across participants) then coordinator COMMIT: 2 sequential fsyncs, order of 0.05-10 ms each depending on the device.
  • Lock hold time: from first write through phase 2 delivery; contention on hot rows scales with it.
  • With replicated participants (each a Raft/Paxos group), each "fsync" becomes a consensus round: a classic distributed-SQL commit costs 2 consensus rounds, which CockroachDB's parallel commits cut to 1.

Efficiency

  • Cost: 3-4n messages for n participants (PREPARE, vote, COMMIT, ACK). Two sequential forced writes on the critical path, participant PREPARED (parallel across participants) then coordinator COMMIT, each order of 0.05-10 ms depending on the device. Client latency is about 1 RTT plus both fsyncs; locks are held at least 2 RTT. With Raft/Paxos-replicated participants each "fsync" becomes a consensus round. Bound: log-force latency and lock hold time.
  • Levers:
    • single-shard fast path — one phase, one log write — well-partitioned schemas
    • read-only participants vote READ-ONLY and drop out of phase 2 — fewer messages and log writes (Mohan et al., R*) — read-mostly transactions
    • presumed abort or presumed commit — no forced write or ACK for the presumed outcome (R*) — abort-heavy or commit-heavy workloads
    • parallel commits — one consensus round fewer (CockroachDB docs) — replicated participants
    • coordinator on the first-written participant — one hop and one log less — shard-local transactions
  • Naive → standard → state of the art:
    • 2PC with every record forced and every participant ACKing · 3-4n messages, 2 forced writes plus ACK round
    • presumed abort, read-only vote, coordinator on a participant · fewer forced writes and messages
    • replicated decision record plus parallel commits (CockroachDB) · 2 consensus rounds cut to 1 on the commit path

Implementation notes

  • Single-shard fast path: one participant → commit locally (1 phase); the common case in well-partitioned schemas.
  • Coordinator on a participant (often the first-written shard): saves a hop and one log.
  • Read-only participants vote READ-ONLY at prepare and drop out of phase 2.
  • Replicate the decision: store the transaction record in a replicated log (Spanner, CockroachDB, TiDB/Percolator's primary key) so any node can resolve in-doubt transactions; this removes the blocking problem in practice.
  • Participants must persist enough redo/undo before voting YES; "prepared" must survive a crash.
  • PostgreSQL PREPARE TRANSACTION is disabled by default (max_prepared_transactions = 0); forgotten prepared transactions hold locks and block vacuum.

Pitfalls

  • Participant aborting after voting YES (e.g. on lock timeout): breaks atomicity. Once prepared, only the decision can end it.
  • Coordinator replying to the client before the COMMIT record is durable.
  • Garbage-collecting the decision before all ACKs: a recovering participant then gets "abort" by presumption for a committed transaction.
  • Orphaned prepared transactions (XA) after a coordinator is decommissioned: monitor and resolve them manually.
  • Timeouts too short in phase 1 → spurious aborts under load; too long → locks held across failures.

Variants and alternatives

Presumed abort / presumed commit (R*)skip forced writes for the presumed outcome.
Paxos Commiteach participant's vote is chosen by a Paxos instance; non-blocking with 2F+1 acceptors, same latency as 2PC with F=0.
Three-phase commitnon-blocking only under synchronous timing assumptions; rarely used.
Percolator2PC on a KV store with a primary lock as the commit point; snapshot isolation via timestamp oracle.
Parallel commits (CockroachDB)mark the transaction STAGING with its write set; commit is implied once all writes are replicated, removing one consensus round.
Sagaschains of local transactions with compensations; no atomicity, no blocking.

In production

Distributed SQL: Google Spanner (2PC across Paxos groups), CockroachDB (parallel commits), TiDB/TiKV (Percolator model), YugabyteDB. Messaging: Kafka transactions (transaction coordinator + commit markers). Application servers and classic databases: XA in MySQL/PostgreSQL/Java EE transaction managers.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
TiKVRusttikv/tikvn/a (service)————Percolator-style transactions: 2PC with a primary lock as commit point
PostgreSQLCpostgres/postgresbrew install postgresql@18————PREPARE TRANSACTION / COMMIT PREPARED; off by default (max_prepared_transactions = 0)
CockroachDBGocockroachdb/cockroachn/a (service)————2PC across ranges with parallel commits
NarayanaJavajbosstm/narayanan/a (Java library: org.jboss.narayana.jta:narayana-jta)————JTA/XA transaction manager
Apache KafkaJava / Scalaapache/kafkabrew install kafka————transaction coordinator with commit markers (KIP-98)

Sources

  • Jim Gray, Notes on Data Base Operating Systems, 1978.
  • C. Mohan, Bruce Lindsay, Ron Obermarck, Transaction Management in the R* Distributed Database Management System, ACM TODS 1986. paper
  • Jim Gray, Leslie Lamport, Consensus on Transaction Commit, ACM TODS 2006. paper
  • Daniel Peng, Frank Dabek, Large-scale Incremental Processing Using Distributed Transactions and Notifications, OSDI 2010. paper
  • Philip A. Bernstein, Vassos Hadzilacos, Nathan Goodman, Concurrency Control and Recovery in Database Systems, 1987.
  • PostgreSQL documentation, PREPARE TRANSACTION. docs
  • Apache Kafka, KIP-98: Exactly Once Delivery and Transactional Messaging. spec

Builds on

Used by

delivery versus payment · plannedAtomic exchange of securities and cash so neither leg settles without the other
exactly once processing · plannedIdempotent producers plus transactional offset+output commits for effectively-once results
percolator transactions · plannedSnapshot-isolation transactions over a KV store with a timestamp oracle and primary locks
spatial shard handoff · plannedSplit a world into spatial shards and migrate entities between servers at boundaries
truetime commit wait · plannedCommit timestamps from bounded-uncertainty clocks plus commit wait for external consistency

Systems (8):

Alternatives and related

Saga patternA business operation must change data in several services or databases, each with its own transactions, and may run for minutes or days. When a later part fails, the earlier parts must be undone without any shared commit.Transactional outboxA service must change its database and tell other services about it, but the database and the message broker cannot commit together. A crash between the two writes leaves the state and the announcement disagreeing: a lost event or a phantom one.Multi-PaxosReplicated systems need several machines to agree on one ordered log of commands despite failures. This is the classic consensus protocol, preferred when you need more flexibility than Raft offers.RaftSeveral machines must agree on an ordered log of commands and keep identical state, surviving failures, for metadata, locks, configuration or sharded databases. This is the understandable consensus protocol for that.Two-phase lockingA database needs transactions that behave as if they ran one at a time, when many transactions contend for the same rows and aborting and retrying would thrash.MVCCA database serving long reads (reports, backups) alongside many writers needs readers to see consistent data without blocking writes or being blocked by them.LeasesA lock held by a crashed node blocks everyone forever. In leader election, shard ownership or cached reads, this lets ownership expire safely so another node can take over without hearing from the old holder.Idempotency keysAnyone calling a service over an unreliable network must retry when a reply is lost, yet repeating an action such as a charge, a booking or a server launch must not perform it twice.