system-design

Distributed Transactions: 2PC, 3PC

Making several databases commit or roll back together. It works, and the reason almost nobody uses it is worth understanding precisely.

Prerequisites: Transactions, Failure Modes Time to read: ~20 minutes


The problem

Transfer $100 from an account in database A to an account in database B.

DB A: UPDATE accounts SET balance = balance - 100 WHERE id = 'ayesha';  COMMIT;
DB B: UPDATE accounts SET balance = balance + 100 WHERE id = 'bilal';   COMMIT;

🚨 If the process dies between the two commits, $100 has vanished. There’s no shared transaction — each database committed independently and neither knows about the other.

This is the same shape as the dual-write problem and it appears everywhere once you have more than one datastore: two shards, two microservices, a database and a message broker, a database and a payment provider.


Two-phase commit

The classic solution. A coordinator drives all participants to a unanimous decision.

sequenceDiagram
    participant C as Coordinator
    participant A as DB A
    participant B as DB B

    Note over C,B: PHASE 1 — PREPARE
    C->>A: prepare?
    C->>B: prepare?
    Note over A: do the work, write to the log,<br/>acquire locks, but DON'T commit
    A-->>C: yes, ready
    B-->>C: yes, ready

    Note over C,B: PHASE 2 — COMMIT
    Note over C: all said yes → decision: COMMIT<br/>(written durably to the coordinator's log)
    C->>A: commit
    C->>B: commit
    A-->>C: done
    B-->>C: done

The key property of the prepare phase: a participant that answers “yes” is making a binding promise that it can commit — it has done the work, written it durably, and acquired the locks. It has given up the right to abort unilaterally. It must now wait for the coordinator’s decision, and it must be able to honour either outcome even if it crashes and restarts.

If anyone says no, or times out, the coordinator sends abort to everyone.

📐 That’s the whole protocol. It’s correct — it genuinely gives you atomicity across independent systems.


Why almost nobody uses it

🚨 1. It blocks — this is the fatal flaw

1. Coordinator sends "prepare" to A and B.
2. Both reply "yes." Both are now holding locks, waiting.
3. The COORDINATOR CRASHES before sending the decision.
4. A and B wait. They cannot commit (maybe the other said no).
   They cannot abort (maybe the other said yes and the decision was commit).
   THEY HOLD LOCKS INDEFINITELY.

In-doubt participants are stuck, and they can’t resolve it by talking to each other — neither knows what the coordinator decided. The locked rows are unavailable to everyone until the coordinator recovers and reads its log.

🚨 This is why 2PC is called a blocking protocol, and it’s the single most important thing to know about it. A coordinator failure doesn’t just fail the transaction — it can freeze parts of your database until a human intervenes.

2. The coordinator is a single point of failure

Its log is the source of truth for the decision. Lose it and you have permanently in-doubt transactions requiring manual reconciliation. Making the coordinator highly available means replicating it with consensus — at which point you’ve built a substantially complex system.

3. Latency and throughput

📐 Two round trips to every participant, plus durable log writes at each step:

Single-database commit:     ~1 ms
2PC across 3 participants:  ~10–50 ms (same datacenter)
2PC across regions:         ~300–600 ms

And locks are held for the whole duration, so the contended throughput of any hot row collapses.

4. Availability multiplies downward

Every participant must be available for the transaction to commit. Five participants at 99.9% each gives 99.5% for the transaction. → Availability arithmetic

You made the system less available by making it more consistent — exactly the CAP trade, made concrete.

5. Practical friction

XA (the standard distributed transaction API) is supported unevenly, performs poorly, and is notoriously awkward to operate. Most modern databases and virtually no message brokers or HTTP APIs support it properly.


Three-phase commit, and why it doesn’t help

3PC adds a pre-commit phase between prepare and commit, so participants learn the intended decision before it’s final. If the coordinator dies after pre-commit, participants know it was going to commit and can proceed themselves.

⚖️ It’s non-blocking under crash failures — but only if the network is synchronous.

🚨 And it’s unsafe under network partitions, which is the failure mode you actually care about. Two partitions can reach different conclusions: one side sees “pre-commit was sent, proceed,” the other sees “no pre-commit, abort” — and you get an inconsistent outcome, which is worse than blocking. It also adds a third round trip.

Result: 3PC is essentially not used in practice. It’s worth knowing the name and the one-line reason — “it trades blocking for the risk of inconsistency under partition, which is a worse trade” — and nothing more.


What people actually do

1. Avoid the need entirely — the best answer

🚨 Design so that atomic operations stay within one transactional boundary.

🎙️ “If ordering and inventory need to commit atomically on every request, that’s a strong hint they belong in the same service and the same database. Needing a distributed transaction is usually a signal that the boundary is in the wrong place.”

That’s the single most valuable thing to say about this topic in an interview.

2. Sagas — the standard alternative

A sequence of local transactions, each with a compensating action to undo it.

1. Reserve inventory      → compensate: release inventory
2. Charge the card        → compensate: refund
3. Create the shipment    → compensate: cancel shipment

If step 3 fails, run the compensations for steps 2 and 1 in reverse.

✅ No locks held across services. No blocking. Each step is a normal local transaction. ❌ No isolation — intermediate states are visible to other transactions. And compensations are business logic you must write and test.

This is the dominant pattern in microservice architectures. → Saga Pattern — the next chapter

3. The transactional outbox

For the specific case of “update the database and publish an event,” this is exact and simple: write the event to an outbox table in the same local transaction, and have a relay publish it. → CDC

4. Idempotency plus reconciliation

Perform operations independently, make them idempotent so retries are safe, and run a periodic reconciliation job to detect and fix discrepancies.

🚨 This is how real payment systems work. Banks, card networks, and payment processors don’t run distributed transactions across organizational boundaries — they exchange messages, retry on ambiguity, and reconcile daily. “Eventually consistent with automated reconciliation” is the industry’s actual answer for money, which surprises people. → Idempotency

5. NewSQL databases

CockroachDB, Spanner, TiDB, and YugabyteDB do provide distributed ACID transactions across shards — implemented with consensus (Raft/Paxos) per shard plus a distributed commit protocol, rather than classic 2PC with a fragile coordinator.

⚖️ They pay for it in latency (a cross-region transaction costs cross-region round trips) and cost, but the coordinator is consensus-replicated so the blocking problem is largely solved.

🎙️ “If we genuinely need cross-shard ACID, I’d use a database that provides it natively rather than implementing 2PC over independent databases — the coordinator being consensus-backed is what makes it survivable.”


When 2PC is actually reasonable

It’s not universally wrong:

🚨 Never across organizational or network boundaries — a third-party API cannot participate in your transaction, and you’d be handing a stranger the ability to freeze your database.


⚖️ Trade-offs

Approach Gain Cost
2PC True atomicity across systems Blocking on coordinator failure; SPOF; latency; availability multiplies down
3PC Non-blocking under crashes Unsafe under partition; extra round trip; unused in practice
Avoid (colocate data) No distributed transaction at all Constrains sharding and service boundaries
Sagas No locks, no blocking, scales No isolation; compensation logic to write and test
Outbox Exact for the DB+event case Only solves that specific case
Idempotency + reconciliation Robust, simple, no coordination Temporary inconsistency; reconciliation to build
NewSQL Native distributed ACID Latency, cost, ecosystem maturity

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Break a naive dual write. Two Postgres instances and a script updating both. Add a sys.exit() between the commits. Run it. Look at both databases — money has vanished. This makes the problem visceral in about five minutes, and it’s the motivation for everything else.

2. See 2PC block. Postgres supports prepared transactions (max_prepared_transactions > 0):

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'txn1';        -- prepared, but not committed

-- Now, from another session:
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;   -- BLOCKS. Indefinitely.

SELECT * FROM pg_prepared_xacts;   -- there it is, in doubt

COMMIT PREPARED 'txn1';            -- or ROLLBACK PREPARED — releases it

Leave the prepared transaction open and watch the row stay locked. That’s exactly what happens when a coordinator dies, and experiencing the block is far more convincing than reading about it.

3. Implement a saga. Three services with local transactions and compensating actions. Make step 3 fail and watch the compensations run. Then make a compensation fail and work out what you’d do — that’s the hard part nobody mentions.

4. Measure the latency cost. Time a local commit versus a 2PC commit across two databases. Then add 100 ms of artificial network latency between them and measure again.


Check yourself

1. What exactly happens if the 2PC coordinator crashes after the prepare phase? Participants that answered "yes" are **in doubt**: they've done the work, written it durably, and acquired locks, and they've promised not to abort unilaterally. They cannot commit (perhaps another participant voted no and the decision was abort) and cannot abort (perhaps everyone voted yes and the decision was commit). They also can't resolve it among themselves — none of them knows what the coordinator decided. So they hold their locks indefinitely, making those rows unavailable to all other transactions, until the coordinator recovers and reads its decision log, or a human manually resolves them. This blocking behaviour is 2PC's defining weakness.
2. Why doesn't three-phase commit solve the problem? 3PC adds a pre-commit phase so participants learn the intended decision before it's final, making it non-blocking under *crash* failures. But it's only safe if the network is synchronous with bounded message delays. Under a **network partition** — the failure mode that actually matters — two sides can reach different conclusions: one group saw the pre-commit and proceeds to commit, the other didn't and aborts. You've traded "everyone blocks" for "the outcome is inconsistent," which is strictly worse, since blocking is at least recoverable. Plus it adds a third round trip. This is why it's a textbook protocol rather than a deployed one.
3. What's the best alternative to a distributed transaction, and why? Avoiding the need for one — designing so that operations requiring atomicity live within a single transactional boundary. Shard by a key that keeps related data together (all of a user's data on one shard makes user-scoped operations locally ACID), and draw service boundaries so that a business transaction doesn't routinely span services. If two services constantly need to commit atomically together, that's strong evidence they should be one service. This eliminates the problem rather than managing it, and it removes the latency, availability, and blocking costs entirely. When you genuinely can't avoid it, sagas are the standard fallback.
4. How does Spanner use 2PC successfully when the general advice is to avoid it? Because it fixes 2PC's fatal flaw. In classic 2PC, the coordinator is a single process whose failure leaves participants in doubt. In Spanner, each participant is a **Paxos group** rather than a single node, and the coordinator's decision is itself replicated by consensus. If the coordinator's leader fails, the Paxos group elects a new leader that already has the decision in its replicated log — so no participant is ever permanently in doubt. Combined with TrueTime for timestamp ordering, this gives distributed ACID transactions with bounded recovery. The lesson: 2PC's problem isn't the protocol, it's the unreplicated coordinator.
5. Why does adding participants to a distributed transaction reduce availability? Because every participant must be available for the transaction to commit — they're in series, so availabilities multiply. Five participants at 99.9% each gives 0.999⁵ ≈ 99.5%, roughly 3.6 hours of unavailability per year, worse than any individual participant. This is the CAP trade made concrete: by requiring all participants to agree (consistency), you've made the system unavailable whenever any one of them is unreachable. Sagas invert this — each step only needs its own service available, and partial progress is handled by compensation rather than by refusing to proceed.

Further reading