€50 that leaves one database and never reaches the other
A wallet app has outgrown one database. Accounts are now split across two PostgreSQL servers by account id: Alice's account lives on database A, Bruno's on database B. Alice sends Bruno €50. Inside one database this would be a single transaction, debit and credit committed together, and either both rows change or neither does. Here there are two databases, so two transactions, and the transfer service has to commit them one after the other.
- The transfer service debits Alice's account by €50 on database A and commits.
- A deploy kills the service's process before it sends anything to database B.
- Database B never hears about the transfer: Alice's balance is down €50 and Bruno's is unchanged.
Commit on B first and the same crash creates €50 instead of losing it. Retrying from the restarted service is no cure on its own, because it does not know which of the two commits happened. The order is not the problem. Each database decides alone to commit, and once one has committed it cannot take the change back if the other then fails: a constraint violated on B, a full disk, a crash. Making the transfer atomic across both needs every database to agree that it can commit before any of them does.
Ask everyone first, then decide once
Two-phase commit (2PC) splits the commit into a vote and a decision, and gives the decision to a single party, the coordinator: usually a transaction manager, here a component of the transfer service. The databases are the participants. The service runs the debit on A and the credit on B as ordinary transactions, leaves both open, and then:
- The coordinator sends PREPARE to database A and to database B.
- Database A checks that it can commit the debit, writes the change and a prepared record, tagged with the transaction's global id, to its durable log, keeps its lock on Alice's row, and votes yes.
- Database B does the same for the credit and votes yes.
- The coordinator, holding two yes votes, writes COMMIT for this transaction to its own durable log.
- The coordinator sends COMMIT to A and to B.
- Each database makes its change visible, releases its locks and acknowledges.
Steps 1 to 3 are the first phase, the vote; steps 4 to 6 are the second, the decision. If any participant votes no, or does not answer before the coordinator's timeout, the coordinator logs an abort instead, sends ABORT to everyone, and each participant rolls back its part.
Step 4 is the commit point. The decision to commit is final the moment that log record is durable, not when the messages arrive; each database makes the change visible only when its COMMIT reaches it, so for a moment A can show the debit while B does not yet show the credit: 2PC makes the outcome all or nothing, not the moment each database shows it. Before that record, the coordinator can still abort. After it, the coordinator must get COMMIT to every participant, retrying until each one acknowledges, because some participant may already have applied it. Replay the deploy from the opening against this protocol: if the process dies before step 4, there is no commit record and the transfer ends up aborted on both databases; if it dies after step 4, the record is there and the transfer ends up finished on both. Either way, a database that has already voted yes and has not been told the outcome usually has to wait for the coordinator to come back; the blocking problem below covers when another participant can tell it instead.
What a yes vote gives away
By voting yes, database B promises it will be able to commit if told to, as long as its durable log survives. If B crashes after voting, it must come back with the prepared transaction still in its log and still able to commit. And B gives up the right to decide alone. Before voting, a participant that hears nothing from the coordinator may simply abort. After a yes, B may not commit on its own, because the coordinator may have decided abort when another participant voted no, and it may not abort on its own, because the coordinator may already have logged commit and told A. It can only wait for the decision, still holding the lock on Bruno's row. A participant in that state is in doubt.
Diagram components: Process, Decision, Document, Split, Process, Process, End, Document, Process, End.
The decision is written to the coordinator's log before any participant is told; the COMMIT record is the commit point.
The diagram follows the coordinator, from the prepare request to the outcome. In the basic protocol, both outcomes pass through a log record before any message goes out: the decision is written down first, then announced.
The coordinator dies at the worst moment
The in-doubt window normally lasts a few milliseconds. It stops being short when the coordinator fails inside it:
- Databases A and B both vote yes.
- The coordinator's machine loses power before any COMMIT or ABORT reaches A or B.
- A and B wait, each holding its lock, so every other transfer that touches Alice's or Bruno's row waits behind them.
- Twenty minutes later the coordinator restarts, reads its log and finishes the transaction.
This is the blocking problem of two-phase commit, and it is not a bug in any one implementation. B cannot guess, since commit and abort are both outcomes the coordinator may already have chosen, and a participant-side timeout does not help, for the same reason. Asking the other participants helps only sometimes. If A has already received the decision, or never voted yes, B can learn the outcome from A. If both voted yes and neither heard back, only the coordinator knows, and they wait for it.
Recovery is why every step goes to a durable log. A restarted coordinator that finds a commit record resends COMMIT until every participant acknowledges; one that finds none never let anyone commit, so it can abort; many implementations go further and treat any transaction they have no record of as aborted, a rule called presumed abort. A restarted participant finds its prepared transactions in its own log, keeps their locks, and asks the coordinator what was decided. If the coordinator never comes back, someone has to resolve those transactions by hand, and a wrong guess leaves the transfer half applied after all.
pg_prepared_xacts lists them.What every commit pays, even on a good day
With nothing failing, 2PC still costs something on every transaction. A commit takes two rounds of messages to every participant, PREPARE and then COMMIT, instead of a single COMMIT, and durable log writes on the coordinator and on each participant before the outcome is known. Locks are held across those network round trips, so one slow participant slows every transaction that wants the same rows. And availability multiplies the wrong way: a transaction commits only if every participant is reachable and votes yes, so each database added to a transaction is one more way for it to abort.
That price is acceptable inside a system that controls all its pieces: a transaction manager committing across resources that support the XA standard, or a distributed database committing across its own shards. Many of those databases replicate the coordinator's decision and each participant's state with consensus, so losing one machine does not leave the others waiting for it to restart; Coordination, consensus & leader election covers consensus. (Three-phase commit, sometimes offered as the fix for blocking, is rarely used in practice.)
Between microservices, two-phase commit is usually avoided. A service's database sits behind its API, and most HTTP APIs, managed queues and third-party services offer no prepare step to vote with. Even where one exists, holding one service's locks while it waits for another service's vote ties their availability together. The usual substitute is a Saga: local transactions committed one by one, with a compensating step for each one a later failure could strand. When the job is only to update a database and publish an event about it, the Transactional Outbox writes the update and the event's row in one local transaction and publishes the event afterwards, with no coordinator at all.
Draw the coordinator, then make a participant go quiet
Open the Two-Phase Commit Flow challenge and draw the transfer from the coordinator's side: PREPARE to every participant, a decision that commits only on unanimous yes votes, and the commit or abort fanned out with a Split and joined with a Merge once every acknowledgement is in. Then work on the failure path: a participant that times out during voting must lead to abort. A good run also writes the decision down before the fan-out, with a Document step for the log, so the flow shows where the commit point is.