What Is Two-Phase Commit — and When You Actually Don't Need One

Two-phase commit (2PC) is a protocol that lets several independent systems agree to either all commit a transaction or all abort it, with no in-between state where one committed and another rolled back. A coordinator asks every participant "can you commit?", waits for all of them to answer, and only then tells them what to do.

The problem it exists to solve is narrow and real: you have written data into two places that do not share a transaction log — two database shards, a database and a message broker, an inventory service and a payments service — and you need the pair to be atomic. Without a protocol, you commit the first, the second fails, and you are left holding a half-finished write that nobody is going to clean up.

The cost is equally real. Two-phase commit converts independent failure domains into a single coupled one, and it introduces a window where participants are blocked, holding locks, waiting for a coordinator that may be dead. Most systems that reach for it would be better served by something weaker.

ConceptTwo-Phase Commit (2PC)
Where it sitsDistributed systems — sits above the storage layer, coordinating commits across independent resource managers
In one sentenceA coordinator asks every participant to promise it can commit, and only after all of them promise does it tell them to actually do it.

The 60-Second Answer

What it does: coordinates a single atomic commit across multiple independent systems that do not share a transaction log.

The problem it solves: partial writes — money debited from one database, never credited in the other, with no automatic cleanup.

The cost it adds: participants hold locks from the moment they vote yes until the coordinator's decision arrives. If the coordinator dies in that window, those rows stay locked until a human or a recovery process resolves them. Availability becomes the product of every participant's availability, not the max.

Rule of thumb: if the two writes can be reconciled after the fact — retried, compensated, or made idempotent — do that instead.

How It Actually Works

1. A coordinator owns the transaction, participants own the data

Two roles, one decision

Two-phase commit splits the world into one coordinator and N participants (often called resource managers — a database, a queue, a service with its own store). The coordinator does not touch the data; it only drives the decision and records it. Each participant holds real state and can independently fail.

The coordinator assigns a transaction ID that every participant tags its work with, so recovery on either side can talk about the same unit of work.

⚠️ In production — The coordinator is not a stateless service you can scale horizontally and forget. It must durably record decisions before communicating them, which means it has a write-ahead log of its own — and that log is now a component you have to back up, monitor, and fail over.


2. Phase one: the coordinator asks, participants promise

Prepare means 'I can no longer fail'

In the prepare phase the coordinator sends a request to each participant asking whether it can commit. A participant that answers yes has done something stronger than expressing an opinion: it has written enough to durable storage that it can commit later even if it crashes and restarts in between. It has given up the right to say no.

coordinator -> A: PREPARE txn-42
coordinator -> B: PREPARE txn-42
A -> coordinator: VOTE YES   (durably prepared)
B -> coordinator: VOTE YES   (durably prepared)

That promise is why the protocol works. Any participant is free to vote no — for a constraint violation, a lock timeout, a full disk — and a single no aborts the whole transaction.

⚠️ In production — "Prepared" is a state your database can sit in indefinitely. Prepared transactions typically hold their locks and pin resources the storage engine cannot reclaim, and they usually do not disappear on restart — that is the entire point. A monitoring gap here is how you end up with a transaction prepared weeks ago silently blocking vacuum or log truncation.


3. The coordinator writes its decision down before announcing it

The durable commit point

Once all votes are in, the coordinator decides: commit if every vote was yes, abort otherwise. Critically it logs that decision durably before sending it to anyone. That log write is the moment the transaction is committed, not the moment participants apply it.

Ordering matters here in a way that is easy to get wrong. If the coordinator announced first and logged second, a crash between the two would leave it unable to say what it had already promised — and participants would have no authority to ask.

⚠️ In production — This is why coordinator storage must be as durable as participant storage. Running the coordinator's log on an ephemeral disk, or fsync-disabled for speed, quietly converts a correctness protocol into a probabilistic one.


4. Phase two: the coordinator tells everyone, and retries until they hear it

Delivery is at-least-once

The coordinator sends commit or abort to each participant, which applies the outcome and releases its locks. Participants acknowledge. If an acknowledgement does not arrive, the coordinator retries indefinitely — it cannot change its mind, so the only forward path is to keep telling the same participant the same thing.

This makes phase two idempotent by construction: applying "commit txn-42" twice must be identical to applying it once, and participants keep enough state to recognise a repeat.

⚠️ In production — Retry-until-acknowledged means a participant that is down for hours leaves the coordinator holding an open transaction for hours. Your alerting should watch the age of in-flight transactions, not just their count — a slowly growing set of decided-but-unacknowledged transactions is the early symptom of every 2PC incident.


5. Recovery: participants ask the coordinator what happened

Presumed-abort keeps the log small

A participant that crashes while prepared comes back up, finds prepared transactions in its log, and cannot resolve them alone — it does not know the votes of its peers. It asks the coordinator for the outcome of each one.

Most implementations use presumed abort: if the coordinator has no record of a transaction, the answer is abort. That lets the coordinator discard log entries for aborted transactions immediately and only durably remember the commits, which is the far rarer and more valuable fact.

