Distributed Transactions
Patterns for maintaining consistency across services and datastores without a global ACID transaction.
1. The Dual-Write Problem
Writing to two systems (DB + message queue) in sequence — no atomic boundary spans both.
Failure scenarios:
| Step | Failure | Result |
|---|---|---|
| DB write succeeds, queue publish fails | Network timeout to broker | DB updated, event never sent — consumers miss the update |
| DB write fails, queue publish succeeds | DB constraint violation | Event published for a transaction that never happened |
| Process crashes between the two writes | OOM, pod eviction | One side is written, other is not — no way to know which |
The root cause: you cannot COMMIT a database transaction and publish a message atomically with two different systems. One of them will always go first.
2. Two-Phase Commit (2PC)
A distributed protocol where a coordinator drives participants through two phases to achieve atomic commit across multiple nodes.
Phases
Phase 1 — Prepare:
Coordinator sends PREPARE to all participants. Each participant writes to its WAL (making it ready to commit), then responds YES or NO. No data is visible yet.
Phase 2 — Commit:
If all responded YES, coordinator writes COMMIT to its own log, then sends COMMIT to all participants. If any responded NO, coordinator sends ROLLBACK.
Why it's blocking and fragile
- If the coordinator crashes after Phase 1 but before Phase 2, all participants are stuck in the prepared state — they have locks held and cannot proceed or roll back without the coordinator's decision.
- Recovery requires reading the coordinator's WAL or waiting for it to restart — this can block for minutes in production.
- Any participant failure during Phase 2 requires the coordinator to retry indefinitely (or a human to intervene).
Coordinator crash window
Coordinator: PREPARE → [crash here] → COMMIT
Participants: locked, waiting, cannot unilaterally decide
This is the uncertainty window — participants hold locks on rows they've prepared but cannot release them because they don't know if the coordinator committed or rolled back.
3. 2PC Sequence Diagrams
Happy Path
sequenceDiagram
participant C as Coordinator
participant P1 as Participant1
participant P2 as Participant2
C->>P1: PREPARE
C->>P2: PREPARE
P1-->>C: YES
P2-->>C: YES
Note over C: Write COMMIT to log
C->>P1: COMMIT
C->>P2: COMMIT
P1-->>C: ACK
P2-->>C: ACK
Coordinator Crashes After Prepare
sequenceDiagram
participant C as Coordinator
participant P1 as Participant1
participant P2 as Participant2
C->>P1: PREPARE
C->>P2: PREPARE
P1-->>C: YES
P2-->>C: YES
Note over C: CRASH before writing COMMIT
Note over P1: Holding locks, uncertain
Note over P2: Holding locks, uncertain
Note over P1,P2: Blocked until coordinator recovers
4. Three-Phase Commit (3PC)
Adds a pre-commit phase between Prepare and Commit to reduce the uncertainty window.
Phases: Prepare → Pre-Commit → Commit
What it adds: After receiving all YES votes, coordinator sends PRE-COMMIT. Participants acknowledge. Only then does the coordinator send COMMIT. If the coordinator crashes after PRE-COMMIT, participants can infer the coordinator intended to commit and proceed unilaterally after a timeout.
Still has issues:
- Network partitions can cause split-brain: some participants receive
PRE-COMMIT, others don't. After a coordinator crash, different participants may make different decisions. - Adds a full RTT of latency vs 2PC.
- Rarely used in practice because of the network partition problem — Saga or Outbox patterns are preferred.
5. Saga Pattern
A saga is a sequence of local transactions. Each step updates one service's database. On failure, compensating transactions undo the preceding steps.
Key property: No global lock. Each local transaction commits immediately and is visible. Compensation is semantic (business-level undo), not a database rollback.
Choreography
Services react to events without a central coordinator. Each service listens for events, does its local transaction, and emits the next event.
Pros:
- No single point of failure
- Services are fully decoupled
- Simple to add new steps by subscribing to events
Cons:
- Hard to track overall saga state (distributed observability problem)
- Cyclic dependencies between services are easy to create accidentally
- Testing the full flow requires all services running
Orchestration
A Saga Orchestrator (a service or workflow engine) explicitly calls each participant and drives the flow. It knows the full saga state.
Pros:
- Central place to observe, debug, and retry saga state
- Easier to reason about compensations
- Can use durable execution (Temporal, AWS Step Functions)
Cons:
- Orchestrator is a single point of coordination (not a SPOF if made durable, but a coupling point)
- Services must expose APIs the orchestrator calls — more coupling than events
6. Saga Choreography Diagram
Order → Inventory → Payment → Shipping, with compensation on failure.
sequenceDiagram
participant O as OrderService
participant I as InventoryService
participant P as PaymentService
participant S as ShippingService
participant Q as EventBus
O->>Q: OrderCreated
Q->>I: OrderCreated
I->>Q: InventoryReserved
Q->>P: InventoryReserved
P->>Q: PaymentCharged
Q->>S: PaymentCharged
S->>Q: ShipmentCreated
Note over P,S: Payment fails scenario
P->>Q: PaymentFailed
Q->>I: PaymentFailed
I->>Q: InventoryReleased
Q->>O: InventoryReleased
O->>Q: OrderCancelled
Compensating transactions:
| Step | Forward | Compensation |
|---|---|---|
| Inventory | Reserve items | Release reservation |
| Payment | Charge card | Issue refund |
| Shipping | Create shipment | Cancel shipment |
7. Outbox Pattern
Write to an outbox table in the same database transaction as your business data. A separate relay process reads the outbox and publishes to the message broker.
Why it works: The outbox write and the business write share the same ACID transaction. Either both commit or both roll back. The relay publishes only after the DB transaction is committed.
-- Outbox table
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
published_at TIMESTAMPTZ
);
-- Business transaction (atomic)
BEGIN;
INSERT INTO orders (id, user_id, status) VALUES ($1, $2, 'pending');
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ($1, 'OrderCreated', $3);
COMMIT;
The relay queries unpublished rows, publishes to the broker, then marks them published:
-- Relay: poll and publish
SELECT * FROM outbox WHERE published_at IS NULL ORDER BY created_at LIMIT 100;
-- ... publish each to broker ...
UPDATE outbox SET published_at = now() WHERE id = $1;
At-least-once delivery: If the relay crashes after publishing but before marking the row, it will republish on restart. Consumers must be idempotent.
8. Outbox Sequence Diagram
sequenceDiagram
participant App as Application
participant DB as PostgreSQL
participant Relay as OutboxRelay
participant Broker as MessageBroker
App->>DB: BEGIN
App->>DB: INSERT INTO orders
App->>DB: INSERT INTO outbox
App->>DB: COMMIT
DB-->>App: OK
loop Poll every N ms
Relay->>DB: SELECT unpublished outbox rows
DB-->>Relay: rows
Relay->>Broker: publish event
Broker-->>Relay: ACK
Relay->>DB: UPDATE outbox SET published_at
end
9. Change Data Capture (CDC) with Debezium
CDC reads the database replication log (Postgres WAL, MySQL binlog) to capture every committed change as a stream of events. No polling, no outbox table needed in the application code.
How Debezium works with Postgres:
- Postgres is configured with
wal_level = logical. - Debezium connects as a replication slot reader — same protocol as a streaming replica.
- Every committed
INSERT/UPDATE/DELETEon tracked tables emits a change event to Kafka. - The event contains
beforeandafterrow images, LSN (log sequence number), and transaction metadata.
Why it's reliable:
- Events come directly from the WAL — they are committed facts, not speculative writes.
- The replication slot tracks the LSN so Debezium can resume exactly where it left off after a restart.
- No dual-write: the application writes to DB normally; CDC is a side effect of the WAL.
Tradeoffs:
- Requires
wal_level = logical(minor storage overhead for WAL retention). - Schema changes (DDL) must be handled carefully — Debezium tracks schema history in a Kafka topic.
- Replication slots accumulate WAL if the consumer falls behind — can fill disk.
10. Idempotency Across Services
An idempotent operation produces the same result if called multiple times. Critical for at-least-once delivery systems.
Idempotency key flow:
- Client generates a unique key (UUID or hash of request content) and sends it in the request header:
Idempotency-Key: <uuid>. - Service checks a deduplication table before processing.
- If key exists: return the stored response immediately, skip processing.
- If key is new: insert the key, process, store the response.
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
response JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ
);
-- Before processing
SELECT response FROM idempotency_keys WHERE key = $1 AND expires_at > now();
-- After successful processing (in same transaction as business logic)
INSERT INTO idempotency_keys (key, response, expires_at)
VALUES ($1, $2, now() + interval '24 hours')
ON CONFLICT (key) DO NOTHING;
TTL: Keys can be expired after a safe window (24h–7d) once the upstream caller no longer retries.
11. Distributed Locking
Use when multiple instances must not execute a critical section simultaneously (e.g., scheduled job, inventory deduction).
Redis — SET NX PX
# Acquire: SET key value NX PX <ttl_ms>
SET lock:order:123 <owner_token> NX PX 5000
# Release: only if we own the lock (Lua for atomicity)
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
NX: only set if not existsPX 5000: auto-expire after 5 seconds (prevents deadlock on crash)- Always use a unique owner token and check it before releasing — prevents releasing someone else's lock
Redlock (multi-node Redis)
Acquire the lock on N/2+1 independent Redis nodes within a time window. If quorum is reached, the lock is held. Protects against single Redis node failure.
Controversy: Redlock is debated (Martin Kleppmann vs Antirez). In practice, for most use cases, a single Redis instance with NX PX is sufficient if you can tolerate the Redis instance being a SPOF.
ZooKeeper / etcd
- Use ephemeral nodes (ZooKeeper) or leases (etcd) — lock is automatically released if the holder crashes or disconnects.
- Stronger consistency guarantees than Redis (linearizable by default in etcd).
- Higher latency than Redis (~1–5ms vs ~0.1ms).
Optimistic vs Pessimistic Locking
| Optimistic | Pessimistic (Distributed Lock) | |
|---|---|---|
| Mechanism | Version check at write time | Lock before read |
| Contention | Low — no blocking | High — serialize all access |
| Use when | Conflicts are rare | Conflicts are frequent, or external system coordination needed |
| On conflict | Retry the operation | Wait or fail fast |
12. Optimistic Concurrency Control
No locks. Each row carries a version field. The writer checks the version hasn't changed before committing.
-- Schema
ALTER TABLE orders ADD COLUMN version INT NOT NULL DEFAULT 0;
-- Read
SELECT id, status, version FROM orders WHERE id = $1;
-- Update (check-and-set)
UPDATE orders
SET status = 'paid', version = version + 1
WHERE id = $1 AND version = $2;
-- If 0 rows updated: someone else modified it — retry
HTTP ETags:
GET /orders/123
→ ETag: "v42"
PUT /orders/123
If-Match: "v42"
→ 200 OK if version matches
→ 412 Precondition Failed if it was modified
ETags are the HTTP-native expression of optimistic concurrency — the ETag is the version, If-Match is the check-and-set.
13. TCC (Try-Confirm-Cancel)
A reservation pattern that avoids holding database locks across service boundaries.
Three phases per participant:
- Try: Reserve resources tentatively (don't commit them). E.g., mark inventory as "reserved" but not "deducted".
- Confirm: If all Tries succeeded, confirm all reservations (make them permanent).
- Cancel: If any Try failed, cancel all reservations (release the tentative holds).
Example — inventory reservation:
Try: UPDATE inventory SET reserved = reserved + 5 WHERE sku = 'X' AND available >= 5
Confirm: UPDATE inventory SET available = available - 5, reserved = reserved - 5 WHERE sku = 'X'
Cancel: UPDATE inventory SET reserved = reserved - 5 WHERE sku = 'X'
How it avoids blocking:
- Resources are "reserved" not "locked". Other transactions can still read and reserve (if enough available).
- The Confirm/Cancel phase is fast — no coordination needed after Try.
- Resources don't stay in limbo: a timeout on unconfirmed reservations triggers automatic Cancel.
Tradeoffs:
- All participants must implement three separate endpoints/operations.
- Requires a timeout/cleanup mechanism for stuck Tries.
- The orchestrator must track which participants were successfully Tried.
14. Decision Table
| Pattern | Consistency | Availability | Latency | Complexity | Use when |
|---|---|---|---|---|---|
| 2PC | Strong (ACID) | Low (blocking) | High | Medium | Same DB engine across nodes, short transactions, can tolerate blocking (rare in microservices) |
| Saga (Choreography) | Eventual | High | Low | High (observability) | Microservices, no central dependency, teams own their events |
| Saga (Orchestration) | Eventual | High | Low | Medium | Complex flows, need visibility, durable execution available |
| Outbox | Eventual (reliable) | High | Low | Low | Solving dual-write; use alongside Saga or CDC |
| TCC | Near-strong | High | Medium | High | Inventory/seat reservation, need resource holds without DB locks |
| CDC (Debezium) | Eventual | High | Low | Low (infra) | Event sourcing from existing DB, no app changes, Kafka-based pipelines |
| Optimistic Lock | Strong (per row) | High | Low | Low | Low contention, single-service writes, HTTP APIs |
| Distributed Lock | Strong (critical section) | Medium | Low-Medium | Low | Scheduled jobs, external system coordination, high-contention resources |
15. Real-World Examples
E-Commerce Order (Saga Orchestration)
Orchestrator drives:
1. OrderService.createOrder() → local commit
2. InventoryService.reserve() → local commit
3. PaymentService.charge() → local commit
4. ShippingService.createShipment() → local commit
On PaymentService failure:
← InventoryService.release() ← compensate step 2
← OrderService.cancel() ← compensate step 1
State machine stored in the orchestrator (e.g., DynamoDB or Postgres). Each step is idempotent. Retries are safe.
Payment Processing (2PC within DB, Saga across services)
Within a single Postgres cluster (e.g., debit account A, credit account B in the same DB):
- Use a regular DB transaction — this is exactly the use case 2PC was designed for at the DB level.
- The DB engine handles 2PC internally; you write
BEGIN ... COMMIT.
Across services (payment gateway, ledger service, notification service):
- Use Saga Orchestration.
- Payment gateway call is the critical step — design it as idempotent with an idempotency key.
- On payment failure, compensate the ledger reservation.
Inventory Reservation for Flash Sale (TCC)
Try: Reserve N units (add to reserved_count) — fast, low contention
Return reservation_id
Confirm: Deduct from available_count, clear reservation
Triggered when order is confirmed
Cancel: Release reservation_count
Triggered on: payment failure, TTL expiry, user abandonment
TTL on reservations (e.g., 10 minutes) prevents ghost reservations from blocking stock indefinitely. A background job sweeps expired reservations and triggers Cancel.