Technology · Distributed Systems
Two-Phase Commit Explained: Why It Blocks, With Code
Two-phase commit makes several databases commit or abort together. How prepare and commit work, why a coordinator crash blocks you, and Postgres examples.
Abhishek Gupta
6 min read
Sponsored
Two-phase commit is how you make two or more databases agree to commit a transaction together or not at all. A coordinator asks every participant “can you commit?”, collects the answers, and then tells everyone the same verdict. It is correct, it is simple to state, and it has one famous flaw: if the coordinator dies at the wrong moment, the participants wait, holding locks, until it comes back.
That flaw is the reason most microservice teams avoid it, and also the reason it is worth understanding before you decide.
The problem 2PC solves
You move money between two accounts, and the accounts live in two different databases. The debit commits on database A. Before the credit commits on database B, B crashes. The money has left A and never arrived at B.
A single database avoids this with one transaction and a write-ahead log. Two databases have two logs and no shared notion of “committed”. You need a protocol that makes both logs reach the same decision.
The two phases
There are two roles. The coordinator drives the protocol. The participants each hold a piece of the data.
Phase 1, prepare. The coordinator sends PREPARE to every participant. Each participant does the work, writes everything it needs to disk (so it can commit even after a crash), keeps its locks, and replies YES or NO. A YES is a promise: “I can commit this, and I will not change my mind.”
Phase 2, commit or abort. If every reply was YES, the coordinator writes COMMIT to its own durable log and sends COMMIT to everyone. If any reply was NO or missing, it writes ABORT and sends that. Participants apply the verdict, release their locks and acknowledge.
Here is the coordinator in about thirty lines of Python, with the network calls stubbed out so the logic stays visible:
class Coordinator:
def __init__(self, participants, log):
self.participants = participants
self.log = log # durable, append-only
def run(self, txid):
# Phase 1: ask everyone to prepare
votes = []
for p in self.participants:
try:
votes.append(p.prepare(txid)) # True means YES
except TimeoutError:
votes.append(False) # silence counts as NO
decision = "COMMIT" if all(votes) else "ABORT"
# The decision is only real once it is on disk.
self.log.append((txid, decision))
self.log.fsync()
# Phase 2: tell everyone, and keep trying until they all ack
for p in self.participants:
self.send_until_acked(p, txid, decision)
return decision
def send_until_acked(self, p, txid, decision):
while True:
try:
p.finish(txid, decision)
return
except TimeoutError:
continue # participant must hear the verdict
Two details carry all the weight. The coordinator writes the decision to disk before it tells anyone, so after a crash it can recover and re-send. And phase 2 never gives up, because a prepared participant cannot proceed without the answer.
Why it blocks
Look at a participant that has voted YES and is now waiting.
- It cannot commit alone. Another participant may have voted
NO, and then the right outcome is abort. - It cannot abort alone. The coordinator may already have decided to commit and told someone else.
So it waits. While it waits, it holds the locks from phase 1. Other transactions that need those rows queue behind it, and a single stuck coordinator can freeze a hot table.
This is called the in-doubt window. It is short when everything works, a network round trip or two. It becomes long exactly when something is broken, which is when you least want your database frozen.
The fix is not a smaller timeout. A participant that times out and aborts after voting yes breaks the promise and can produce a split outcome. The real fix is a coordinator that writes its decision to durable storage and recovers it after a restart. Some systems replicate the coordinator’s log across nodes with a consensus protocol to avoid a single point of failure, which is the same machinery described in our post on leader election and Raft.
Two-phase commit in Postgres
Postgres supports the participant side. Three statements do the work:
-- On participant A, inside a transaction:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'transfer-8841-a'; -- durable; locks stay held
-- Later, after the coordinator decides:
COMMIT PREPARED 'transfer-8841-a';
-- or
ROLLBACK PREPARED 'transfer-8841-a';
PREPARE TRANSACTION writes the transaction’s state to disk under a global identifier you choose. The session is released, but the transaction stays alive, with its locks, until someone runs COMMIT PREPARED or ROLLBACK PREPARED, even from another session and even after a server restart.
Two practical warnings, both from the PostgreSQL documentation. First, max_prepared_transactions defaults to zero, so PREPARE TRANSACTION fails until you raise it and restart. Second, a prepared transaction that nobody finishes keeps holding locks and blocks cleanup of dead rows. You can see them in pg_prepared_xacts, and you should alert on any that are old. An orphaned prepared transaction is the in-doubt window made permanent.
Postgres does not ship a coordinator. Something external, an application, a transaction manager speaking the XA standard, or a tool you write, has to do phase 2, and it has to survive its own crashes.
When to use it, and when not to
Use 2PC when:
- The transaction is short, ideally milliseconds.
- You control all the participants and can run recovery on each.
- The participant count is small. Every added participant raises the odds that someone is slow or down.
- You need real atomicity and isolation, not “eventually consistent.”
Avoid it when:
- A step takes seconds or minutes, or calls a service you do not operate. Locks held across that call turn into an outage for everyone else.
- Participants are separate teams’ services over an unreliable network.
- You can model the work as steps with compensations.
That last case is the saga pattern. A saga commits each step locally and undoes earlier steps when a later one fails. It never holds cross-service locks, and in exchange you accept that other readers can see intermediate states. For the other common alternative, writing a database change and an event together without a distributed transaction, see the outbox pattern.
A note on three-phase commit
You will see “three-phase commit” mentioned as the cure for blocking. It adds a step so that participants can make progress when the coordinator dies, but it assumes a network where you can tell a crash from a slow link. Real networks do not give you that, so in practice 3PC is mostly a textbook topic. Systems that need non-blocking atomic commit tend to use consensus-based commit instead.
The takeaway
Two-phase commit trades availability for atomicity. The YES vote is a promise that cannot be taken back, and that promise is why a crashed coordinator leaves participants waiting with their locks. Use it for short transactions across a few systems you run yourself, keep the coordinator’s log durable, and monitor for stuck prepared transactions. For anything long or cross-team, design for compensation instead.
Frequently asked questions
- What is two-phase commit?
- It is an atomic commit protocol. A coordinator asks every participant whether it can commit (the prepare phase). If all say yes, the coordinator tells all of them to commit. If any says no or fails to answer, it tells all of them to roll back. The result is that every participant ends up with the same outcome.
- Why is two-phase commit called a blocking protocol?
- Because a participant that has voted yes must wait for the coordinator's decision. It cannot commit on its own, since another participant may have voted no, and it cannot abort, since the coordinator may have decided to commit. If the coordinator is unreachable, the participant sits in the prepared state, holding its locks.
- What is the difference between 2PC and a saga?
- 2PC holds locks across all participants until a global decision is made, so the transaction is atomic and isolated but blocks if something fails. A saga commits each step locally and undoes earlier steps with compensating actions if a later one fails, so it never holds cross-service locks but is only eventually consistent.
- Does PostgreSQL support two-phase commit?
- Yes, as the participant side. PREPARE TRANSACTION saves a transaction's state durably under a global identifier, and COMMIT PREPARED or ROLLBACK PREPARED finishes it later, even after a restart. You need max_prepared_transactions set above zero, and something external has to play coordinator.
- What happens if the coordinator crashes in two-phase commit?
- Participants that already voted yes stay in doubt. They keep their locks until the coordinator recovers and re-sends its decision, which it can do only if it wrote that decision to a durable log first. Participants that have not voted yet can safely abort.
Sources
Sponsored
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored