Transactions across services: 2PC, sagas, outbox, last-seat booking
We'll cover the following
Two-phase commit (2PC): flight booking example
Reserve the last seat in the Inventory DB and charge $200 in the Payment DB; both or neither. A coordinator (transaction manager) runs it.
- Prepare: the coordinator asks both to prepare.
- Inventory locks seat 14A, writes "will reserve" to its durable log, votes YES.
- Payment checks the card, holds $200, logs it, votes YES.
- Commit: if ALL voted YES the coordinator logs "commit" and tells both to commit; locks are released.
- If ANY voted NO (card declined), everyone aborts: the seat is freed, nothing charged.
The database provides the primitive (Postgres PREPARE TRANSACTION, XA in MySQL/Oracle); the coordinator is separate (a transaction manager, or built into distributed DBs like Spanner). The "reserved" state is a lock plus a write-ahead-log record held by the DB engine, with no TTL.
- Locks are held across both phases, so other users wait.
- Coordinator crashes after the YES votes: participants are stuck in limbo holding locks (the blocking problem).
- Slow: several round trips and disk writes.
- All participants must be up, so availability drops (CP behavior).
Saga instead
A saga splits one business transaction into a sequence of local transactions, one per service, each committing on its own. If a later step fails, the saga runs compensating transactions for the steps that already committed, in reverse order. There is no global lock and no coordinator holding locks, so it keeps working when a service is slow. The price: the system is only eventually consistent, and in the middle other users can see half-done state.
Sequence between an order service acting as orchestrator, the inventory service and the payment service. The orchestrator asks inventory to reserve seat 14A and inventory commits locally and replies OK. The orchestrator asks payment to charge 200 dollars and payment replies declined. The orchestrator then runs the compensation: it asks inventory to release seat 14A, inventory frees the seat and replies OK, and the orchestrator marks the order failed and tells the user.
- Forward steps (each is its own local commit)
- Order service → Inventory
ReserveSeat 14A - Inventory14A is
HELD - Inventory → Order serviceOK
- Order service → Payment
ChargeCard $200 - Payment → Order service
DECLINED - Compensate what already committed, in reverse order
- Order service → Inventory
ReleaseSeat 14A(the compensating action) - Inventory14A is
AVAILABLE - Inventory → Order serviceOK
- Order serviceOrder marked
FAILED, user told to try another card
- Local transaction
- Each service commits in its own database. Nothing is held open across services.
- Compensating action
- A new, opposite action (release the seat, refund the card). It is business undo, not a database rollback.
- Orchestrator
- Remembers which step the saga is on and decides what to do next, including the compensations.
Compensations are business undo, not rollback
- A committed local transaction cannot be rolled back, so you issue a new opposite action: release the seat, refund the payment, cancel the shipment.
- Some steps cannot be undone (an email already sent). Put those after the pivot, the point of no return (e.g. after the payment is captured), and put the steps most likely to fail first.
- Steps and compensations must be idempotent and retried until they succeed, because messages are delivered at least once (see Message queues).
- A compensation can fail too: retry with backoff, then send it to a dead-letter queue and alert a human.
Two ways to run a saga
| Style | How | Good | Watch out |
|---|---|---|---|
| Orchestration | A central orchestrator (the order service, or a workflow engine such as Temporal or AWS Step Functions) tells each service what to do and tracks the saga state. | The whole flow is in one place: easy to read, monitor and add timeouts to. | One more piece of logic to own; services depend on it. |
| Choreography | No central brain. Each service reacts to events and emits its own: SeatReserved makes Payment charge; PaymentFailed makes Inventory release the seat. | Loose coupling, no extra component. | The flow is scattered across services, hard to see and debug; cyclic dependencies creep in. |
Rule of thumb: choreography for two or three simple steps; orchestration once there are more steps, branches or timeouts.
Pitfalls of sagas
- No isolation. Sagas give up the I in ACID: another user can see the seat
HELDor a charge that will later be refunded. Make the in-between state explicit with a semantic lock (aHELD/PENDINGstatus the application understands, as in the seat hold below). - The saga state must be durable. Persist which step it reached so a crash resumes instead of forgetting. "Update the state and send the next command" must be atomic: that is the outbox pattern, at the end of this section.
- Timeouts. If a step never answers, treat it as failed and compensate. A late reply can still arrive, which is another reason steps must be idempotent.
Check yourselfPayment succeeded but the shipping service is down for an hour. Do you refund right away?
No. Shipping is a retryable step: keep retrying with backoff while the order shows PAID, shipping PENDING. Run the compensation (refund, release stock) only if it fails permanently or a business deadline passes, because a refund costs a customer while a retry is free.
Last-seat booking: only one user may win
Booking is CP: never sell the same seat twice; better to say "try again". Create the hold atomically so the database serializes the two clicks:
UPDATE seats
SET status='HELD', held_by='user_X', expires_at=now()+interval '10 minutes'
WHERE seat_id='14A' AND status='AVAILABLE';
-- first click changes 1 row; second finds status != AVAILABLE, changes 0 rows -> "seat taken"- Other ways: optimistic locking with a
versioncolumn, a unique constraint on (flight, seat), or RedisSET NXwith a TTL as a fast lock (the DB is still the final authority). - The application-level hold (
HELD+expires_at) is a soft lock you build, better than holding real DB locks for minutes while a user pays. It is what sagas use.
When the user abandons the hold
- Lazy expiry: treat an expired hold as available inside the booking query:
WHERE seat_id=? AND (status='AVAILABLE' OR (status='HELD' AND expires_at < now())). No background job needed; gives correctness. - Active expiry: a sweeper job every minute, a TTL feature (Redis key TTL, DynamoDB TTL), or a delayed message (SQS delay, delayed queue) at hold time. Gives tidiness and accurate displayed availability.
Use both. And when the payment confirmation finally arrives, verify the hold is still yours and unexpired before confirming; if it was lost (paid at minute 11, someone else took it), refund.
The dual-write problem and the outbox pattern
A service often has to update its database and publish an event (booking confirmed, so tell email and loyalty). Those are two systems with no shared transaction. Commit then crash before publishing: the event is lost for good. Publish first then the commit fails: an event announces something that never happened. Using 2PC between a database and a broker is not realistic.
Outbox pattern: write the event into an outbox table in the same local transaction as the business change. A separate relay then publishes the outbox rows to the broker.
BEGIN;
UPDATE seats SET status='BOOKED' WHERE seat_id='14A' AND held_by='user_X';
INSERT INTO outbox (event_id, topic, payload)
VALUES (gen_random_uuid(), 'booking.confirmed', '{"seat":"14A","user":"user_X"}');
COMMIT;
-- relay (separate process): read unsent outbox rows, publish to Kafka, mark them sent| Relay | How | Trade-off |
|---|---|---|
| Polling | A job runs SELECT ... FROM outbox WHERE sent = false ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED, publishes, marks sent. | Simplest, no extra infrastructure. Adds database load and some delay. |
| CDC (change data capture) | A tool such as Debezium reads the database log (WAL / binlog) and publishes new outbox rows. | Low latency, no polling load, keeps commit order. Needs connector infrastructure and log access. |
- The relay can crash after publishing but before marking the row sent, so delivery is at least once. Consumers must be idempotent: dedupe on
event_id(an "inbox" table of processed IDs). - The same trick makes sagas safe: each step commits its state change and its next command message in one transaction.
Check yourselfA service commits the booking, then crashes before publishing the event. What happens without an outbox, and with one?
Without it the event is lost and downstream services (email, loyalty) never hear about the booking. With an outbox the event row was committed with the booking, so the relay publishes it after the restart. It might be published twice, so consumers dedupe by event_id.
Summary: say it in the interview
- "2PC gives atomicity across services but blocks and hurts availability, so I would use a saga with compensating actions unless I need strict atomicity."
- "I never write the database and publish to Kafka as two separate steps. The event goes into an outbox row in the same transaction, a relay or CDC publishes it, and consumers are idempotent."