System Design
Advanced 14 min read Data at scale

Distributed transactions: 2PC and sagas

In one line

When one business action spans several databases or services, you either lock everything and commit together (two-phase commit) or run a sequence of local transactions with compensating undo steps (a saga), trading strict atomicity for availability and loose coupling.

What it is

A local database transaction is atomic: all of its writes commit or none do. Once an action spans two systems, for example deducting stock in the inventory service and charging money in the payment service, no single database can guarantee that. A distributed transaction is any protocol that tries to keep such a multi-system action consistent.

Two-phase commit (2PC) is the classic protocol. A coordinator asks every participant to 'prepare' (do the work, hold locks, promise it can commit), and only if all say yes does it tell them to 'commit'. It gives atomicity, but participants hold locks while waiting, and if the coordinator dies after prepare, they are blocked until it recovers.

A saga splits the action into a series of local transactions, each in one service, and defines a compensating transaction for each step (refund the payment, release the stock). If step 4 fails, steps 3, 2 and 1 are undone in reverse order. Sagas never hold locks across services, so they scale and survive partial outages, but the system is temporarily inconsistent and other users can see intermediate states.

Sagas are either orchestrated (a central workflow tells each service what to do, often built on a durable workflow engine such as Temporal) or choreographed (each service reacts to events from the previous one over Kafka). Both need idempotent steps and a reliable way to publish events, usually the transactional outbox.

Why interviewers ask

Checkout, booking, money transfer and order fulfilment all cross service boundaries, so this comes up in many designs. Interviewers want to hear why 2PC is rarely used across microservices, how a saga handles failure in the middle, what compensation really means (a refund, not a rollback), and how you avoid the dual-write problem between a database and a message broker.

How it works

  1. 1First, try to avoid it: if the data that must change together can live in one database, use one local ACID transaction. Many 'distributed transaction' problems are really service boundaries drawn in the wrong place.
  2. 22PC phase 1 (prepare): the coordinator sends PREPARE to all participants. Each writes the changes to its log, holds row locks and replies YES or NO.
  3. 32PC phase 2 (commit): if every participant said YES, the coordinator durably logs COMMIT and sends it to all; otherwise it sends ABORT. Participants apply and release locks.
  4. 42PC failure: if the coordinator crashes after participants voted YES, they cannot decide alone and keep holding locks until it returns. This blocking, plus locks held over network round trips, is why 2PC is used inside a database system (Spanner, CockroachDB, XA between a database and a broker) but rarely across independently owned microservices.
  5. 5Saga design: list the steps in order, write a compensating action for each, and put steps that cannot be undone (sending an email, the final irreversible capture) last. Steps after which the saga must go forward are called pivot and retriable steps.
  6. 6Orchestration: a saga orchestrator stores the saga's state durably, calls step 1, records the result, calls step 2, and on a permanent failure runs compensations in reverse. Temporal, AWS Step Functions or a state table in Postgres can hold this state.
  7. 7Choreography: OrderCreated triggers inventory to reserve stock and emit StockReserved, which triggers payment, and so on. Fewer central components, but the flow is spread across services and harder to trace.
  8. 8Reliability details: every step takes an idempotency key so retries do not double-charge; events are published via a transactional outbox so a database write and its event cannot diverge; and semantic locks (a 'PENDING' status) stop other requests from acting on half-finished data.

Worked example · Booking saga: flight + hotel + payment for a travel app

  1. User books a Goa trip: flight Rs 6,000, hotel Rs 9,000. The orchestrator creates saga 7781 with status STARTED and idempotency key bk-7781.
  2. Step 1: flight service holds seat 14C for 15 minutes (local transaction, status HELD). Compensation: release the hold.
  3. Step 2: hotel service holds a room for 2 nights (status HELD). Compensation: release the room.
  4. Step 3: payment service authorises Rs 15,000 on the card. Compensation: void the authorisation (or refund if already captured).
  5. Step 4: flight confirmation with the airline API fails permanently because the fare class sold out.
  6. Compensate in reverse: void the Rs 15,000 authorisation, release the hotel room, release the flight hold. Each call uses key bk-7781-comp-N, so retries after a timeout are safe.
  7. Saga status becomes COMPENSATED; the user sees 'Flight no longer available, you have not been charged'. Total time about 4 seconds, no locks held across services at any point.
  8. Note: between step 3 and the compensation, the user's bank briefly showed a Rs 15,000 hold. That visible intermediate state is the price of a saga, and the product must explain it.

Picture it

2PC is like a wedding where the officiant asks both people 'do you?' and nothing happens until both say yes; if the officiant faints after the yeses, everyone waits in place. A saga is like planning a holiday by booking the flight, then the hotel, then the taxi; if the taxi cannot be booked, you cancel the hotel and the flight, maybe paying a small cancellation fee, which is why compensation is not the same as never having booked.

Reference code

# Orchestrated saga, Temporal-style (simplified)
@workflow.defn
class CheckoutSaga:
    @workflow.run
    async def run(self, order: Order) -> str:
        undo = []
        try:
            await activity(reserve_stock, order, key=f"{order.id}-stock")
            undo.append(release_stock)
            await activity(debit_wallet, order, key=f"{order.id}-debit")
            undo.append(refund_wallet)
            await activity(confirm_order, order, key=f"{order.id}-confirm")
            return "CONFIRMED"
        except PermanentFailure:
            for step in reversed(undo):        # compensate in reverse order
                await activity(step, order, key=f"{order.id}-{step.__name__}")
            await activity(cancel_order, order, key=f"{order.id}-cancel")
            return "CANCELLED"

