Books Deep-Read · DDIA · Chapter 7
Designing Data-Intensive Applications · Ch 7 · Martin Kleppmann · 2017
You transfer money to a friend: your balance drops by 100, theirs hasn't gone up yet — and at that instant the data center loses power. Where did the money go? DDIA Chapter 7 is about the transaction, the database's answer: it guarantees that a group of operations either all happen or none of them do, never stopping halfway. The chapter also reveals that many databases' claimed "safety" is weaker than you'd think.
A transaction is like packing for a move: you seal a room's worth of stuff into one box. It either arrives intact at the new place (commit), or something goes wrong en route and it's returned untouched to the old place (abort) — never "the table arrived but the chairs got lost on the way." The database seals "subtract from A" and "add to B" into the same box: they succeed together or fail together.
The hard part isn't one person slowly editing data — it's many people editing at once. Two people grabbing the last ticket, both adding a line to the same wiki page, both deducting from the same account balance. If the database doesn't step in, one person's change gets silently overwritten, or you read a mix of half-old, half-new data — a garbled total. Add machines that can crash anytime, and chaos is the default.
How does the database keep everyone from clashing? The mainstream trick is to hand each transaction a "photo of this moment" — your operation sees the world exactly as it was when you began, invisible to others' later changes, so nothing scrambles it (this is a snapshot). When you say "commit," the database double-checks: did anything I read get changed in the meantime? If so, you redo it. Everyone reads their own photo, and we reconcile at commit — that's the skeleton of modern database concurrency.
A hospital requires at least 2 doctors on call. Right now exactly 2 are on duty, and both want to take leave. They click "request leave" at nearly the same time: Dr. Li glances — "there are 2 of us, I can go" — and so does Dr. Wang — "there are 2, I can go." So both leave, and the on-call room is empty. Each looked fine alone; together they broke the rule. This "you read yours, I read mine, and together it goes wrong" trap (called write skew) is exactly what many supposedly-safe databases fail to catch — and this chapter teaches you to spot it.
The database turns "safety" into an adjustable dial (called the isolation level): loosest is fast but prone to the messes above; tightest (called serializable, which behaves as if "everyone queues up one at a time") is safest but either slow or full of retries. The catch: names lie. Some databases label the dial "serializable" when it's really only turned to the middle. So don't trust the name — check which traps it actually blocks.
A transaction = a box that's "all-or-nothing," shielding you from two kinds of chaos: concurrent clashes and mid-flight crashes. But "safety" is an adjustable dial whose label is often inflated — judge it by the traps it truly blocks, not the tier it claims.
One honest cost: turning the dial all the way up (true serializability) either forces transactions into a single slow queue, or makes them "collide and retry" under high concurrency — safety is never free.
Want the actual mechanics — ACID, isolation levels, MVCC snapshots, two-phase locking? → Switch to the Deep version
A transaction is a layer of "pretend nothing went wrong" that the database offers the application: it packs a group of reads and writes into one logical unit, letting you code as if "concurrency problems and partial failures don't exist." This chapter first pins down what the four letters of ACID actually guarantee (especially the most-misunderstood I / isolation), then lays out which concurrency anomalies each of the industry's weak isolation levels lets slip through, and finally gives three roads to serializability — along the way you'll find that many databases' claimed "safety" is far weaker than the name.
This chapter is in Part II, "Distributed Data." The previous two chapters (Replication, Partitioning) handle "data spread across many machines"; this chapter pulls the lens back to a single node to tackle an orthogonal problem: how to avoid chaos when there is concurrent access to the same data + the ever-present risk of a crash. It builds on Ch 5–6's discussion of multi-replica consistency and sets up Ch 8–9 (the trouble with distributed systems, consistency and consensus) — you can only reason clearly about the harder distributed consistency once you grasp single-node isolation and its anomalies. It maps to every relational database, and a growing number of distributed databases.
Data systems are full of things going sideways: a process crashes mid-write, several clients modify the same record at once, a network interruption makes requests ambiguous. If every application had to handle these corner cases itself, the code would drown in defensive logic — and almost certainly get it wrong, because race conditions are notoriously hard to test and reproduce. The value of a transaction is packing this whole class of problems into one abstraction: the app just fences related operations into a transaction, and the database guarantees "all-or-nothing, with concurrency kept out of each other's way." This has been the database's core trick for shielding applications from chaos for decades.
But DDIA is blunt: transactions are not a law of nature; they are an engineering choice with a cost. In the 2000s the NoSQL wave threw transactions overboard for the sake of scalability and high availability; developers then discovered that without them, application-level error handling was miserable — so transactions were partly brought back. The question this chapter answers: what exactly do transactions guarantee, how expensive are those guarantees, and where do they quietly fail to deliver on their promise?
ACID is the classic acronym for a transaction's guarantees, but DDIA stresses it is more of a marketing slogan — implementations differ enormously across databases. Letter by letter:
The counterpart to ACID is a vague marketing term, BASE (Basically Available, Soft state, Eventual consistency), which effectively means "anything that isn't ACID" — its boundary is fuzzy, so don't read too much into it.
Even a single write to a single object needs atomicity and isolation: if writing a 20KB JSON document crashes midway, other readers must not see half a corrupt document (atomicity via a log, isolation via a row lock). But that's just the baseline. The scenarios that truly need multi-object transactions (fencing operations over multiple rows and tables together) are: foreign-key references that must stay consistent across rows, denormalized redundant data that must be updated together, and secondary indexes that must stay in sync with the primary data — the places where "several things must be right at once." Without transactions you get dangling references and indexes fighting the data.
The most common default, giving two guarantees: ① no dirty reads — you only read committed data; another transaction's half-done, uncommitted intermediate value is invisible; ② no dirty writes — you only overwrite committed data; when two transactions write the same object, the later one waits for the earlier to commit (queued via a row lock), so their uncommitted writes never get tangled. Implementation: writes use row locks; for reads the database keeps both "old value + new value" and returns the old until commit. PostgreSQL, Oracle, SQL Server and others default to it. But it fails to block the trap below.
First, what Read Committed misses — read skew (a.k.a. nonrepeatable read): you have accounts A and B, each holding 500. You read A first (still 500); meanwhile a transfer moves 100 from A to B and commits; you then read B (now 600). The total you see is 500 + 600 = 1100 — 100 appeared from nowhere. The data was never wrong for even an instant; you just read "half before the transfer, half after." For backups, reconciliation, and analytic queries that read a large swath at once, this garbled total is a disaster.
Snapshot isolation solves it elegantly: each transaction reads from a consistent snapshot as of the instant it started — throughout the transaction it sees the world of "that starting moment," blind to anything others commit afterward. So the total above is always 1000. The mainstream implementation is MVCC: the database keeps multiple versioned copies of each item, and each transaction, by the visibility rule of "which transactions had committed before I started," reads the version it should see. The beauty: readers don't block writers and writers don't block readers — a long read-only query and a high-frequency write stream run in parallel without stalling each other.
The other big concurrent-write trap is the lost update: two transactions each do a "read-modify-write" cycle — say each reads counter 42, each adds one, each writes back 43; the result should be 44 but only rose by 1, one increment silently eaten. Concurrent wiki edits and concurrent balance deductions both hit this. DDIA offers several fixes: atomic write operations (UPDATE … SET n = n + 1, compressing read-modify-write into one database step — the top recommendation), explicit locking (SELECT … FOR UPDATE), automatic detect-and-abort-then-retry (some snapshot-isolation implementations detect lost updates and roll back, e.g. PostgreSQL Repeatable Read; but MySQL/InnoDB does not detect them), and compare-and-set.
This is the trap to remember most in the chapter, and the one snapshot isolation fails to block. Write skew is a generalization of the lost update: two transactions read the same set of data, then each writes a different object based on it; each looks compliant alone, but together they break an invariant.
The classic example (used in DDIA itself): a hospital requires at least 2 doctors on call; right now exactly 2 are on duty and both want leave. Two transactions run almost simultaneously: both first check "are there currently ≥ 2?" — and because each reads its own snapshot, both see 2, both decide "I can go", and each flips its own row to "off call." The result: 0 doctors on call, the invariant shattered. Similar cases: double-booking a meeting room or seat, claiming the same username, double-spending from an account, two players in a game moving to the same square.
Their common structure is the phantom: one transaction's write changes the result set of another transaction's query — you made a decision based on "which rows currently satisfy some condition," but that "which rows" was altered by someone else's write. Snapshot isolation blocks phantoms in read-only queries, but not this "decide from a query result, then write" write-skew pattern. To truly prevent it, you need serializability.
Only serializability blocks every anomaly above at once — it guarantees the concurrent result is equivalent to "some serial order." Industry has three roads:
30 years. Readers and writers block each other: reads take a shared lock, writes an exclusive lock; a writer blocks all readers and vice versa; predicate locks / index-range locks lock even "future rows matching a condition" to prevent phantoms. Safe but slow: heavy lock overhead, low concurrency, deadlock-prone (the database detects one and aborts it), and very unstable tail latency (p99).SERIALIZABLE and FoundationDB). On top of snapshot isolation it takes no locks and lets transactions run, checking only at commit: has the "premise" my operation relied on (the result of an earlier query) been changed by someone else in the meantime? If so, abort and retry. Under low contention it far outperforms 2PL (readers don't block writers); under high contention the abort rate rises.The soul of this chapter is two comparison tables: first recognize which anomalies each isolation level lets through, then pick one of the three serializability roads by workload.
Table 1 · Isolation level × whether it blocks a given anomaly (DDIA's core matrix)
| Isolation level | Dirty read | Dirty write | Read skew (nonrepeatable) | Lost update | Write skew / phantom |
|---|---|---|---|---|---|
| Read Uncommitted | possible ✗ | blocks ✓ | possible ✗ | possible ✗ | possible ✗ |
| Read Committed | blocks ✓ | blocks ✓ | possible ✗ | possible ✗ | possible ✗ |
| Snapshot Isolation (Repeatable Read) | blocks ✓ | blocks ✓ | blocks ✓ | depends* | possible ✗ |
| Serializable | blocks ✓ | blocks ✓ | blocks ✓ | blocks ✓ | blocks ✓ |
* Lost update: PostgreSQL's Repeatable Read auto-detects and aborts; MySQL/InnoDB's does not. "Repeatable Read" is the most vaguely defined level in the SQL standard and the most inconsistently implemented — which is exactly where the name is least trustworthy.
Table 2 · The three roads to serializability — how to choose
| Actual serial execution | Two-phase locking (2PL) | Serializable snapshot isolation (SSI) | |
|---|---|---|---|
| Idea | single thread, one at a time | pessimistic locking: read/write exclude | optimistic: run first, check conflicts at commit |
| Concurrency/perf | capped at one core; transactions must be short | heavy lock overhead, low concurrency, deadlocks | reads don't block writes, fastest at low contention |
| Tail latency | stable (no lock waits) | p99 jitters badly (lock waits / deadlock) | depends on abort/retry rate |
| Hard constraint | transactions as stored procedures; data must fit in memory | needs predicate locks to block phantoms | abort rate ↑ under high contention, must retry |
| Representative | VoltDB, Redis, Datomic | classic RDBMS SERIALIZABLE | PostgreSQL 9.1+, FoundationDB |
| Best for | few hotspots, short high-frequency OLTP | heavy contention, mature systems needing strong isolation | moderate contention, wanting serializable + throughput |
Table 3 · Four ways to prevent lost updates
| Method | How | Caveat |
|---|---|---|
| Atomic write op | UPDATE t SET n=n+1 WHERE id=…, read-modify-write in one step | top choice; only for updates expressible in a single statement |
| Explicit lock | SELECT … FOR UPDATE: lock first, then modify | you must remember to add it; miss one spot, miss one update |
| Auto-detect | database spots the lost update, aborts, lets you retry | implementation-dependent (PostgreSQL RR yes, MySQL RR no) |
| Compare-and-set | on write-back, verify "is the value still the one I read?" | common substitute when there's no transaction; beware ABA & snapshot-read misfires |
Transactions are a high-frequency danger zone in backend and database interviews, and a common root cause of production incidents. Grasp this chapter and you can answer a string of real questions: why does your database default to Read Committed rather than serializable? Why does concurrent stock/balance deduction oversell (lost update)? Why can two people book the same meeting room (write skew)? Should you use SELECT FOR UPDATE, an atomic increment, or crank the isolation level to SERIALIZABLE? — the coordinate system for all these decisions is in this chapter.
The most important lesson: don't trust the name, look at what it actually blocks: Oracle's SERIALIZABLE is really just snapshot isolation (doesn't block write skew), and vendors' "Repeatable Read" behaviors differ. In real systems there is both validation and hard-won failure:
SERIALIZABLE is really snapshot isolation and can't block write skew; "Repeatable Read" is vaguely defined in the SQL standard and implemented differently by each vendor — judge by which anomalies it blocks, not what it's called.① In one line: a transaction packs a group of reads/writes into an "all-or-nothing" unit, shielding the app from two kinds of chaos — concurrency and crashes; it's a costly engineering simplification, not a law of nature.
② ACID: A atomicity = abortability/rollback; C consistency is the app's job (acronym filler); I isolation = concurrency non-interference (most pitfalls); D durability = committed won't be lost.
③ Read Committed (most defaults): blocks dirty reads + dirty writes, but leaks read skew, lost updates, write skew.
④ Snapshot isolation / MVCC: each transaction reads the consistent snapshot of its starting instant, readers and writers never block, blocks read skew — but not write skew.
⑤ Lost update: concurrent "read-modify-write" overwriting each other; fixes are atomic writes, explicit locks, auto-detect, compare-and-set.
⑥ Write skew / phantom (the trap to remember): read the same data, each write a different object, together breaking an invariant (on-call doctors, double-booking, double-spending) — only serializability prevents it.
⑦ Three roads to serializable: actual serial execution (one core, short transactions), two-phase locking 2PL (pessimistic, slow, jittery p99), SSI (optimistic, fastest at low contention).
⑧ Meaning & iron rule: don't trust the isolation level's name, look at which anomalies it truly blocks — Oracle's "serializable" is actually snapshot isolation; weak isolation is the norm, and safety is something you turn on.