⚠️ In production — Presumed abort is safe only because the coordinator logs commit before announcing it. If you build a custom coordinator and get that ordering backwards, an amnesiac coordinator will confidently tell a participant to abort a transaction that another participant already committed — and you have silently broken atomicity.


6. The blocking window is the protocol's defining weakness

Prepared and unreachable means stuck

Between voting yes and receiving the decision, a participant is blocked. It cannot commit, because a peer may have voted no. It cannot abort, because it promised. It holds its locks and waits, and every other transaction that touches those rows waits behind it.

If the coordinator becomes unreachable in exactly that window, that wait has no bound. The participant is not slow — it is stuck, and no amount of local timeout logic can correctly resolve it.

⚠️ In production — This is where the operational scars come from. Teams add a participant-side timeout that unilaterally aborts after N seconds "to protect availability" — and that is precisely the change that lets one participant abort while another commits. If you find yourself designing that timeout, you have discovered that you did not actually want 2PC.


7. Heuristic decisions: the escape hatch that breaks the guarantee

A human overrides the protocol

Because indefinite blocking is unacceptable in production, real implementations expose a manual override: an operator can force a prepared transaction to commit or roll back locally. This is called a heuristic decision, and it is an explicit, documented violation of the protocol's guarantee.

The system records that a heuristic was taken so that when the coordinator finally returns and disagrees, the mismatch is reported as a heuristic exception rather than being silently lost.

⚠️ In production — A heuristic exception is not something the system can fix. It means two participants may now be inconsistent, and resolving it is a business problem — read the records, work out what actually happened, write a correcting entry. Build the runbook for this before you ship 2PC, not during the incident.


8. The coordinator becomes your availability floor

Failure domains multiply, not max

With independent writes, one system being down degrades one feature. Under two-phase commit, any participant being down blocks the whole transaction, so the composite availability is roughly the product of the participants' availabilities plus the coordinator's. Adding a participant makes things strictly worse, never better.

Making the coordinator highly available means replicating its log — usually via a consensus protocol — which is a second distributed system you now operate underneath the first.

⚠️ In production — The frequent surprise is latency, not downtime. Every transaction now costs at least two network round trips to the slowest participant plus multiple fsyncs, all while holding locks. Contention that was invisible at single-digit-millisecond commits becomes a queue at hundreds of milliseconds, and throughput on hot rows falls off well before CPU does.

network cables converging into a switch panel ## Where You Meet It
  • A payments platform writes a ledger entry in one database and a balance update in another, sharded by customer. The two shards run the same engine and sit in the same data centre on the same operations team's pager. Two-phase commit across them is defensible: the failure domains are already coupled, the transactions are short, and a partial write is a regulatory problem rather than a support ticket.
  • A Java service uses XA to enlist a relational database and a JMS broker in one transaction so that a row insert and a message publish are atomic. This is the classic application of 2PC, and it is also the one most teams replace over time with the transactional outbox pattern — write the message into a table in the same local transaction, and have a separate relay publish it — because the outbox has no distributed lock window.
  • A sharded database performs a cross-shard write, and its internal distributed transaction layer runs two-phase commit under the covers with the shards as participants. You do not implement anything; you inherit the behaviour. The observable symptom is that cross-shard writes have visibly worse tail latency and lock contention than single-shard ones, which is why the schema design advice is always to make the common transaction fit in one shard.
  • A data pipeline commits offsets to a broker and results to a warehouse, and wants exactly-once delivery. Some brokers provide a transactional producer that is 2PC-shaped internally. Notice the boundary: the guarantee holds inside the broker's own ecosystem, and the moment your sink is an external system that cannot be enlisted, you are back to idempotent writes keyed by offset.
  • A migration temporarily has a monolith's database and a newly extracted service's database that both must be updated on one API call. Teams reach for 2PC to preserve the old atomicity. It works, but it also means the extraction achieved nothing — the two services still fail together. The usual outcome is going back and redrawing the service boundary so the transaction lives entirely on one side.

When You Actually Need It

  • The two writes must be atomic for correctness that cannot be reconciled after the fact — double-spend, regulated financial ledgers, anything where 'we will fix it in a nightly job' is not an acceptable sentence to say to an auditor.
  • Your participants are already in one failure domain: same data centre, same operations team, same maintenance window. You are not actually giving up independence, because you never had it.
  • The transactions are short and touch few rows, so the prepared window is milliseconds rather than seconds. Long-running or human-in-the-loop workflows are disqualifying by themselves.
  • You are using a platform that already implements 2PC correctly — a distributed SQL database's cross-shard transactions, or a mature XA transaction manager — rather than writing a coordinator yourself. Hand-rolled coordinators are where the subtle correctness bugs live.
  • You can operate the coordinator's log with the same durability and monitoring you give a production database, including alerting on the age of prepared transactions.
  • Compensation is genuinely impossible for this operation. If you cannot write an 'undo' — because the effect left your system, or because a partial state is externally visible and harmful — the atomicity has to happen up front.

