Notes
The problem
Inside one database, a transaction is all or nothing. But here the stock was reserved in one database and the payment failed in another, and no single commit covers both. We're left half done: stock held for an order that will never be paid. We need another way to keep services consistent.
- one DB: BEGIN … COMMIT
- all or nothing (episode 18)
- three services, three DBs:
- stock reserved ✓
- payment failed ✗
- order? half created
- no single BEGIN/COMMIT
- spans them
Two-phase commit
The classic answer is two-phase commit. Phase one: a coordinator asks every participant to prepare: do the work, lock the rows, make it durable, and vote. Phase two: if everyone voted yes, the coordinator says commit; if anyone said no, everyone aborts. It's truly atomic across databases.
- phase 1, prepare: each DB does
- the work, locks rows, writes it
- durably, votes yes or no
- phase 2: all yes → commit
- any no → everyone aborts
- atomic across databases
2PC problems
The problem is blocking. If the coordinator crashes after participants have prepared, they sit holding locks, unable to commit or abort on their own. It's also slow, with locks held across several round trips, and it needs every participant available. So it's used inside some databases, but rarely across microservices.
- blocking: coordinator crashes
- between phases → participants
- hold locks, cannot decide
- slow: several round trips,
- locks held the whole time
- needs every participant up
- used inside some databases,
- rarely across microservices
Sagas
The common answer is a saga. Break the work into local transactions, each committing in its own service, and give each a compensating action: cancel the order, release the stock, refund the card. If a step fails, run the compensations for the steps already done, in reverse. Here, payment failed, so release the stock and cancel the order.
- step compensation
- create order cancel order
- reserve stock release stock
- charge card refund
- ship (none: last)
- each step commits locally
- failure → run compensations
- backwards
Saga details
Sagas can be orchestrated, with a coordinator that stores the saga's state and runs each step, or choreographed, with services reacting to each other's events. Remember they aren't isolated: others can see the order as 'pending' in between. And compensations must be idempotent and retryable, because they'll be retried.
- orchestrated: a coordinator
- service runs the steps and
- stores saga state
- choreographed: each service
- reacts to the previous event
- not isolated: others can see
- "pending" in between
- compensations must be
- idempotent and retryable
Dual writes
There's a sneakier bug inside every saga step: the dual write. A service saves the order to its database, then publishes an event. If it crashes between the two, the order exists but nobody hears about it. Swap the order and you can announce an order that was never saved.
db.save(order) ✓ kafka.publish(event) ✗ crash → order exists, nobody hears about it or the reverse: event sent, DB write rolled back
Outbox
The fix is the transactional outbox. In the same local transaction that saves the order, insert the event into an outbox table. Both commit or neither does. A separate relay reads the outbox, often via change data capture, and publishes to Kafka. Delivery is at least once, so consumers stay idempotent.
BEGIN
INSERT INTO orders …
INSERT INTO outbox
(event = OrderCreated)
COMMIT ← one local tx
relay reads outbox → Kafka
(polling or change data capture)
at-least-once → consumers
must be idempotent (ep. 29) Say it like this
In an interview, when asked what happens if payment fails: use a saga, orchestrated by an order workflow, where each step is a local transaction with a compensation like release stock, refund or cancel. Publish events with the outbox pattern, keep every step idempotent, and show users a pending status. Avoid two-phase commit across services.
- "What if payment fails?"
- saga, orchestrated by an order
- workflow; each step local
- compensations: release, refund,
- cancel; idempotent + retried
- outbox for reliable events
- show "pending" status to users
- avoid 2PC across services