Key patterns

Orchestrated saga

When: Flows with more than three steps, branching or long waits

How: One orchestrator owns the state machine and compensations; use a durable workflow engine so state survives crashes.

Choreographed saga

When: Short flows of two or three services with simple linear steps

How: Each service consumes the previous event and emits the next; add correlation IDs and tracing so the flow can be followed.

Transactional outbox

When: A service must update its database and publish an event

How: Write the business row and an outbox row in one local transaction; a relay or CDC publishes outbox rows to the broker.

Reservation with expiry

When: Scarce resources such as seats, rooms or stock

How: Hold the resource with a TTL in step 1 and confirm later; if the saga dies, the hold expires on its own.

Real-world use

Google Spanner and CockroachDB

Use 2PC between shards, but each participant is itself a Paxos or Raft group, so a single machine failure does not block the protocol. This is 2PC done inside one database system, with tight latency control.

Uber Cadence and Temporal

Uber built Cadence to run long-running, durable workflows, and its creators later founded Temporal. Both are widely used to orchestrate sagas: the workflow code calls activities and runs compensations, and the engine persists every step so a crash resumes where it stopped.

Payment gateways (Razorpay, Stripe)

Separate authorisation from capture, and support voids and refunds. These map directly onto saga steps and compensations for merchants, and both accept idempotency keys on API calls so retries are safe.

AWS Step Functions

AWS documents the saga pattern on Step Functions, with Catch blocks that route a failed state to compensating Lambda functions.

XA transactions

The XA standard lets a Java application commit across, for example, an Oracle database and an IBM MQ queue with 2PC. It works within one organisation's infrastructure but is slow and operationally painful, so modern systems prefer the outbox pattern.

In a project

A quick-commerce app in India has separate order, inventory, wallet and delivery services. During a festive sale, about 0.5% of orders end with money deducted from the wallet but no order created, or stock reserved for orders that were never paid.

  1. Trace the bug: the order service calls wallet, then inventory, then writes the order, with plain HTTP calls and no recovery. A timeout after the wallet debit leaves the debit in place with no order.
  2. Introduce an orchestrated saga on Temporal: create order (PENDING) -> reserve stock -> debit wallet -> confirm order -> assign delivery.
  3. Define compensations: release stock, credit wallet back, mark order CANCELLED. Delivery assignment is last because a dispatched rider cannot be undone.
  4. Make every step idempotent: the wallet keeps a unique index on (saga_id, step), so a retried debit returns the earlier result instead of debiting twice.
  5. Publish events (OrderConfirmed, OrderCancelled) through a transactional outbox so notifications and analytics never miss or invent an order.
  6. Add a reconciliation job every 15 minutes that compares wallet debits with confirmed orders and flags any mismatch older than 30 minutes.

Impact: Orphan debits fall from about 0.5% of orders to zero detected by reconciliation in the next sale, refunds that used to need support tickets happen automatically within seconds, and on-call can see each order's saga history step by step in the Temporal UI.

Trade-offs

Use it when

  • Single local transaction: whenever the data can live in one database; always the first choice
  • 2PC: inside one database system across shards, or a few tightly controlled resources with short transactions
  • Saga: multi-service business flows such as checkout, booking, onboarding and fulfilment
  • Saga: long-running steps (seconds to days) or external APIs that cannot join a lock-based protocol

Avoid it when

  • 2PC across microservices owned by different teams or across third-party APIs
  • 2PC on hot paths where holding locks across network calls would cut throughput
  • Sagas where intermediate states must never be visible, such as some regulated ledger postings; keep those in one database
  • Choreography for long flows with many branches; it becomes impossible to see who does what

Common mistakes

  • Proposing 2PC between microservices without mentioning blocking on coordinator failure and locks held across network calls.
  • Calling compensation a 'rollback'. It is a new business action (refund, cancel), and it may itself fail and need retries.
  • Making steps non-idempotent, so a retry after a timeout charges the customer twice.
  • Writing to the database and then publishing to Kafka as two separate operations (the dual-write problem) instead of using an outbox.
  • Putting an irreversible step, such as dispatching a rider or sending money out, in the middle of the saga.
  • Ignoring isolation: without semantic locks or PENDING states, other requests act on data that may still be compensated.

Interview questions

Quick revision

  • First choice: keep data that must change together in one database.
  • 2PC: prepare (vote, hold locks) then commit; atomic but blocking if the coordinator dies.
  • 2PC lives inside databases (Spanner, CockroachDB), not between team-owned services.
  • Saga: chain of local transactions + compensations in reverse on failure.
  • Compensation = new business action (refund), not a rollback.
  • Orchestration (Temporal, Step Functions) for long or branching flows; choreography for short ones.
  • Every step idempotent with an idempotency key.
  • Outbox fixes the DB-write + event-publish dual write.
  • Irreversible steps go last; reservations carry a TTL.

Check yourself

1. What is the main weakness of two-phase commit?

2. In a saga, step 3 of 4 fails permanently. What happens?

3. Why should every saga step accept an idempotency key?

4. Where should an irreversible step, such as dispatching a delivery rider, go in a saga?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free