Distributed Transactions
The fan has held seat A-12 and taps Pay R$ 450. Three things must now happen together: inventory turns the seat from HELD into SOLD, payment charges the card through an external provider, and order records a confirmed order. Inside one PostgreSQL database this would be a single transaction β BEGIN β¦ COMMIT, all or nothing. But the three pieces live in three services with three databases (and one of them is someone else's company). What happens when the charge succeeds and the inventory database is down? Or the seat is marked SOLD and the charge is declined?
A distributed transaction is a unit of work that changes data on several nodes and must be atomic: either every change happens, or none does. This page shows the classic protocol that achieves it β two-phase commit (2PC) β, why it is rarely used across microservices, and what replaces it in practice: sagas, the reservation (tryβconfirmβcancel) pattern and the transactional outbox.
Atomicity across nodes: the problem
On one node, atomicity is provided by the database's log: a transaction's changes become durable and visible at a single point, the commit record. Across nodes there is no single log, and each participant can fail independently β so a naΓ―ve "commit everywhere, one after the other" leaves a window where some participants committed and others did not:
sequenceDiagram
participant O as order (checkout)
participant I as inventory DB
participant P as payment provider
O->>I: UPDATE seat A-12 to SOLD, then COMMIT
I-->>O: committed
O->>P: charge R$ 450
P-->>O: declined (card limit)
Note over O,P: the seat is SOLD but nobody paid:<br/>no single place can roll both back Two families of solutions exist:
| Approach | Idea | Isolation | Availability |
|---|---|---|---|
| Atomic commitment (2PC) | Everyone prepares, then a coordinator decides commit/abort for all | Full ACID across participants | All participants and the coordinator must be up at the same time |
| Sagas and compensations | A sequence of local transactions; on failure, run compensating transactions that undo the earlier steps | None between steps (intermediate states are visible) | Each step only needs its own service |
Two-phase commit (2PC)
2PC introduces a coordinator (often the service or database node that started the transaction) and splits the commit into two phases1:
- Prepare (voting): the coordinator asks every participant "can you commit?". A participant that answers yes must first write all the changes and a prepared record durably to disk and keep the locks: from that moment it has promised to commit if asked β it can no longer abort on its own.
- Commit (decision): if all voted yes, the coordinator writes commit to its own log and tells everyone to commit; if anyone voted no (or timed out), it decides abort.
sequenceDiagram
participant C as coordinator
participant I as inventory DB
participant R as order DB
C->>I: PREPARE (seat A-12 β SOLD)
C->>R: PREPARE (order 981 β CONFIRMED)
I-->>C: YES (changes + prepared record on disk, locks held)
R-->>C: YES
Note over C: all YES: write COMMIT to the coordinator log
C->>I: COMMIT
C->>R: COMMIT
I-->>C: done (locks released)
R-->>C: done PostgreSQL exposes the participant side as PREPARE TRANSACTION 'id' followed later by COMMIT PREPARED 'id' or ROLLBACK PREPARED 'id'; the X/Open XA standard defines the same protocol for databases and message brokers coordinated by a transaction manager (as in Jakarta EE).
The blocking problem
The weak point is the moment after a participant voted yes and before it hears the decision. If the coordinator crashes there, the participant is in doubt: it cannot commit (maybe someone voted no) and cannot abort (maybe the coordinator decided commit and others already committed). It must wait, holding its locks, until the coordinator recovers:
sequenceDiagram
participant C as coordinator
participant I as inventory DB
participant R as order DB
C->>I: PREPARE
C->>R: PREPARE
I-->>C: YES (locks on seat A-12 held)
R-->>C: YES
Note over C: crashes before sending the decision
Note over I: in doubt: cannot commit, cannot abort
Note over I,R: seat A-12 stays locked until<br/>the coordinator comes back You can cause it yourself in the simulator under Sagas: choose 2PC and coordinator crash.
During the 10:00 on-sale, a locked seat row blocks every other fan trying to hold it, and a crashed coordinator can freeze a whole block of seats. Three-phase commit (3PC) tries to remove the blocking but assumes bounded network delays, which real networks do not guarantee; production systems instead make the coordinator itself fault-tolerant by replicating its decision with consensus β exactly what Google Spanner (2PC over Paxos groups) and CockroachDB (over Raft) do2.
What 2PC costs
- Latency: two round trips to every participant, plus forced disk writes (fsync) at each prepare and at the decision. Locks are held for the whole duration β the slowest participant decides how long.
- Availability: the coordinator and every participant must be up at the same time. Availabilities multiply, like services in series:
- Coupling: every participant must speak the same protocol and expose "prepared" state. An external payment provider will never join your 2PC: its API charges or declines, with no "prepare and wait for my decision" step.
Where 2PC is alive and well
2PC is the standard inside a distributed database or broker, where one vendor controls all participants and the coordinator is replicated: Spanner, CockroachDB, YugabyteDB, TiDB, and Kafka transactions (atomic writes to several partitions plus consumer offsets). What is discouraged is 2PC across independently deployed microservices and third parties.
Think first: inventory and order share the same PostgreSQL cluster but use different schemas. Do you need 2PC between them?
No β if both tables are in the same database, a single local transaction covers both, and that is far simpler and faster than any distributed protocol. Splitting data into separate databases is a decision with a price; when two pieces of data must change atomically all the time, that is a strong hint they belong to the same service or at least the same database (see Compute β services).
Sagas
A saga (Garcia-Molina and Salem, 1987)3 splits a long transaction into a sequence of local transactions \(T_1, T_2, β¦, T_n\), each committed in its own service's database. If step \(T_k\) fails, the saga runs compensating transactions \(C_{k-1}, β¦, C_1\) that semantically undo the steps already done. There is no global lock and no global commit point.
The BoxOffice checkout as a saga:
| Step | Local transaction (service) | Compensation |
|---|---|---|
| 1 | Create order PENDING (order) | Mark order CANCELLED |
| 2 | Confirm the hold: seat HELD β RESERVED_FOR_PAYMENT (inventory) | Release the seat β FREE |
| 3 | Charge the card (payment β provider) | Refund the charge |
| 4 | Seat β SOLD, order β CONFIRMED, issue ticket | (last step: no compensation; it must succeed β retried until it does) |
Try it
Play the default run, charge declined: T1 and T2 commit, T3 fails, and the orchestrator runs C2 (release A-12) and then C1 (cancel order 981) β the saga log lists them in reverse order of the steps they undo, and time holding DB locks stays at 0 ms. Choose charge timeout: the card was charged but the reply was lost, so the orchestrator adds C3, a refund with the same idempotency key, before releasing the seat. Choose ticket down: the failure comes after the pivot (the successful charge), so nothing is compensated β the ticket step fails twice, succeeds on the third try, and the order ends CONFIRMED. Then switch to 2PC: with no failure the seat row is locked for 145 ms; with coordinator crash every participant has voted YES and waits in doubt β the lock time grows by a second per step and fans queue behind seat A-12 until you restart the coordinator, which finds no decision in its log and aborts.
Two ways to coordinate the steps:
A central orchestrator (here, the order service or a workflow engine such as Temporal or AWS Step Functions) tells each participant what to do and decides when to compensate. The saga's logic is in one place, easy to read, monitor and change; the orchestrator must persist its state so it survives restarts. Preferred when there are more than a few steps.
No central brain: each service reacts to events from the others (OrderCreated β inventory reserves β SeatReserved β payment charges β PaymentFailed β inventory releasesβ¦). Loosely coupled and simple for two or three steps; with more, the flow is spread over many services and hard to follow ("who compensates what?"). Covered with Kafka in Messaging β patterns.
Sagas give up isolation
Between steps, other transactions see intermediate states: an order PENDING, a seat RESERVED_FOR_PAYMENT. Sagas are ACD without the I. Design countermeasures explicitly4:
- Semantic locks: the intermediate state is the lock β while a seat is
RESERVED_FOR_PAYMENT, no other fan can hold it, and the hold expires after 10 minutes if the saga never finishes. - Commutative and idempotent steps: every step and compensation may be retried (at-least-once), so each one needs an idempotency key (Idempotency).
- Order the steps by risk: do the cheap, invisible, easily reversed steps first, and the steps that are hard or impossible to compensate (a charge, an e-mail) last, so that a failure late in the saga has little to undo. A refund is a compensation the fan sees; releasing a seat is invisible.
- Pivot step: the step after which the saga can only go forward (here, the successful charge) β after it, the remaining steps must be retried until they succeed, never compensated.
In the BoxOffice code: one saga step around the charge (lab 9)
The labs build a shortened version of this saga in order: lab 7 creates the order PENDING_PAYMENT, and Hands-on 9 β Patterns adds the payment step. Look at three choices. The charge happens outside any database transaction (a connection is never held while waiting for the provider). The result is applied with a conditional UPDATE β¦ WHERE status = 'PENDING_PAYMENT', so two concurrent pay calls β both deduplicated by payment through the same idempotency key β change the status once and emit one event. And the outcome (OrderConfirmed or OrderCancelled, the compensation's trigger) leaves through the outbox, in the same local transaction as the status change.
/** No @Transactional here: never hold a DB connection during a remote call. */
public OrderView pay(UUID id) {
OrderView order = find(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!"PENDING_PAYMENT".equals(order.status())) {
return order; // already paid or cancelled: paying twice is a no-op
}
PaymentResponse payment = resilience
? payments.charge(order.id(), order.amount())
: payments.chargeUnprotected(order.id(), order.amount());
String next = "AUTHORIZED".equals(payment.status()) ? "CONFIRMED" : "CANCELLED";
tx.executeWithoutResult(status -> {
int changed = jdbc.sql("UPDATE orders SET status = ? WHERE id = ? AND status = 'PENDING_PAYMENT'")
.params(next, id)
.update();
if (changed == 1) {
String type = "CONFIRMED".equals(next) ? "OrderConfirmed" : "OrderCancelled";
OrderEvent event = new OrderEvent(UUID.randomUUID(), type, order.id(), order.showId(),
order.holdId(), order.customerEmail(), order.amount(), Instant.now());
outbox.append(event.eventId(), "order", order.id(), type, event);
}
});
return find(id).orElseThrow();
}
Reservation: try, confirm, cancel
The TCC pattern (tryβconfirmβcancel) makes each participant offer a tentative operation with a timeout. BoxOffice already works this way: the hold is the try (the seat is reserved for 10 minutes), the sale is the confirm, and expiry or release is the cancel. Payment providers offer the same shape: authorise (reserve funds) β capture (take them) or void (release them). Because every tentative state expires by itself, a crash anywhere leaves the system converging to a safe state instead of blocking.
The dual-write problem and the outbox
Every saga step does two things: change its own database and tell the next step (send an event or a command). Doing them as two separate writes is the dual-write problem β if the service crashes between them, the database says "seat reserved" but no event is ever sent, and the saga stalls:
sequenceDiagram
participant I as inventory
participant DB as inventory DB
participant K as Kafka
I->>DB: UPDATE seat + INSERT INTO outbox (SeatReserved), one COMMIT
Note over I,DB: one local transaction: both or neither
DB-->>I: committed
Note over DB,K: a relay (poller or CDC such as Debezium)<br/>reads the outbox table
DB->>K: publish SeatReserved
K-->>DB: ack: mark outbox row as sent The transactional outbox writes the event into an outbox table in the same local transaction as the business change; a separate relay publishes outbox rows to the broker and marks them sent. Publishing is at-least-once, so consumers must be idempotent. The details (polling vs change data capture, ordering, cleanup) are in Messaging β patterns and Hands-on 7.
In the BoxOffice code: order + outbox in one transaction (lab 7)
Hands-on 7 β Outbox writes the order and its OrderPlaced event in one @Transactional method; Outbox.append is declared Propagation.MANDATORY, so it fails if anyone calls it outside the business transaction. The same method is idempotent: ON CONFLICT (hold_id) DO NOTHING turns a retried request for the same hold into a replay that returns the existing order and appends no second event. The lab proves the guarantee by stopping Kafka: the order was accepted while the broker was down, and its event was published 71 s later, when Kafka came back β the fan never saw the outage.
@Transactional
public PlaceResult place(OrderRequest req) {
int inserted = jdbc.sql("""
INSERT INTO orders (id, show_id, hold_id, customer_email, amount, status)
VALUES (?, ?, ?, ?, ?, 'PENDING_PAYMENT')
ON CONFLICT (hold_id) DO NOTHING
""")
.params(UUID.randomUUID(), req.showId(), req.holdId(), req.customerEmail(), req.amount())
.update();
OrderView order = jdbc.sql(SELECT + " WHERE hold_id = ?")
.param(req.holdId())
.query(OrderView.class)
.single();
if (inserted == 0) {
// same hold already ordered: idempotent replay, no second event
return new PlaceResult(order, false);
}
OrderEvent event = OrderEvent.placed(order);
if (props.notifyMode() == NotifyMode.SYNC) {
notifications.send(event); // BEFORE: the fan waits for the e-mail
} else {
outbox.append(event.eventId(), "order", order.id(), event.eventType(), event);
}
return new PlaceResult(order, true);
}
Choosing
| Local transaction | 2PC / distributed DB | Saga (orchestrated) | Saga (choreographed) | TCC / reservation | |
|---|---|---|---|---|---|
| Atomicity | Yes | Yes | Eventually, via compensation | Eventually, via compensation | Eventually, via confirm/cancel |
| Isolation | Yes | Yes | No (semantic locks) | No | Partial (the reservation) |
| Availability | One DB | All participants at once | Each step independently | Each step independently | Each step independently |
| Latency | Lowest | 2 round trips + fsyncs, locks held | Sum of steps, asynchronous possible | Asynchronous | Try now, confirm later |
| Works with third parties | β | No | Yes | Yes | Yes, if they offer reserve/capture |
| Complexity | Lowest | In the database (if it offers it) | Orchestrator state, compensations | Event flow spread across services | Timeouts and expiry logic |
| BoxOffice use | Hold a seat; seat + outbox row | Inside CockroachDB/Spanner if used | Checkout | Notifications, analytics | Seat hold; card authorise/capture |
BoxOffice: the checkout, end to end
- Hold (TCC try):
inventoryruns one local transaction β conditionalUPDATEof the seat toHELDwithexpires_at, plus an outbox rowSeatHeld. - Checkout saga, orchestrated by
order: create orderPENDINGβinventorymoves the seat toRESERVED_FOR_PAYMENTβpaymentauthorises the card with an idempotency key βinventorymarks the seatSOLDβpaymentcaptures β orderCONFIRMEDβnotificationsends the ticket. - Failures: card declined β release the seat, cancel the order. Timeout talking to the provider β query the provider by idempotency key before deciding (the charge may have happened β see Slow or dead?). Orchestrator crash β it resumes from its persisted state. Abandoned hold β expires after 10 minutes and the sweeper releases the seat.
- No 2PC anywhere across services β the external provider could not join one anyway.
In the interview
When a design touches several services' data "atomically", say: "I won't use 2PC across services β it blocks if the coordinator fails, couples availability of every participant, and the payment provider can't participate. I'll use an orchestrated saga with compensations, idempotent steps, a semantic lock on the seat, and a transactional outbox to publish each step's event." Then name the pivot step and what happens on a timeout with the provider. If the data can live in one database, say so β the best distributed transaction is the one you avoid.
Best practices
- Keep remote calls outside database transactions β a connection held while the provider takes 3 s is a pool of 10 exhausted by 4 orders per second. In BoxOffice:
OrderService.paycharges first, then opens a short transaction (lab 9, box above). - Model each saga as explicit states with conditional transitions β
UPDATE β¦ WHERE status = 'PENDING_PAYMENT'makes every step safe to repeat and every illegal jump impossible. - Make the outbox impossible to call outside the transaction β
@Transactional(propagation = MANDATORY)onOutbox.appendturns a forgotten transaction into an error at the first test (lab 7). - Give every tentative state an expiry and a sweeper β a hold that nobody confirms frees itself. In BoxOffice: 10-minute holds and a sweeper every 5 s in lab 6.
- Alert on sagas stuck past their timeout, and reconcile with the provider daily β an order
PENDING_PAYMENTfor an hour, or a charge without an order, is a bug or a lost message; find it before the fan does.
Pitfalls
- Dual writes (database, then broker) without an outbox: a crash between them silently stalls the saga.
- Non-idempotent steps or compensations: retries double-charge or double-refund.
- Forgetting that compensations can fail: they must be retried until they succeed, and alerted on when they cannot.
- Treating sagas as isolated: another fan sees
RESERVED_FOR_PAYMENT; design for it (semantic locks, expiry). - 2PC with a non-replicated coordinator: a coordinator crash leaves participants in doubt with locks held.
How it connects
flowchart LR
IDEM["idempotency keys<br/>(overview)"] --> SAGA["saga steps<br/>retried safely"]
OUT["transactional outbox"] --> SAGA
SAGA --> ORCH["orchestration<br/>(workflow engine)"]
SAGA --> CHOR["choreography<br/>(events on Kafka)"]
TCC["reservations: hold, authorise"] --> SAGA
TPC["2PC"] --> BLOCK["blocks if the<br/>coordinator fails"]
BLOCK --> CONS["replicate the coordinator<br/>β Consensus"]
CONS --> NEWSQL["inside Spanner,<br/>CockroachDB, Kafka txns"] Palette components involved: stream / kafka (saga events, outbox relay) Β· queue (commands to participants) Β· payment (authorise / capture / void) Β· sql (local transactions + outbox table) Β· worker (orchestrator, relay). Try the payment-system and flash-sale scenarios in the playground. Continues in: Messaging β patterns, Compute β integrations, Case study: ticketing.
Key takeaways
- A distributed transaction must be atomic across nodes; there is no single log to make it so.
- 2PC (prepare, then commit) gives ACID across participants but blocks when the coordinator fails, holds locks for two round trips, and needs every participant up at once β use it inside distributed databases, not across microservices.
- Sagas chain local transactions with compensations; they trade isolation for availability β use semantic locks, idempotent steps and a pivot step.
- TCC / reservations (hold β sell / expire; authorise β capture / void) make tentative states expire safely.
- The transactional outbox solves the dual-write problem between a database and a broker.
-
GRAY, J.; LAMPORT, L. Consensus on Transaction Commit. ACM TODS, 2006.Β β©
-
CORBETT, J. C. et al. Spanner: Google's Globally-Distributed Database. OSDI, 2012.Β β©
-
RICHARDSON, C. Pattern: Saga and Microservices Patterns. Manning, 2018.Β β©