Transactions & Isolation
At 10:00:03 on sale day, Alice and Bob both click seat A-12 of the stadium show. Both see "Seat held β you have 10 minutes to pay". Both pay. At the gate, one of them is turned away. The code that did this had been in production for a year, had 100% test coverage and passed every code review β because the bug only exists when two requests interleave in the database, which never happens in a unit test.
This page explains why, using the vocabulary every database engineer shares: transactions, isolation levels and anomalies. Then it fixes the bug four different ways and builds the seat-hold flow BoxOffice really uses.
ACID, concretely
A transaction groups several reads and writes into one logical unit. The classic guarantees are summarized as ACID1:
| Letter | Guarantee | What it means for a seat hold | What it does not mean |
|---|---|---|---|
| Atomicity | all writes of the transaction happen, or none | the seat is marked HELD and the hold row is created, or neither | nothing about concurrency |
| Consistency | the database moves from one valid state to another | constraints (CHECK, UNIQUE, foreign keys) hold after every commit | it is mostly your invariants, enforced by A, I and D plus constraints |
| Isolation | concurrent transactions do not interfere as defined by the isolation level | Bob's transaction does not see Alice's half-done work | it does not mean "as if run one at a time" β unless the level is serializable |
| Durability | once committed, the data survives a crash | after "Seat held", a power cut does not undo the hold | survival of a disk or region loss β that needs replication (Replication) |
The trap is in the I: most databases do not run transactions as if one at a time by default. PostgreSQL, Oracle and SQL Server default to read committed; MySQL InnoDB defaults to repeatable read. Both permit anomalies β including the one that sold seat A-12 twice.
The double-booking bug
This is the code path, written as the SQL each request sends (the application checks the result of the SELECT before issuing the UPDATE):
BEGIN; -- PostgreSQL default: READ COMMITTED
SELECT status FROM seats
WHERE event_id = 42 AND seat_id = 'A-12';
-- application: if status != 'AVAILABLE' then ROLLBACK and show "seat taken"
UPDATE seats
SET status = 'HELD', held_by = :customer_id,
hold_expires_at = now() + interval '10 minutes'
WHERE event_id = 42 AND seat_id = 'A-12';
COMMIT;
Now interleave two requests:
sequenceDiagram
participant A as Alice (T1)
participant DB as PostgreSQL
participant B as Bob (T2)
A->>DB: SELECT status β¦ A-12
DB-->>A: AVAILABLE
B->>DB: SELECT status β¦ A-12
DB-->>B: AVAILABLE
A->>DB: UPDATE β¦ SET HELD, held_by = Alice
Note over DB: T1 locks row A-12
B->>DB: UPDATE β¦ SET HELD, held_by = Bob
Note over DB: T2 waits for T1's row lock
A->>DB: COMMIT
DB-->>A: ok β "seat held"
Note over DB: lock released: T2's UPDATE runs<br/>and overwrites Alice's hold
B->>DB: COMMIT
DB-->>B: ok β "seat held" Both requests read AVAILABLE before either wrote; both decided "free"; the second UPDATE overwrote the first. This is the check-then-act race, and the anomaly is called a lost update: Alice's write was silently lost. The row lock did not help, because it only serialized the two UPDATEs β the decision had already been made on stale reads.
If the tickets table has PRIMARY KEY (event_id, seat_id) (see Data Models), the second ticket insert fails at payment confirmation β after the money was taken. The constraint saved the stadium from two people in one seat, but turned the bug into a refund and an angry customer. Constraints are the last line of defense, not the design.
Think first: would wrapping the code in a transaction with a stricter isolation level fix it?
In PostgreSQL, yes: under repeatable read, T2's UPDATE fails with could not serialize access due to concurrent update because the row changed after T2's snapshot; the application retries, reads HELD and tells Bob the seat is taken. Under serializable the same happens. In MySQL InnoDB, repeatable read does not detect it: the UPDATE reads the latest committed row and overwrites it. Isolation levels have the same names across databases but not the same behaviour β test yours, or use one of the fixes below that work everywhere.
Isolation levels and their anomalies
An anomaly is an outcome that could never happen if transactions ran one at a time (serially). Each weaker isolation level permits some of them in exchange for more concurrency2.
| Anomaly | What happens | BoxOffice example |
|---|---|---|
| Dirty read | T2 reads data T1 wrote but has not committed | the seat map shows A-12 sold; Alice's card is declined and T1 rolls back β the seat was never sold |
| Non-repeatable read (read skew) | T reads the same data twice and gets different values, because another transaction committed in between | a report counts 59,990 sold seats, then sums paid orders and gets 60,002 tickets β the two numbers disagree |
| Lost update | two transactions read, modify and write the same row; one write is lost | the double booking above |
| Phantom | a query's set of matching rows changes because another transaction inserted or updated rows | "how many tickets does customer 7 hold?" returns 2, while another transaction adds 2 more |
| Write skew | two transactions read the same data, then write different rows based on it; each is fine alone, together they break an invariant | the ticket limit below |
The levels, as implemented by PostgreSQL (other databases differ β check the documentation3):
| Level | Dirty read | Non-repeatable read | Phantom | Lost update | Write skew |
|---|---|---|---|---|---|
| Read uncommitted (behaves as read committed in PostgreSQL) | no | possible | possible | possible | possible |
| Read committed (default) | no | possible | possible | possible | possible |
| Repeatable read (snapshot isolation) | no | no | no | detected β error, retry | possible |
| Serializable (serializable snapshot isolation) | no | no | no | no | no β aborts with error 40001, retry |
How snapshots work: MVCC
PostgreSQL, MySQL InnoDB, Oracle and most modern engines use multi-version concurrency control (MVCC): an update writes a new version of the row instead of overwriting it, tagged with the transaction that created it. Each transaction reads from a snapshot β the versions committed before it (repeatable read) or before each statement (read committed) started.
Row A-12 version | Created by | Visible to |
|---|---|---|
status = AVAILABLE | T100 (loading the event) | transactions whose snapshot predates T205's commit |
status = HELD, held_by = Alice | T205 (Alice) | transactions that start after T205 commits |
The payoff: readers never block writers, and writers never block readers. The cost: old versions accumulate and must be cleaned up (VACUUM in PostgreSQL), and two writers to the same row still serialize on a row lock.
Write skew: the ticket limit
BoxOffice allows at most 6 tickets per customer per event. Alice already holds 4 and opens two browser tabs, each trying to hold 2 more seats:
| Time | Tab 1 (T1) | Tab 2 (T2) |
|---|---|---|
| 1 | SELECT count(*) β¦ WHERE held_by = 7 β 4 | |
| 2 | SELECT count(*) β¦ WHERE held_by = 7 β 4 | |
| 3 | 4 + 2 β€ 6 β UPDATE seats β¦ 'B-1', 'B-2' | |
| 4 | 4 + 2 β€ 6 β UPDATE seats β¦ 'C-7', 'C-8' | |
| 5 | COMMIT | COMMIT |
| Alice now holds 8 tickets |
No row was written by both transactions, so no row lock conflicts and snapshot isolation sees nothing wrong. This is write skew, and it is driven by a phantom: each transaction's count(*) would have changed had it seen the other's writes. Two fixes:
- Serializable isolation: PostgreSQL's serializable snapshot isolation (SSI)4 tracks read/write dependencies and aborts one transaction with
ERROR: could not serialize access due to read/write dependencies among transactions(SQLSTATE40001). The application must retry it. - Materialize the conflict: make both transactions lock the same row first, e.g. a per-customer-per-event row:
BEGIN;
SELECT tickets_held FROM customer_event_limits
WHERE customer_id = 7 AND event_id = 42
FOR UPDATE; -- the second tab waits here
-- application: if tickets_held + 2 > 6 then ROLLBACK
UPDATE seats SET status = 'HELD', held_by = 7,
hold_expires_at = now() + interval '10 minutes'
WHERE event_id = 42 AND seat_id IN ('B-1', 'B-2') AND status = 'AVAILABLE';
UPDATE customer_event_limits SET tickets_held = tickets_held + 2
WHERE customer_id = 7 AND event_id = 42;
COMMIT;
Four ways to fix the double booking
All four work on any mainstream relational database at read committed, which is why they are preferred over "just raise the isolation level".
1. Atomic conditional update (compare-and-set)
Put the check inside the write, so check and act are one atomic statement:
UPDATE seats
SET status = 'HELD', held_by = :customer_id,
hold_expires_at = now() + interval '10 minutes'
WHERE event_id = 42 AND seat_id = 'A-12'
AND status = 'AVAILABLE';
-- 1 row updated β the hold is yours; 0 rows β someone else got it
When Bob's UPDATE waits for Alice's lock and then runs, PostgreSQL re-checks the WHERE clause against the new row version: status is now HELD, so 0 rows are updated. One round trip, no explicit lock, no retry loop. This is the BoxOffice default.
2. Pessimistic locking: SELECT β¦ FOR UPDATE
Lock the row when you read it, so nobody can change it until you commit:
BEGIN;
SELECT status FROM seats
WHERE event_id = 42 AND seat_id = 'A-12'
FOR UPDATE; -- Bob blocks here until Alice commits
-- application: if status != 'AVAILABLE' then ROLLBACK
UPDATE seats SET status = 'HELD', held_by = :customer_id,
hold_expires_at = now() + interval '10 minutes'
WHERE event_id = 42 AND seat_id = 'A-12';
COMMIT;
Use it when the decision needs application logic between the read and the write (pricing rules, the ticket limit). Variants matter under load:
FOR UPDATE NOWAITfails immediately instead of waiting β good for "seat taken, choose another".FOR UPDATE SKIP LOCKEDskips rows other transactions hold β perfect for "best available": hand each fan different free seats without them queuing on the same rows.
SELECT seat_id FROM seats
WHERE event_id = 42 AND section = 'B' AND status = 'AVAILABLE'
ORDER BY seat_id
LIMIT 2
FOR UPDATE SKIP LOCKED;
When one transaction locks several rows (a group of 4 seats), always lock them in the same order (e.g. ORDER BY seat_id). Otherwise T1 locks A-1 then waits for A-2 while T2 locks A-2 then waits for A-1 β a deadlock; the database kills one of them.
3. Optimistic locking with a version column
Do not lock at all; detect conflicts at write time with a version counter:
SELECT status, version FROM seats
WHERE event_id = 42 AND seat_id = 'A-12'; -- AVAILABLE, version 7
UPDATE seats
SET status = 'HELD', held_by = :customer_id, version = version + 1,
hold_expires_at = now() + interval '10 minutes'
WHERE event_id = 42 AND seat_id = 'A-12' AND version = 7;
-- 0 rows β somebody changed the seat since you read it: reload and retry
JPA/Hibernate does this automatically with an @Version field and throws OptimisticLockException. It shines when conflicts are rare (editing an event description) and holds no locks during user think time. It is a poor fit for a flash sale, where thousands of fans collide on the same few rows and most attempts fail and retry.
4. Let a constraint decide
Make the invariant a unique constraint, and let the database reject the loser. With a separate holds table, an expired hold can even be taken over atomically:
CREATE TABLE seat_holds (
event_id BIGINT NOT NULL,
seat_id TEXT NOT NULL,
customer_id BIGINT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (event_id, seat_id)
);
INSERT INTO seat_holds (event_id, seat_id, customer_id, expires_at)
VALUES (42, 'A-12', :customer_id, now() + interval '10 minutes')
ON CONFLICT (event_id, seat_id) DO UPDATE
SET customer_id = EXCLUDED.customer_id, expires_at = EXCLUDED.expires_at
WHERE seat_holds.expires_at < now(); -- only if the old hold expired
-- 1 row β hold acquired; 0 rows β a live hold exists
Comparing the fixes
| Approach | How conflicts are resolved | Under heavy contention | Round trips | Best for |
|---|---|---|---|---|
Conditional UPDATE | the WHERE is re-checked; loser updates 0 rows | good: short lock, no retries | 1 | single-row check-and-set (seat hold) |
SELECT β¦ FOR UPDATE | losers wait for the lock | waits pile up; use NOWAIT / SKIP LOCKED | 2+ | logic between read and write; multi-row decisions |
Optimistic (version) | loser detects the change and retries | many failed attempts and retries | 2 + retries | rare conflicts, long user think time |
| Unique constraint / upsert | loser gets a constraint violation or 0 rows | good | 1 | "at most one" invariants; last line of defense |
| Serializable + retry | database aborts one transaction | aborts grow with contention | n + retries | complex invariants across many rows |
In the BoxOffice code: three strategies behind one switch (lab 6)
Hands-on 6 β Holds puts three of these behind one setting, INVENTORY_LOCKING, and runs the same flash-sale test against each (100 seats, thousands of attempts). naive is the bug above, check-then-act; pessimistic is fix 2, locking the seats in ORDER BY id so that two overlapping holds cannot deadlock; optimistic is fix 3, a version compare-and-set per seat. In one run on a busy laptop the audit found 9 double-booked seats with naive and 0 with the other two, and optimistic had the lowest hold p99 (1.03 s against 1.77 s for pessimistic). That does not contradict the table: when a seat is gone, the conflict is the answer (409, "taken"), so nobody retries and nobody waits for a lock. Optimistic locking degrades only when the losers retry the same rows.
private void holdNaive(long eventId, List<Long> seatIds, UUID holdId) {
var seats = read(eventId, seatIds, "");
requireAvailable(seats, seatIds);
// race window: another transaction may have read the same AVAILABLE seats and
// be about to run this same UPDATE β both will "win"
jdbc.sql("UPDATE inventory.seats SET status = 'HELD', hold_id = :holdId WHERE id IN (:ids)")
.param("holdId", holdId).param("ids", seatIds)
.update();
}
private void holdPessimistic(long eventId, List<Long> seatIds, UUID holdId) {
// row locks until commit; a second transaction on the same seats WAITS here.
// Locking in id order (ORDER BY id) prevents deadlocks between overlapping holds.
var seats = read(eventId, seatIds, "ORDER BY id FOR UPDATE");
requireAvailable(seats, seatIds);
jdbc.sql("UPDATE inventory.seats SET status = 'HELD', hold_id = :holdId, version = version + 1 WHERE id IN (:ids)")
.param("holdId", holdId).param("ids", seatIds)
.update();
}
private void holdOptimistic(long eventId, List<Long> seatIds, UUID holdId) {
var seats = read(eventId, seatIds, ""); // no lock
requireAvailable(seats, seatIds);
for (var seat : seats) {
int changed = jdbc.sql("""
UPDATE inventory.seats
SET status = 'HELD', hold_id = :holdId, version = version + 1
WHERE id = :id AND version = :version AND status = 'AVAILABLE'""")
.param("holdId", holdId).param("id", seat.id()).param("version", seat.version())
.update();
if (changed == 0) {
// someone changed this seat after we read it: give up (the exception rolls back)
throw new ResponseStatusException(HttpStatus.CONFLICT, "seat " + seat.id() + " was just taken");
}
}
}
Seat holds, end to end
A hold is a lease: a temporary, expiring claim. The seat's life cycle:
stateDiagram-v2
[*] --> AVAILABLE
AVAILABLE --> HELD: hold (conditional UPDATE)
HELD --> SOLD: payment confirmed
HELD --> AVAILABLE: hold expired or cancelled
SOLD --> AVAILABLE: refund (rare, audited)
SOLD --> [*] Two design decisions make it robust:
-
Lazy expiry. The hold query treats an expired hold as available, so a seat is never stuck because a cleanup job is late:
InventoryRepository.java (Spring Data JPA)@Modifying @Query(""" UPDATE Seat s SET s.status = 'HELD', s.heldBy = :customerId, s.holdExpiresAt = :expiresAt WHERE s.eventId = :eventId AND s.seatId = :seatId AND (s.status = 'AVAILABLE' OR (s.status = 'HELD' AND s.holdExpiresAt < CURRENT_TIMESTAMP)) """) int tryHold(@Param("eventId") long eventId, @Param("seatId") String seatId, @Param("customerId") long customerId, @Param("expiresAt") Instant expiresAt);The service throws
SeatTakenExceptionwhentryHoldreturns 0. A background job still flips expired holds back toAVAILABLE(using the partial index onhold_expires_at) so the seat map stays accurate. -
Confirm conditionally, too. Payment confirmation runs
UPDATE seats SET status = 'SOLD' WHERE β¦ AND status = 'HELD' AND held_by = :customer_id AND hold_expires_at > now(). If the hold expired while the fan was paying, 0 rows are updated and the payment is refunded or the fan is offered another seat. Never assume the state you saw minutes ago is still true.
Contention: why hot rows cap throughput
A row lock behaves like a server that serves one transaction at a time. If each transaction holds it for t seconds, that row admits at most 1 / t transactions per second, no matter how big the machine is (Little's Law, from Queueing). The plot compares three designs under illustrative assumptions (load spread evenly over the contended rows; the database itself tops out at 10,000 transactions per second):
The lessons:
- Never keep a global counter in the hot path. Decrementing
events.seats_leftin every hold serializes the whole on-sale on one row: at 5 ms per transaction, 200 holds per second, for 2 million fans. Compute "seats left" asynchronously (or with per-section counters) and show it from a cache. - Keep transactions short. A transaction that calls the payment provider (hundreds of milliseconds to seconds) while holding a seat lock multiplies lock time by 100. Hold the seat, commit, call the provider outside any transaction, then confirm in a new short transaction.
- Fine-grained rows spread contention. One row per seat means fans only collide when they want the same seat.
Try it. The flash-sale scenario starts without a waiting room: app writes straight to inventory-db. Run it, then raise the app replicas until the app is no longer the bottleneck: when the sale opens (t = 60 s) inventory-db saturates on writes (a write costs 3 reads' worth of capacity) and p99 explodes. Then open it in the full playground (β) and press Show a possible solution: a waiting-room queue drained by order workers keeps the writes reaching inventory-db β the concurrent hold transactions β below what the database can serve. (The simulator models capacity, not locks: the row contention above is the part you reason about by hand.)
Transactions across services
The hold lives in the inventory database, the order in the order database, and the payment at an external provider. No single ACID transaction spans them.
- Two-phase commit (2PC) coordinates several databases: a coordinator asks every participant to prepare (promise to commit), then tells all of them to commit. It is atomic, but a participant that prepared must wait β holding locks β until the coordinator answers; if the coordinator crashes at the wrong moment, they block. It is used inside some databases and with XA transactions, rarely across microservices, and never across an external payment provider.
- Sagas split the business transaction into local transactions, each with a compensation (release the hold, refund the payment), coordinated by events or an orchestrator. Combined with the transactional outbox (write the event in the same local transaction as the state change), they give atomicity eventually. See Messaging patterns.
- Calls to the payment provider carry an idempotency key, so a retry never charges twice. See Integrations.
BoxOffice: the checkout, transaction by transaction
inventory: conditionalUPDATEβ seatHELDfor 10 minutes. Commit. (~2 ms of lock time)order: insert the order asPENDING+ an outbox eventOrderCreated. Commit.payment: call the provider with idempotency key = order id. No database transaction open.- On
PaymentSucceeded:inventoryruns the conditional confirm (HELDby this customer and not expired βSOLD) and inserts the ticket (unique key). Commit. - On failure or timeout: compensations release the hold and cancel the order.
Every step is short, idempotent and safe to retry, and no lock is held while waiting for the network.
In the interview
- When a design has a scarce resource (seats, stock, usernames, coupon codes), say the words "check-then-act race" and show the fix: "a conditional
UPDATE β¦ WHERE status = 'AVAILABLE'and I check the affected row count", plus "a unique constraint as the last line of defense". - Know the default isolation level of the database you name (PostgreSQL: read committed; MySQL InnoDB: repeatable read) and one anomaly it allows.
- For "best available" or job queues on SQL, mention
FOR UPDATE SKIP LOCKED. - For flows across services, say saga + outbox + idempotency keys, not distributed transactions.
- Mention contention: "a single counter row would cap the on-sale at 1 / lock-time β I avoid it".
Best practices
- Set
lock_timeoutandstatement_timeoutβ a hold should wait for a lock at most a second or two and fail with a clear error, instead of piling up behind a slow transaction; set them per role or withSET LOCALper transaction. - Answer a lost race with
409, not500β the loser of a conflict is a normal outcome the client can act on (pick other seats). In BoxOffice:requireAvailableand the optimisticUPDATEthrowCONFLICT(lab 6). - Bound the size of every transaction β a maximum number of rows per transaction bounds its lock time. In BoxOffice: at most 6 seats per hold (
max-seats-per-hold), at most 500 holds per sweep (LIMIT 500). - Audit the invariant continuously β a query that counts violations (seats in two live holds), run after every load test and on a schedule in production. In BoxOffice:
GET /inventory/audit/double-bookings(lab 6). - Alert on old transactions β any transaction open for more than about 30 s (
now() - xact_startinpg_stat_activity) holds locks and keeps vacuum from cleaning old row versions.
Common pitfalls
- Check in the application, act in the database β the double booking. Move the check into the
WHEREclause or a constraint. - Long transactions: a transaction open during an HTTP call, a user's think time, or a big report holds locks and old MVCC versions for its whole duration.
- Retrying without idempotency: serializable and optimistic strategies require retries; the retried code must be safe to run twice.
- Raising the isolation level as a magic fix: it changes behaviour per database, adds aborts under load, and still needs a retry loop.
- Inconsistent lock order when locking several rows: deadlocks under exactly the load you are testing for.
- Caching the decision: the seat map may come from a cache; the hold must always hit the database (see Caching).
How it connects
flowchart LR
ACID["ACID"] --> ISO["isolation levels<br/>MVCC snapshots"]
ISO --> ANOM["anomalies: lost update,<br/>write skew"]
ANOM --> BUG["the double-booking bug<br/>check-then-act race"]
BUG --> FIX["conditional UPDATE, FOR UPDATE,<br/>version column, unique constraint"]
FIX --> HOLD["seat holds as leases<br/>lazy expiry + cleanup job"]
FIX --> CONT["hot rows cap throughput<br/>at 1 / lock time"]
CONT --> WR["waiting room<br/>β Messaging"]
BUG -.-> DIST["consistency models<br/>β Distributed"]
ACID --> XS["across services: saga, outbox,<br/>idempotency keys"] Palette components: sql (the inventory database decides: conditional updates, row locks, constraints) Β· app (inventory answers 409 to the losers; transactions stay short) Β· queue (the waiting room bounds contention on hot rows) Β· scheduler (the job that flips expired holds back) Β· stream (outbox events between order, inventory and payment) Β· payment (called with an idempotency key, never inside a transaction). Scenarios: flash-sale, payment-system. Continues in: Replication, Distributed β transactions, Messaging β patterns, Compute β schedulers, Case study: ticketing, Hands-on 6.
-
GRAY, J.; REUTER, A. Transaction Processing: Concepts and Techniques. Morgan Kaufmann, 1992.Β β©
-
BERENSON, H. et al. A Critique of ANSI SQL Isolation Levels. ACM SIGMOD, 1995. See also KLEPPMANN, M. Hermitage: testing transaction isolation levels.Β β©
-
PostgreSQL Global Development Group. Transaction Isolation. PostgreSQL Documentation.Β β©
-
PORTS, D. R. K.; GRITTNER, K. Serializable Snapshot Isolation in PostgreSQL. Proceedings of the VLDB Endowment, 5(12), 2012.Β β©