Transactions and Isolation

ACID in plain words, the race conditions isolation levels stop, locking choices, and how sagas and the outbox pattern replace distributed transactions.

18 minLesson 2 of 8
On this page
  1. The idea
  2. Three races to know
  3. Isolation levels
  4. Across services: 2PC and sagas
  5. Numbers worth remembering
  6. In the interview
  7. Common mistakes
  8. What to practise next

A concert has one ticket left. Two fans press "buy" in the same 10 ms. Both requests read remaining = 1, both charge a card, both write remaining = 0. Two people paid for one seat, and the database never reported an error.

Each request was correct on its own; the bug is in how they overlapped. Isolation levels decide which overlaps the database allows.

The idea

A transaction groups several reads and writes so the database treats them as one unit. ACID names its four promises:

  • Atomic: all of the writes happen, or none do. A crash halfway leaves nothing behind.
  • Consistent: your rules hold before and after. Mostly your job; constraints such as UNIQUE and CHECK (stock >= 0) help.
  • Isolated: concurrent transactions do not see each other's half-done work. How strictly is set by the isolation level; the default is rarely the strictest.
  • Durable: once committed, the data survives a crash, because it is in a log on disk (and often on replicas).

Stricter isolation means more waiting or more retries. Choose the weakest level that stops the races your data can have.

Three races to know

Two users, T1 and T2, and time going down the page.

Dirty read: seeing work that is later undone

T1 (payment job)T2 (statement)
balance 100 → 20, not committed
reads balance: 20, emails "low balance"
fails, rolls back to 100

T2 acted on a value that never existed.

Lost update: read, modify, write, collide

T1T2
reads likes = 10reads likes = 10
writes likes = 11, commits
writes likes = 11, commits

Two likes, count up by one. The concert ticket is this race.

Write skew: two valid writes, one broken rule

Rule: a team always keeps at least one admin. Ana and Ben are the only two.

T1 (Ana)T2 (Ben)
counts admins: 2, so leaving is finecounts admins: 2, so leaving is fine
sets Ana's row to membersets Ben's row to member
commitscommits

Zero admins. Each updated a different row, so row-level checks see no conflict. Only serializable isolation, or a lock on something both touch, catches it.

Isolation levels

LevelDirty readLost updateWrite skew
Read uncommittedpossiblepossiblepossible
Read committedpreventedpossiblepossible
Snapshot (repeatable read in some databases)preventeddepends on the databasepossible
Serializablepreventedpreventedprevented

Read committed is a common default. Snapshot isolation gives each transaction a frozen view as of its start; some databases also abort a transaction whose row changed underneath it, others let the later write win. Names differ between products, so check what yours does. Serializable means the result equals some one-at-a-time order, at the cost of more aborts to retry.

Stopping a lost update

Three ways to make read-modify-write safe
OptionGood atBad atPick it when
Atomic conditional update Usual defaultOne statement, no race window: UPDATE … SET remaining = remaining - 1 WHERE remaining > 0.Only works when the change fits in one statement on one row.Counters and stock. The ticket fix.
Optimistic (version check)No locks held while the user thinks. Cheap when conflicts are rare.Every conflict is a retry; on a hot row most attempts fail.Edits to documents, profiles, carts: low contention, longer think time.
Pessimistic (lock the row)SELECT … FOR UPDATE makes others wait instead of fail.Waiting adds latency; locks taken in different orders deadlock.Hot rows with complex logic between read and write, kept short.
Optimistic locking with a version column
-- read: title = 'Draft', version = 7
UPDATE docs
SET    title = 'Final', version = 8
WHERE  id = 42 AND version = 7;
-- 1 row updated: you won.
-- 0 rows updated: someone wrote first. Re-read and retry, or show a conflict.

For write skew, make the transactions collide on purpose: lock the team row (SELECT … FOR UPDATE on teams) before counting admins, or run that one transaction as serializable.

Across services: 2PC and sagas

An order touches the orders database, payments and inventory. No single transaction spans them.

Two-phase commit (2PC) adds a coordinator. Phase 1: it asks every participant to prepare (do the work, hold the locks, promise to commit). Phase 2: if all voted yes, it tells them to commit; otherwise to abort. It is atomic, but if the coordinator dies after "prepare", participants hold their locks until it returns. It costs two round trips per transaction, and every participant must support it.

A saga gives up atomicity across services. It runs a chain of local transactions, each publishing an event that triggers the next. If a step fails, compensating actions undo earlier steps: refund the card, release the seat. Others can see the middle states ("paid, not yet reserved"), so design for them.

Each step must update its database and publish an event. As two separate writes, a crash between them loses one. The outbox pattern fixes this: write the event into an outbox table in the same local transaction, and let a relay publish rows from the outbox afterwards.