When You Don't

  • Both writes go to the same database. Use one local transaction. This sounds too obvious to state, but 2PC and XA get pulled in by frameworks configured for multiple data sources when the application only ever touches one — you pay the protocol overhead for nothing. Check whether your transaction manager has silently escalated to a distributed transaction.
  • One of the writes is a message publish. Use the transactional outbox: insert the message into a table inside the same local transaction as your business write, then have a relay process poll and publish it, with consumers deduplicating on a message ID. You get the same practical guarantee with no coordinator, no prepared locks, and no blocking window — this replaces most of the historical database-plus-broker XA use case.
  • The operation is compensable. If a failed second step can be undone by a refund, a release of a reservation, a cancellation, then use a saga: run the steps independently and issue compensating actions on failure. You trade atomicity for eventual consistency and a window of visible intermediate state. For order flows, booking, provisioning, and most business processes, that window is acceptable and the availability you keep is worth far more.
  • The step is naturally idempotent or retryable. Write the first system, record the intent, and retry the second until it succeeds with an idempotency key. A durable job queue plus idempotent handlers solves a large fraction of what people reach for 2PC to do, and it degrades gracefully instead of blocking.
  • Any participant is a third-party or cross-network service. You cannot make a payment gateway or a partner API vote in your protocol, and you should not want to. Use their idempotency keys and reconcile.
  • You are below the scale where the failure is worth the machinery. If the operation runs a handful of times a minute, the partial-write window is milliseconds, and the blast radius of a rare inconsistency is one support ticket, then log the inconsistency, alert on it, and fix it with a reconciliation query. A daily reconciliation job with an alert is a real engineering answer, not a shortcut — and it is far cheaper to operate than a coordinator with its own replicated log. engineers at a whiteboard sketching a system diagram

What People Get Wrong

"Two-phase commit gives you atomic commits across services, so microservices can keep database-style transactions."

It gives you atomicity at the price of shared fate. Services that commit together fail together, which removes the independent failure isolation that was the reason to split them. If two services need 2PC on every request, the service boundary is in the wrong place.

"2PC solves the distributed consensus problem."

It solves atomic commit, which is a different and weaker goal, and it is not fault-tolerant in the way consensus protocols are. 2PC blocks when the coordinator fails; consensus protocols keep making progress with a majority. This is why production systems often run a consensus protocol underneath 2PC to replicate the coordinator's log — the two are complements, not alternatives.

"We enabled XA in the transaction manager, so our writes are atomic now."

Only if every resource in the transaction genuinely implements the protocol, and only if the driver's distributed transaction support is real rather than an emulation. Some drivers offer a 'best efforts one phase commit' style optimisation that commits resources in sequence with no prepare phase — it is faster and it is not atomic. Verify what your stack actually does before writing a design doc around it.

"The blocking problem is solved; there are protocols that fix it."

Three-phase commit removes blocking under specific failure assumptions, but it does not survive network partitions and adds a round trip, which is why it is largely a textbook protocol rather than a deployed one. In practice the real answers are making the coordinator highly available and keeping the prepared window short — not replacing the protocol.

Why It Was Built This Way

The design follows from one constraint: a participant cannot know the outcome of a transaction from local information alone. Whether the transaction commits depends on votes it never sees. So somebody has to hold the global decision, and every participant has to be willing to accept that decision after the fact — which means it must be able to commit after a crash, without re-checking anything. That requirement is exactly what the prepare phase encodes. "Yes" does not mean "I would like to commit"; it means "I have made committing unconditional."

What was traded away is local autonomy. Before prepare, a participant could abort at any time for any reason: deadlock victim, timeout, shutdown, resource pressure. After prepare, it has surrendered that right to a remote process. The blocking window is not an implementation flaw anyone forgot to fix — it is the direct, unavoidable cost of the promise that makes atomicity possible. You cannot both guarantee a participant will commit if told to, and let it walk away when nobody tells it anything.

The protocol also predates the assumptions most current systems are built on. It comes out of an era where the participants were a handful of databases inside one machine room, connected by a reliable local network and run by one operations team — an environment where coordinator failure was rare and a human could resolve a stuck transaction within the hour. Applied across services owned by different teams, across availability zones, with autoscaling replacing instances underneath it, the same protocol produces a system whose availability is the product of its parts and whose worst failure needs a person to untangle. Nothing about 2PC got worse; the environment changed, which is why the modern answer is usually to design the transaction boundary so you never need it.

Related Briefs

What to Read Next

  • saga pattern vs two-phase commit
  • transactional outbox pattern
  • idempotency keys in distributed systems

Two-phase commit is the correct tool for a small, specific case: short transactions across participants that already share a failure domain, where a partial write is unrecoverable and you are using an implementation you did not write yourself. Everywhere else, the honest move is to design the boundary so the atomic part fits in one local transaction, and handle the rest with an outbox, idempotent retries, or explicit compensation. Reaching for 2PC across service boundaries usually means the boundary is wrong — and moving the boundary is cheaper than operating a coordinator.

댓글

이 블로그의 인기 게시물

What Is a Message Queue - and When You Actually Don't Need One

What Is Blue-Green Deployment - and When You Actually Don't Need One

What Is a JWT - and When a Plain Session Is Still the Better Choice