Overview
A distributed transaction updates data in multiple databases or services atomically: all changes commit or none do. The classic protocol is two-phase commit (2PC): a coordinator asks all participants to prepare (lock and promise to commit), and if all vote yes, tells them to commit. 2PC is correct but blocking: if the coordinator fails after prepare, participants hold locks until it recovers.
In microservices, 2PC is usually avoided in favor of sagas (a sequence of local transactions with compensating actions), the transactional outbox pattern (publish events reliably with the local transaction), and idempotent consumers. Distributed SQL databases (Spanner, CockroachDB) implement distributed transactions internally with consensus to avoid 2PC's blocking problem.
The officiant (coordinator) asks each partner 'do you?' (prepare). Only if both say yes are they pronounced married (commit). If one says no, nobody is married (abort). If the officiant faints after both said yes, everyone waits.
When to use it
- Operations spanning multiple databases or services.
- Choosing between 2PC, sagas, and outbox patterns.
- Keeping a database write and an event publish consistent.
- Interview questions about order, payment, and inventory flows.
Where it shows up in interviews
Recognize it when: place order, charge payment, reserve stock.
- Design an e-commerce checkout
- Design a travel booking system
Recognize it when: update the DB and publish an event.
- Design an event-driven order service
- Design a CDC pipeline
Where it is used in real software
Java EE and many databases support XA 2PC across resources, but it is rarely used in cloud-native systems.
Uses 2PC across Paxos groups, so participants are themselves replicated and the protocol does not block on single failures.
Services write events to an outbox table in the same transaction; Debezium streams them to Kafka.
Key terms
- Two-phase commit
- Prepare phase, then commit or abort phase, driven by a coordinator.
- Saga
- Local transactions with compensating actions on failure.
- Transactional outbox
- Store events in the same DB transaction and publish them asynchronously.
- Compensation
- An action that semantically undoes a previous step (refund).
- In-doubt transaction
- A participant that prepared but does not know the outcome.
How it works, step by step
- 1Coordinator sends PREPARE
Participants lock resources and write a prepare record.
- 2Participants vote
YES (can commit) or NO.
- 3Coordinator decides
All YES: commit; any NO or timeout: abort. Decision is logged durably.
- 4Coordinator sends the decision
Participants commit or roll back and release locks.
- 5Recovery
In-doubt participants ask the coordinator for the outcome after failures.
STEP 1Phase 1: the coordinator sends PREPARE to all three participants.
2PC vs saga vs outbox
Choosing a cross-service consistency pattern
| Pattern | Consistency | Availability | Best for |
|---|---|---|---|
| 2PC | Atomic | Blocks on coordinator failure | Few databases in one trusted environment |
| Saga | Eventual, with compensations | High | Long-running business flows across services |
| Transactional outbox | DB write and event are atomic | High | Reliable event publishing |
| Distributed SQL | Atomic (internally) | High (consensus) | Single logical database at scale |
NOWPattern: 2PC | Consistency: Atomic | Availability: Blocks on coordinator failure | Best for: Few databases in one trusted environment
Microservices usually combine sagas for business flows with the outbox pattern for each local step.
Implementation
// Write business data and the event atomically in one local transactionexport async function placeOrder(order: NewOrder) { await db.transaction(async (tx) => { const { id } = await tx.one("INSERT INTO orders (user_id, total_cents, status) VALUES ($1, $2, 'pending') RETURNING id", [order.userId, order.totalCents]); await tx.none("INSERT INTO outbox (aggregate_id, type, payload) VALUES ($1, 'OrderPlaced', $2)", [id, JSON.stringify({ orderId: id, ...order })]); });} // Relay (or Debezium CDC) publishes outbox rows to Kafka, then marks them sentexport async function relayOutbox() { const rows = await db.any("SELECT * FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED"); for (const row of rows) { await kafka.send({ topic: "orders", messages: [{ key: row.aggregate_id, value: row.payload, headers: { eventId: String(row.id) } }] }); await db.none("UPDATE outbox SET sent_at = now() WHERE id = $1", [row.id]); }}Complexity and performance
prepare, vote, commit, ack per participant.
Locks held throughout.
Trade-offs
2PC gives atomic commits but blocks and reduces availability; sagas stay available but expose intermediate states.
Sagas move consistency logic (compensations, idempotency) into application code.
Variants and related techniques
Adds a pre-commit phase to reduce blocking; rarely used due to partition issues.
Reserve resources, then confirm or cancel; a business-level 2PC.
Common mistakes
- Dual writes: update the DB then publish to Kafka separately.
Fix: One can fail; use the outbox pattern or CDC.
- Using 2PC across microservices owned by different teams.
Fix: Tight coupling and blocking; prefer sagas.
- Compensations that are not idempotent.
Fix: Compensations are retried too; make them idempotent.
Interview questions
Why avoid 2PC in microservices?
It couples services' availability together, holds locks across network calls, and blocks if the coordinator fails after prepare. Sagas with compensations and the outbox pattern give eventual consistency with higher availability.
How do you reliably update a database and publish an event?
Transactional outbox: insert the event into an outbox table in the same local transaction as the business change, then a relay or CDC process publishes it to the broker and consumers deduplicate by event ID.
Practice problems
| Problem | Difficulty | What it trains |
|---|---|---|
| Implement the outbox pattern | Medium | Atomic event publish. |
| Design checkout across order, payment, inventory services | Hard | Saga vs 2PC. |