A saga with an outbox: a failure and its compensationUse the arrows or ← → keys
  1. A saga with an outbox: a failure and its compensation, step 1Shopper to Order service; Order service to Orders DB + outbox; Orders DB + outbox to Event stream (relay), asynchronous; Event stream to Order service, asynchronous; Event stream and Payment service, asynchronous; Event stream and Inventory service, asynchronous.ShopperOrder serviceOrders DB +outboxPENDING + eventEvent streamPayment serviceInventoryservicerelay
    order 501
    PENDING

    1The shopper places order 501. One local transaction inserts the order as PENDING and an OrderPlaced row in the outbox. Both or neither.

  2. A saga with an outbox: a failure and its compensation, step 2Shopper to Order service; Order service to Orders DB + outbox; Orders DB + outbox to Event stream (relay), asynchronous; Event stream to Order service, asynchronous; Event stream and Payment service, asynchronous; Event stream and Inventory service, asynchronous.ShopperOrder serviceOrders DB +outboxEvent streamPayment serviceInventoryservicerelay
    order 501
    PENDING

    2The relay reads the outbox and publishes OrderPlaced. If it crashes before marking the row sent, it publishes again later: at-least-once.

  3. A saga with an outbox: a failure and its compensation, step 3Shopper to Order service; Order service to Orders DB + outbox; Orders DB + outbox to Event stream (relay), asynchronous; Event stream to Order service, asynchronous; Event stream and Payment service, asynchronous; Event stream and Inventory service, asynchronous.ShopperOrder serviceOrders DB +outboxEvent streamPayment servicecharged $60Inventoryservicerelay
    order 501
    PENDING
    charged
    $60

    3Payment charges $60 and publishes PaymentTaken through its own outbox.

  4. A saga with an outbox: a failure and its compensation, step 4Shopper to Order service; Order service to Orders DB + outbox; Orders DB + outbox to Event stream (relay), asynchronous; Event stream to Order service, asynchronous; Event stream and Payment service, asynchronous; Event stream and Inventory service, asynchronous.ShopperOrder serviceOrders DB +outboxEvent streamPayment serviceInventoryserviceout of stockrelay
    order 501
    PENDING
    charged
    $60

    4Inventory tries to reserve the item. It sold out a moment ago, so it publishes ReservationFailed.

  5. A saga with an outbox: a failure and its compensation, step 5Shopper to Order service; Order service to Orders DB + outbox; Orders DB + outbox to Event stream (relay), asynchronous; Event stream to Order service, asynchronous; Event stream and Payment service, asynchronous; Event stream and Inventory service, asynchronous.ShopperOrder serviceOrders DB +outboxEvent streamPayment servicerefund $60Inventoryservicerelay
    order 501
    PENDING
    charged
    $0

    5Compensation: payment hears the failure and refunds $60. Across services there is no rollback, only a new action that undoes the old one.

  6. A saga with an outbox: a failure and its compensation, step 6Shopper to Order service; Order service to Orders DB + outbox; Orders DB + outbox to Event stream (relay), asynchronous; Event stream to Order service, asynchronous; Event stream and Payment service, asynchronous; Event stream and Inventory service, asynchronous.ShopperOrder serviceOrders DB +outboxEvent streamPayment serviceInventoryservicerelay
    order 501
    CANCELLED
    charged
    $0

    6The order service marks the order CANCELLED. The shopper briefly saw "paid". Reserving stock before charging would have avoided this refund.

  7. A saga with an outbox: a failure and its compensation, step 7Shopper to Order service; Order service to Orders DB + outbox; Orders DB + outbox to Event stream (relay), asynchronous; Event stream to Order service, asynchronous; Event stream and Payment service, asynchronous; Event stream and Inventory service, asynchronous.ShopperOrder serviceOrders DB +outboxEvent streamPayment serviceseen 501, skipInventoryservicerelay
    order 501
    CANCELLED
    charged
    $0

    7After a crash the relay re-sends OrderPlaced. Payment has already seen order 501 and ignores it. Every consumer must be idempotent.

1 / 7

The usual default across services is a saga with outboxes and idempotent consumers. Reach for 2PC only when the participants are a few databases that support it, in one data center, and a visible in-between state is unacceptable.

Numbers worth remembering

  • A short transaction on one database: about 1 to 10 ms. Locks held that long are cheap; locks held across a network call or a user's think time are not.
  • 2PC adds at least two round trips per transaction, plus a durable log write at each step.
Estimate: Is optimistic locking a good fit?

On average, how many other committed updates land on the same row between one transaction's read and its write?

You know
  • One hot row is updated 200 times a second
  • Each transaction takes 10 ms between its read and its write

Show the working
  1. 10 ms = 0.01 s
  2. 200 × 0.01 = 2 other updates in each transaction's window

Estimate 2 (anything from 0.7 to 6 is a good answer)

With about 2 rivals in every window, most attempts fail their version check and retry. Use an atomic update or a lock for this row.

In the interview

Bring this up whenever two users can change the same thing (stock, seats, balances) or one action spans services. Name the race, then the fix: "Two buyers can read the same stock, so I decrement with a conditional update and treat zero rows as sold out."

Placing an order across services
Interviewer

Orders, payments and inventory are separate services. How do you keep them consistent?

You

I would not use a distributed transaction here. Each service commits locally and publishes an event through an outbox, so a crash cannot lose the event. If inventory fails after payment, payment refunds. Consumers dedupe by order id, since events can arrive twice.

Interviewer

The user sees "paid" and then "cancelled". Is that okay?

You

It is the cost of a saga. To soften it I would reserve stock first and charge second, so the common failure happens before any money moves.

Follow-ups you should expect:

  • "What if the compensation fails?" Retry it; it must be idempotent. After several failures, alert a human.
  • "Why not publish the event, then write the database?" A crash between the two leaves an event for an order that does not exist. The outbox makes them one write.

Common mistakes

Quick check

Each of two reviewers checks that the article has another reviewer, then unassigns themselves. Both commit under snapshot isolation. Which anomaly is this?

A seat counter is decremented 500 times a second during a sale. What is the best fix for lost updates?

A service writes an order row, then publishes an event in a separate call. What does the outbox pattern change?

What to practise next

Sagas and outboxes run on message queues and streams. For a design where these choices decide the outcome, work through Design a Payment System.