Concurrency Control: Schedules and Serializability
Interleave four operations from two transactions and €20 disappears; the engine's job is to allow only interleavings whose result equals some serial order, and the two ways to do that — refuse conflicting steps (locking) or keep every version and check afterwards (multi-version / optimistic) — are the roots of every isolation mechanism.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
Two sessions run at once: A reads balance 100 and writes 80; B reads 100 and writes 70. Run one after the other the result is 50. Interleaved as read, read, write, write the result is 70 and A's €20 debit is gone — with no error anywhere.
↓ - Naive solution
Run transactions one at a time. A global lock at BEGIN, released at COMMIT. Every result is trivially correct.
↓ - Why it breaks
One slow report freezes every checkout. A commit that waits on an fsync holds the whole database for milliseconds while thousands of cores idle. Throughput equals the throughput of one session.
↓ - Better idea
Most interleavings are harmless — two transactions touching different rows can overlap freely. Only conflicting operations (same item, at least one a write) need ordering. Allow any interleaving whose conflicts all point the same way as some serial order.
↓ - Internal mechanism
Either block a conflicting step until the other transaction finishes (two-phase locking), or let both proceed on separate versions and detect at commit whether the conflicts still form a consistent order (multi-version, optimistic). Both enforce conflict-serializability; they differ in when they pay.
↓ - Trade-offs
Locking pays in waiting and deadlocks and is exact. Optimistic pays in aborted work and retries and is cheap when conflicts are rare. Neither is free, which is why production engines combine them.
↓ - Real database
PostgreSQL: MVCC for reads, row locks for writes, dependency tracking (SSI) for SERIALIZABLE. InnoDB: MVCC for consistent reads, two-phase locking with next-key locks for everything that writes.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
Two correct programs run at the same time can produce a wrong answer, because each read a value that the other was about to change. Serial execution is always correct; concurrent execution is only correct when it is *equivalent* to a serial one. Concurrency control is the set of rules that admits the equivalent interleavings and rejects or delays the rest.
What happened?
Account 7 holds 100. Transaction A wants to debit 20, transaction B wants to debit 30. Each does the obvious thing: read the balance, subtract, write the result. The engine runs them interleaved.
Final balance: 70. Correct balance: 50. Nothing failed, nothing was rejected, no lock was requested. A's write was simply overwritten by a value computed from a stale read. This is the lost update, and every concurrency control mechanism exists to make this schedule either impossible or detectable.
time TX A TX B balance on disk 1 READ balance -> 100 100 2 READ balance -> 100 100 3 WRITE balance := 80 80 4 WRITE balance := 70 70 <- A's -20 is gone 5 COMMIT 6 COMMIT serial A then B: 100 -> 80 -> 50 serial B then A: 100 -> 70 -> 50 this schedule: 70 (equivalent to neither)
Schedules and conflicts
A schedule is the global order in which the operations of concurrent transactions actually executed. A serial schedule runs each transaction to completion before the next starts; serial schedules are correct by definition, whatever the transactions do. Two operations conflict when they come from different transactions, touch the same item, and at least one is a write: read–write, write–read and write–write pairs. Read–read pairs never conflict.
Swapping two adjacent non-conflicting operations cannot change any result, since neither could observe the other. A schedule is conflict-serializable if a sequence of such swaps turns it into a serial schedule. The test is mechanical: draw a node per transaction and an edge Ti → Tj for every conflict where Ti's operation came first. If the graph is acyclic, a topological order of it is a serial schedule the interleaving is equivalent to. If it has a cycle, no serial order matches. In the schedule above, B's read precedes A's write (B → A) and A's write precedes B's write (A → B): a cycle of length two.
conflicts:
2. B: READ(bal) before 3. A: WRITE(bal) -> edge B -> A (rw)
1. A: READ(bal) before 4. B: WRITE(bal) -> edge A -> B (rw)
3. A: WRITE(bal) before 4. B: WRITE(bal) -> edge A -> B (ww)
A <----- B
A -----> B cycle: not conflict-serializableThe anomalies as schedules
Every anomaly in the practical lesson is a particular shape of conflicting schedule. Seeing them as schedules explains why they need different mechanisms: some are visible on a single item, and some only exist at the level of predicates or of pairs of items.
lost update rA(x) rB(x) wA(x) wB(x) cA cB two writers, both from a stale read
dirty read wA(x) rB(x) aA cB B used a value that never committed
non-repeatable read rA(x) wB(x) cB rA(x) cA same row, two answers inside A
phantom rA(P) wB(y in P) cB rA(P) cA P is a predicate; y did not exist to lock
write skew rA(x) rA(y) rB(x) rB(y) wA(x) wB(y) cA cB
each read both, each wrote a different one; no ww conflict at allFamily one: pessimistic (locking)
Prevent the conflict from forming. Before reading, take a shared lock on the item; before writing, an exclusive one; a conflicting request waits. Two-phase locking (2PL) adds the rule that makes this correct: a transaction acquires all its locks before it releases any (a growing phase, then a shrinking phase). Under 2PL every admitted schedule is conflict-serializable, because the point where a transaction holds its maximum set of locks orders it with respect to every transaction it conflicts with. Strict 2PL holds locks until commit, which additionally rules out dirty reads and cascading aborts, and is what real engines do.
In the lost-update schedule, A's read would take a shared lock; B's read a second shared lock; A's write would need to upgrade to exclusive and would wait for B; B's write would wait for A — a deadlock, which is detected and broken. The anomaly turns into an abort, which is the correct outcome. Locking is exact, and it pays in waiting and deadlocks; The Lock Manager shows the structure that grants the locks and Deadlock Detection: The Waits-For Graph the one that breaks the cycles.
Family two: optimistic and multi-version
Optimistic concurrency control (Kung and Robinson, 1981) assumes conflicts are rare. A transaction runs in three phases: *read* — execute freely against the database, buffering writes privately and recording the read set; *validate* — at commit, check that no transaction that committed since this one started wrote anything in its read set; *write* — install the buffered writes. Fail validation and the transaction restarts. No waiting ever, no deadlocks ever; the cost is wasted work under contention.
Multi-version concurrency control takes the reader side of that idea and makes it structural: never overwrite, keep every version, and give each transaction a snapshot that selects the versions it may see. Readers need no locks and no validation, because the versions they read cannot change. Writers still conflict, and engines handle that either with row locks (a write waits for a concurrent writer of the same row and then re-checks, or aborts) or with validation at commit. The result is a hybrid: pessimistic for write–write conflicts, optimistic for everything else. MVCC Internals: Version Chains and Snapshots derives the version chain and the visibility rule.
| Pessimistic (2PL) | Optimistic / multi-version | |
|---|---|---|
| When conflicts are handled | before the operation (wait) | at commit (validate) or never for readers |
| Readers block writers | yes | no |
| Writers block readers | yes | no |
| Deadlocks | possible | impossible (pure OCC); write locks reintroduce them |
| Wasted work | none; time is spent waiting | aborted transactions are redone |
| Best fit | high contention, expensive retries | low contention, read-heavy |
Why engines mix them
A workload is typically nine reads for every write. Giving reads the multi-version path removes them from the lock manager entirely: no lock table entries, no waiting, no deadlock participation. Writes are rarer and benefit from the exactness of locks — a row lock held to commit means a write–write conflict resolves in a definite order and the loser sees the winner's result rather than restarting blindly. Full serializability, which needs read–write conflicts tracked too, is the rare requirement, so it gets a separate mechanism that only those transactions pay for.
PostgreSQL: snapshot reads, row locks for writers, SSI dependency tracking at SERIALIZABLE. InnoDB: snapshot (read view) reads, next-key locks for writers and locking reads, SELECTs promoted to locking reads at SERIALIZABLE. Oracle: snapshot reads, row locks, and a SERIALIZABLE that is really snapshot isolation. The names of the levels are the same; the mechanisms — and therefore the anomalies that slip through — are not, which is the subject of Isolation Levels: The Mechanism Behind Each.
Key points
- A schedule is the interleaving that actually ran. It is correct when equivalent to some serial order.
- Only conflicts (same item, different transactions, at least one write) matter; conflict-serializable means the precedence graph is acyclic.
- Every named anomaly is a schedule shape: lost update and non-repeatable read on one item, phantom on a predicate, write skew on two items with no write–write conflict.
- Two-phase locking prevents bad schedules by waiting; optimistic and multi-version control admit them and validate, or keep versions so readers never conflict.
- Real engines run MVCC for reads and locks for writes, adding dependency tracking or locking reads only for SERIALIZABLE.
Interleave two transactions
| t | TX A | TX B |
|---|---|---|
| 1 | SELECT balance -- 100 | |
| 2 | SELECT balance -- 100 | |
| 3 | UPDATE balance = 100 − 20 = 80 | |
| 4 | COMMIT | |
| 5 | UPDATE balance = 100 − 30 = 70 | |
| 6 | COMMIT |
A schedule is conflict-serializable iff its precedence graph is acyclic — the same cycle detection you run on any directed graph. With only two nodes a cycle means both edges exist; the deadlock detector two lessons on uses the same test on a waits-for graph.
When to use — and when not
- Pessimistic control fits when conflicts are frequent and a retry is expensive or user-visible: balances, inventory, seat assignment.
- Optimistic and multi-version control fit read-heavy workloads where a reader must never wait for a writer and conflicts are rare.
- Pure optimistic control does not fit hot rows under heavy contention — abort rates climb until nothing commits.
- Pure locking does not fit workloads dominated by long reads: every report would block every writer.
Failure modes
- Reasoning about correctness per statement instead of per schedule — the statements were fine; the interleaving was not.
- Expecting snapshot isolation to reject write skew: there is no write–write conflict for it to see.
- Choosing optimistic control for a hot counter and discovering the retry loop is the workload.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- Operating SystemsRace condition on a shared counter → Lost update between two transactionsThe same read-modify-write race, one level up: the "counter" is a row and the "critical section" must survive across statements and crashes.
- DSACycle in a directed graph → Non-serializable schedule (precedence graph has a cycle)Conflict-serializability is exactly acyclicity of the precedence graph.