Courses › System Design › Core Concepts

Distributed Transactions

The dual-write problem, two-phase commit, the transactional outbox and change data capture.

Inside one database, a transaction makes several writes all-or-nothing. As soon as a business operation spans two databases, or a database and a message queue, you lose that guarantee — and "we updated the order but the event was never published" becomes a real bug. This page covers the tools for keeping data consistent across those boundaries. For long-running, multi-service workflows, see Coordinating Workflows with Sagas.

The dual-write problem

BEGIN; UPDATE orders SET status = 'paid' WHERE id = 9001; COMMIT;
kafka.publish("OrderPaid", 9001)     ← crash here and the event is lost forever

Publishing before the commit has the opposite bug: the event goes out but the transaction rolls back. Any time you write to two systems without coordination, one can succeed while the other fails.

Two-phase commit (2PC)

A coordinator makes several participants commit atomically:

  • Prepare — the coordinator asks each participant to get ready. Each one writes the changes durably, holds its locks, and votes yes or no.
  • Commit / abort — if everyone voted yes, the coordinator records the decision and tells all of them to commit; otherwise, to abort.

Distributed databases make 2PC practical by replicating the coordinator's state with consensus, so a coordinator crash doesn't block forever. Across independent microservices, 2PC is usually avoided.

The transactional outbox

The standard fix for "update the database and publish an event":

[Service] -> (DB transaction: orders + outbox rows) -> [Relay / CDC] -> {Kafka} -> [Consumers]
  • In the same local transaction, update the business table and insert a row into an outbox table describing the event.
  • A relay reads new outbox rows (by polling, or via change data capture such as Debezium reading the write-ahead log) and publishes them to the broker.
  • Mark rows as sent (or rely on CDC offsets).

The event is published if and only if the transaction committed. The relay may publish an event twice after a crash, so consumers must be idempotent — see Safe Retries & Idempotency.

[!TIP] The inbox is the outbox's partner On the consuming side, record each processed event ID in an inbox table in the same transaction as the consumer's own writes. A redelivered event is detected and skipped — effectively exactly-once processing on top of at-least-once delivery.

Change data capture (CDC)

CDC streams every committed change from a database's log to Kafka. Besides powering the outbox, it keeps caches, search indexes and warehouses in sync without dual writes from application code: the database is the single source of truth, and everything else derives from its log.

Choosing an approach

[!INTERVIEW] Order placed, inventory reserved "The order service writes the order and an OrderPlaced outbox row in one transaction. Debezium streams the outbox to Kafka. The inventory service consumes it, deduplicates by event ID in its inbox table, and reserves stock. If stock is short, it publishes ReservationFailed and the order service cancels the order — a choreographed saga with no dual writes anywhere."