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.
On this page
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
UNIQUEandCHECK (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
| T1 | T2 |
|---|---|
| reads likes = 10 | reads 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 fine | counts admins: 2, so leaving is fine |
| sets Ana's row to member | sets Ben's row to member |
| commits | commits |
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
| Level | Dirty read | Lost update | Write skew |
|---|---|---|---|
| Read uncommitted | possible | possible | possible |
| Read committed | prevented | possible | possible |
| Snapshot (repeatable read in some databases) | prevented | depends on the database | possible |
| Serializable | prevented | prevented | prevented |
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
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Atomic conditional update Usual default | One 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. |
-- 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.
- order 501
- PENDING
1The shopper places order 501. One local transaction inserts the order as PENDING and an
OrderPlacedrow in the outbox. Both or neither.- 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.- order 501
- PENDING
- charged
- $60
3Payment charges $60 and publishes
PaymentTakenthrough its own outbox.- order 501
- PENDING
- charged
- $60
4Inventory tries to reserve the item. It sold out a moment ago, so it publishes
ReservationFailed.- 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.
- 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.
- 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.
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.
On average, how many other committed updates land on the same row between one transaction's read and its write?
- One hot row is updated 200 times a second
- Each transaction takes 10 ms between its read and its write
Show the working
- 10 ms = 0.01 s
- 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."
Orders, payments and inventory are separate services. How do you keep them consistent?
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.
The user sees "paid" and then "cancelled". Is that okay?
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
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.