R2 — Atomicity across machines: distributed transactions and why you avoid them

R0 ended on a promise: atomicity does not survive the trip across the network. This section is what that costs, and what the industry does instead.

Where the boundary bites

Atomicity — all of it happens or none of it does — is something one database gives you inside one transaction. It does not stretch across two systems. Your database transaction cannot un-send a message the broker already accepted, and the broker cannot un-accept one when your transaction aborts.

There is a mechanism for spanning two systems — the distributed transaction — and the rest of this section is why you will not be using it.

So every time an operation touches something outside its own database — a broker, another service, a payment rail, an email provider — you are on the wrong side of this boundary and need a deliberate answer.

The distributed transaction

Everything in R3, R4 and R5 is a workaround for not having this. It only makes sense once you know what it replaces.

What a transaction gives you. ACID: atomicity (all or nothing), consistency (constraints hold), isolation (concurrent transactions do not see each other's partial work), durability (committed means survived). Inside one database this is solved, cheap and reliable. The moment an operation spans two databases, or a database and a broker, none of it extends across the gap.

What a distributed transaction is. One logical unit of work that changes data in more than one independent system — two databases, or a database and a message broker — with the same promise a local transaction makes: either every system commits its part, or none of them does.

The components of a distributed transaction

A distributed transaction always involves the same four roles. Naming each one precisely, rather than saying "the system", shows the interviewer you know where every responsibility sits — and therefore where every failure lands.

Component Its role Examples
Application Marks where the transaction begins and ends, and does its business work in between. It never runs the protocol itself. A Spring @Transactional method with JTA configured; an EJB container-managed transaction
Transaction manager (the coordinator) Issues the global transaction ID, enlists each resource the application touches, runs the commit protocol, and owns recovery after a crash. Narayana (JBoss/WildFly), Atomikos, Bitronix, WebLogic JTA, MSDTC on Windows
Transaction manager log The coordinator's durable record of each decision. The outcome becomes final when it is written here, and recovery after any crash is a replay of it. Narayana's object store, a JDBC-backed TM log, a dedicated log volume
Resource managers The systems that actually hold the data. Each can prepare its part, hold it, then commit or roll back on the coordinator's instruction. Oracle and Postgres via their XA drivers, DB2, IBM MQ, ActiveMQ, some JMS providers

One absence in that table explains most of the modern objection: Kafka is not a resource manager. Nor are most HTTP services, payment rails or SaaS APIs. The protocol needs every participant to implement prepare-and-wait, and the ecosystem you actually build on largely does not.

How the components talk: the two-phase commit protocol

The components coordinate through two-phase commit (2PC), a protocol that splits "commit" into a vote and a decision:

  1. Phase 1 — prepare. The transaction manager asks every resource manager whether it can guarantee to commit its part. A resource that says yes first writes its changes durably and keeps its locks; from then on it has given up the right to decide on its own.
  2. The decision. If every vote is yes, the transaction manager writes COMMIT to its log; if any vote is no, it writes ABORT. The decision is that log write, not the messages that follow it.
  3. Phase 2 — commit or roll back. The transaction manager tells every resource the outcome; each one applies it and releases its locks.

Step through the four scenarios below. The first is the happy path and the second shows a single "no" vetoing the whole transaction — both are 2PC working correctly. The third is the failure that disqualifies it: watch what the resource managers are still holding after the coordinator dies. The fourth shows what people do under pressure when it stays dead.

The protocol is correct. The problem is what happens when the coordinator dies between the two phases.

A participant that has voted yes has surrendered its right to decide. It must hold its locks and wait for an answer that may never come — it cannot commit (the other participant may have voted no) and cannot abort (the other may already have committed). This is the blocking window, and it is not a bug to be engineered away; it is a proven property of the protocol.

The interface they talk through: XA

Two-phase commit only works across products from different vendors if they agree on the exact calls. XA is that agreement: an open standard from X/Open (now The Open Group) defining the interface between a transaction manager and its resource managers. XA is the interface; two-phase commit is the protocol it carries.

XA call What it does in the protocol
xa_start / xa_end Associate the resource's work with the global transaction ID, then mark that work complete
xa_prepare Phase 1: "can you guarantee to commit?" — the resource persists its changes and votes
xa_commit / xa_rollback Phase 2: apply the transaction manager's decision
xa_recover After a crash, list the transactions this resource still holds in doubt, so the transaction manager can finish them from its log

In Java the same interface appears as JTA: every XA-capable driver implements javax.transaction.xa.XAResource (jakarta.transaction.xa in newer Jakarta EE), whose start, end, prepare, commit, rollback and recover map one-to-one onto the calls above. That is why a resource manager in the table above is precisely "a system that ships an XA driver" — and why Kafka, which does not, cannot take part.

Where 2PC sits: CAP, PACELC and ACID

This is where the theory earns its keep, because it shows the blocking window is not a flaw in the implementation — it is a choice the protocol makes on your behalf, every time.

CAP, stated properly. Consistency, availability, partition tolerance — and the common phrasing, "pick two of three", is misleading. In a distributed system partitions are not optional; the network will drop messages whether you planned for it or not. So the real statement is narrower: when a partition happens, you must choose between consistency and availability. Keep answering and risk disagreeing, or stop answering and stay correct.

PACELC: the trade-off on a normal day. CAP only tells you what happens during a partition — when machines cannot reach each other. Partitions are rare; almost all of the time the network is working fine, and CAP says nothing about that time. PACELC adds it. The name reads as a sentence: if there is a Partition, choose Availability or Consistency; Else — no partition, a normal day — choose Latency or Consistency.

That second half is the one you feel every day. Keeping copies in agreement means waiting for other machines to confirm each write, and that waiting is latency: a write that must be confirmed by a replica in another region pays a cross-region round trip, tens of milliseconds, on every write, even though nothing is broken. Answering from the nearest copy without waiting is fast, but may return slightly stale data.

What 2PC chooses, in both halves:

During a partition With no partition
2PC / XA C — participants block rather than risk disagreeing C — two round trips plus locks held across the network, on every transaction
Saga with compensation A — each service keeps serving, and the business transaction converges afterwards L — no cross-service coordination in the hot path
Outbox + idempotent consumers A — the sender commits locally and the message catches up L — publishing is asynchronous
Spanner / CockroachDB C — consensus-replicated participants, so a coordinator loss does not block forever C — and they pay for it in commit latency
Dynamo-style stores A — accept writes on both sides, resolve later L — quorum size tuned down for speed

So 2PC is PC/EC: the consistency corner in both halves. Nothing about that is wrong — it is exactly what you want for a transfer between two accounts in one bank's ledger. It is simply the most expensive corner, and you are paying for it on every transaction, not only on the rare day a partition occurs.

ACID and 2PC. The point of 2PC is to stretch a single database's ACID guarantee across several independent resource managers. It is worth being precise about how much of it actually stretches:

Two implications worth saying out loud. First, ACID is a property of a transaction, not of a system — so "we need ACID across microservices" is usually a sentence that has not been thought through, because the only honest way to get it is to make everyone wait. Second, the industry's answer is not to weaken correctness but to relocate it: keep strict ACID inside each service, and use compensation plus reconciliation to converge between them. That is what BASE names — basically available, soft state, eventual consistency — and it is a deliberate trade, not a lowering of standards.

Why this is disqualifying in practice

Where it legitimately still lives. Inside a single database across shards, where one vendor controls every participant. In systems that hide it behind consensus rather than a bare coordinator — Spanner and CockroachDB run 2PC with Paxos-replicated participants, so a coordinator failure does not block indefinitely. Within Kafka, where transactions cover topics and offsets inside one cluster. And in legacy enterprise stacks with a real XA transaction manager, usually between two databases in the same data centre.

The alternatives, and what each gives up

Since distributed ACID inevitably couples the availability of every participant, the industry made a different bargain: keep real ACID inside each local database, and accept eventual consistency across the network. That is the philosophical shift. The three approaches below are not drop-in replacements for a distributed transaction — they are what you build once you have stopped asking for one.

The protocol approach, for completeness:

Approach What it gives up When to use it
2PC / XA Availability, throughput, participant freedom One vendor's resources, low volume, same data centre

Eventual consistency paradigms — local ACID plus network-level convergence:

Approach What it gives up When to use it
Outbox Nothing that matters — it makes the second write local State change plus telling someone else
TCC (try, confirm, cancel) Isolation — the reservation is visible to others Reservations with an explicit hold, such as funds or inventory
Saga Atomicity itself — intermediate states are visible Multi-step work spanning services that own their own state
Idempotency + reconciliation Instant consistency; you converge afterwards Anything crossing a boundary you do not control

TCC is worth recognising by name, because it is 2PC's shape without its blocking: try reserves, confirm commits the reservation, cancel releases it. Each phase is an ordinary call that can be retried, nobody holds a database lock across the network, and the reservation is explicit domain state rather than an invisible lock. A funds hold is TCC.

TCC in practice: an e-commerce checkout paid by card

TCC is not only a textbook pattern. Every card payment is one: authorisation places a hold on the cardholder's funds (try), capture turns the hold into a charge (confirm), and a void — or simply letting the authorisation expire — releases it (cancel). Seat and ticket holds with a countdown, hotel reservations and a bank's funds earmarks have the same shape, and Apache Seata, the distributed-transaction framework Alibaba open-sourced, ships a TCC mode for services that want it as a framework. In most stacks, though, TCC lives in the domain model as explicit holds rather than in a framework.

An online checkout is the clearest worked example, because it has to move two things that live in different systems, stock and money, and must not move one without the other. Neither the inventory database nor the card network can join an XA transaction, but each of them offers a natural hold.

Try Confirm Cancel If nobody ever sends confirm or cancel
Inventory service Reserve the quantity: reserved += qty only if on_hand - reserved >= qty, with an expires_at Commit: on_hand -= qty, reserved -= qty Release: reserved -= qty A sweeper releases reservations past their 10–15 minute TTL
Card, through the PSP Authorise the amount with manual capture: the issuer puts a hold on the cardholder's available credit Capture — in full, partially, or in several parts for split shipments Void the authorisation The issuer drops the hold when the authorisation expires — around seven days for most online card payments
Order service (the coordinator) Records PENDING, calls both tries Records CONFIRMED before confirming anything Records CANCELLED before cancelling anything Restarts from the recorded state

The flow follows from the table:

  1. The order service writes the order as PENDING with its idempotency record (R3), then sends both tries.
  2. Both tries succeed: it writes CONFIRMED, then confirms the stock straight away and captures the card when the goods ship. Capturing at dispatch is why a cancellation before shipping costs a void rather than a refund.
  3. Either try fails — out of stock, or the card is declined: it writes CANCELLED, then cancels whichever tries did succeed.

The order service's state write is the decision — exactly like the TM log in 2PC — but nobody blocks while waiting for it, because every hold carries its own expiry.

The rules that make TCC correct

Two of these are obvious, two are not, and frameworks such as Seata exist largely to enforce the last two.

Rule Why
Try does all the checking Stock, limits, fraud screening, card authorisation: anything that can say no says it in try, so confirm has no business reason left to fail
Confirm and cancel are idempotent and retried until they succeed They are not allowed to fail for business reasons; they can only fail transiently, so they are retried, keyed by order ID and participant
Empty rollback A cancel can arrive for a try that never ran — the try request was lost and the coordinator timed out. The participant records "cancelled" and answers success, because there is nothing to undo
Suspension (the hanging try) A delayed try can arrive after its cancel. The participant must refuse it, or it reserves stock for an order that is already cancelled and holds it until the TTL

The last two are handled by one small record per transaction and participant, written in the same local transaction as the reservation:

Arriving call No record yet Record says TRIED Record says CANCELLED Record says CONFIRMED
Try Reserve, write TRIED Already done — return the same result Refuse — suspension Already done
Confirm Error — confirm without try is a coordinator bug Commit, write CONFIRMED Error — alert No-op
Cancel Write CANCELLED, nothing to undo — empty rollback Release, write CANCELLED No-op Too late — this order needs a refund, not a cancel

An authorisation that expires before capture is a cancel you did not send. If shipping can take longer than the authorisation stays valid, the design needs a re-authorisation before capture, and reconciliation (R6) must catch any order marked confirmed whose capture later failed.

Step through the checkout below. The first tab is the happy path and the second a declined card. The third crashes the coordinator after both tries, so you can watch the holds expire on their own instead of blocking anyone. The fourth delivers a try late, after its cancel, to show the empty rollback and suspension rules.

Where TCC beats a plain saga is the undo. A saga's compensation is invented afterwards — a refund, a correcting entry — and can itself fail. TCC's cancel releases something that was reserved for the purpose of being released, so it is guaranteed to be possible. That is why R5 calls TCC a saga with a better first step: use it wherever the domain already has a hold.

Semantic locks. Notice what TCC's reservation actually is, and what a saga's PENDING status does. A database lock is invisible infrastructure — nobody outside the engine knows it exists. When you set a row to PENDING, or place a hold on ₹5,000, you are doing the same job in the domain model instead: a flag that other operations are expected to respect. That is a semantic lock — isolation re-implemented at the application layer, because the database can no longer provide it across services.

Two consequences follow, and both are worth saying out loud. It only works if every code path honours it, since nothing enforces it for you. And it is visible to users, which is why a bank shows "available balance" separately from "current balance" — the semantic lock has leaked into the product, deliberately.

What to say in the interview

When asked "how do you handle distributed transactions?", refuse the premise, then show the ladder: "I avoid them — 2PC couples availability and blocks on coordinator failure. I keep the atomic part inside one database using an outbox, use TCC — explicit reservations with an expiry, like a card authorisation and a stock hold at checkout — where the domain has a hold, use sagas with compensation where state spans owners, and reconcile against anything outside my control."