Locking, 2PL, Isolation & Deadlock
The pessimistic answer to concurrency: locks, the two-phase rule that makes them sufficient, strictness that makes aborts safe, the SQL isolation levels built from them, and what to do when transactions wait for each other forever — including Obermarck's distributed detection and hierarchical intention locks.
01 · Mechanism
Locks and the conflict table
The most common method in commercial systems, and the pessimistic one: assume collisions will happen, and control access to resources so they cannot.
A transaction is well-formed with respect to locking when every read is preceded by an
r_lock (a shared lock) and every write by a w_lock (an exclusive lock), each eventually
followed by an unlock. Unlocking may be delayed well past the end of the operation itself — and the
whole of this chapter turns on how long it is delayed.
A transaction that reads an object and later writes it has two options: acquire the exclusive lock straight away at read time, or take a shared lock and later upgrade it — lock escalation.
An object is therefore in one of three states — free, r-locked (by one or more readers), or w-locked (by exactly one writer) — and the lock manager grants requests from this table:
| Request | free | r-locked | w-locked |
|---|---|---|---|
r_lock | OK → r-locked | OK → r-locked () | NO → w-locked |
w_lock | OK → w-locked | NO → r-locked | NO → w-locked |
unlock | ERROR | OK → depends () | OK → free |
where counts concurrent readers, incremented on each r_lock and decremented on each unlock;
the object becomes free only when it reaches zero. Implementations use a hash-indexed lock table:
each locked item has a linked list of nodes recording the requesting transaction, the lock mode, and
whether it is granted or waiting.
Now the crucial negative result. Consider the arrival sequence r1(x) r2(x) w2(x) r1(x), executed by
a scheduler that obeys the table above:
r1(x) → r_lock granted, n=1; T1 unlock(x) → free
r2(x) → r_lock granted, n=1
w2(x) → upgrade to w_lock granted
T2 unlock(x) → free
r1(x) → r_lock granted againEvery request was legal. Yet T1 read x twice and got two different values — a nonrepeatable
read. Respecting the lock-granting table is not sufficient for serializability.
What went wrong, precisely
T1 did two things: it released its read lock too early, and then it acquired another lock after having released one. Forbidding the second of those is the entire content of the next section.
02 · Protocol
Two-phase locking
One rule, and the class of schedules it generates is serializable.
The two-phase rule
A transaction cannot acquire any lock after releasing a lock.
Every transaction therefore has a growing phase in which it only acquires, a plateau at its maximum lock set, and a shrinking phase in which it only releases. Consider a scheduler that processes only well-formed transactions, grants locks by the conflict table, and checks the two-phase rule: the class of schedules it produces is called 2PL, and
Result
Schedules in 2PL are both view- and conflict-serializable: 2PL ⊂ CSR ⊂ VSR.
The proof is short and worth reproducing, because papers ask you to justify membership rather than assert it.
Deep dive Why 2PL implies CSR, and why the inclusion is strict
2PL ⇒ CSR. Suppose a 2PL schedule were not conflict-serializable. Its conflict graph would contain a cycle, say , which requires a pair of conflicting operations in reverse order — some earlier and some later leading back to . For to access after , must have released a lock. For the conflict on to occur later in the other direction, must acquire a lock afterwards. That is exactly what the two-phase rule forbids — contradiction. Inclusion in VSR then follows from CSR ⊂ VSR.
CSR ⊋ 2PL. The counter-example is r1(x) w1(x) r2(x) w2(x) r3(y) w1(y). It is conflict-
serializable with order — on x the operations run r1 w1 r2 w2, on y they run
r3 w1, and the graph is acyclic. But it violates 2PL: T1 must release its lock on x for T2 to
proceed at position 3, and must then acquire a lock on y at position 6. Release then acquire —
not two-phase.
So locking is a sufficient discipline, not a characterisation: it rejects some perfectly correct schedules in exchange for being enforceable online, one request at a time.
The lectures also give a visual test that is faster than reasoning in prose: draw resources on the vertical axis and operation times on the horizontal, mark each transaction’s first acquisition and last release per resource, and look for any transaction whose acquisitions are not all before its releases.
Which anomalies does plain 2PL stop? All of them except one:
| Anomaly | Blocked by 2PL? | Why |
|---|---|---|
| Nonrepeatable read | ✅ | T1 would have to release then re-acquire |
| Lost update | ✅ | same |
| Phantom update | ✅ | same |
| Phantom insert | ⚠️ partly | needs locks on future data — see predicate locks |
| Dirty read | ❌ | requires reasoning about aborts, which 2PL says nothing about |
03 · Protocol
Strict 2PL, and why write locks are held to commit
Everything so far assumed commit projection — that no transaction aborts. Drop that assumption and 2PL is not enough.
If a transaction releases its locks before it commits, another transaction can read what it wrote; should the first then roll back, the second has performed a dirty read. The fix adds one clause:
Strict 2PL
Locks held by a transaction may be released only after commit or rollback.
Strict-2PL locks are called long-duration locks; plain 2PL locks are short-duration. Most commercial DBMSs use strict 2PL when a high isolation level is requested. Real systems typically apply it asymmetrically — long-duration strict locks for writes, more varied policies for reads, because long-duration read locks are expensive and are often replaced by the multiversion mechanisms of chapter 7.
Why writes in particular can never be short-duration is worth deriving, since a paper asks it outright:
Deep dive Dirty write — why long-duration write locks are non-negotiable
Suppose write locks could be released early, and consider
w1[x] … w2[x] … then (c1 or a1) and (c2 or a2) in any orderT2 has overwritten an object that T1 has not yet finished with. Now let T1 abort. How should the
system process a1?
- If
xis restored to its pre-T1 state, T2’s update is silently destroyed — and if T2 then commits,xholds a stale value that no serial execution produces. - If
xis not restored, and T2 subsequently aborts too, then T2’s own before-state — which was T1’s uncommitted value — cannot be reinstalled either. Neither rollback can be made correct.
The anomaly is called a dirty write, and the only way out is to prevent the interleaving: write locks are held until the transaction completes, so no second writer can ever get in. This is the answer to February 2025’s third sub-question, and note that it is an argument about abort correctness, not about serializability — plain 2PL already serializes.
04 · SQL
Predicate locks and the SQL isolation levels
Phantom inserts need locks on rows that do not exist yet; and SQL exposes the whole spectrum of lock-duration choices as four named levels.
A phantom insert happens when a transaction adds items to a set another transaction has already read.
No data lock can prevent it, because the row is not there to lock. A predicate lock extends
locking to future data: for update Tab set B=1 where A>1, the lock is on the predicate A > 1,
and no other transaction may insert, delete or update any tuple satisfying it. Implementations use
index structures (gap locks); where predicate locks are unsupported, the lock degenerates to the
whole table.
SQL:1999 defines four isolation levels, specifying which anomalies must be prevented. Note what they do not touch: a transaction always takes an exclusive lock on anything it modifies and holds it to completion — strict 2PL on writes — regardless of level. The levels only vary the protection of reads.
| Level | Dirty read | Nonrepeatable read | Phantoms |
|---|---|---|---|
READ UNCOMMITTED | possible | possible | possible |
READ COMMITTED | prevented | possible | possible |
REPEATABLE READ | prevented | prevented | insert only |
SERIALIZABLE | prevented | prevented | prevented |
And the same table read as lock durations — the version worth memorising, because it explains why:
| Level | Read locks | Write locks |
|---|---|---|
READ UNCOMMITTED | not required (ignores others’ locks too) | long duration |
READ COMMITTED | short duration, data and predicate | long duration |
REPEATABLE READ | long duration on data, short on predicate | long duration |
SERIALIZABLE | long duration on data and predicate | long duration |
Read down the read-lock column and the anomaly table falls out: no read locks admits dirty reads; short read locks mean a value can change between two reads; long data locks fix that but leave predicates unguarded, so inserts still slip in; long predicate locks close the last gap.
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION READ ONLY;SERIALIZABLE does not mean serial
Transactions at SERIALIZABLE do not execute one at a time. The requirement is only
that they may commit if the result would be as if they had executed serially in some
order. The locking needed to guarantee that frequently produces deadlocks, one of the transactions
gets rolled back, and for that reason SERIALIZABLE is used sparingly and is
not the default in most commercial systems. Note also that the standard states
minimum requirements: MySQL and PostgreSQL both prevent phantom inserts at
REPEATABLE READ, exceeding the standard.
05 · Deadlock
Deadlock: timeout, prevention, detection
Waiting is the price of locking, and waiting can become circular.
Transactions requesting a held lock are suspended and queued. Two pathologies follow:
- Deadlock — two or more transactions in endless mutual wait, each holding what another needs.
- Starvation — a single transaction waiting forever, typically a writer behind an endless stream of readers on a hot object such as an index root.
The canonical deadlock takes four operations:
T1: r1(x) w1(y) S: r_lock1(x), r_lock2(y), r1(x), r2(y),
T2: r2(y) w2(x) w_lock1(y) → wait, w_lock2(x) → waitTwo representations: the lock graph is bipartite, with resources and transactions as nodes and arcs for requests and assignments; the wait-for graph keeps only transactions, with an arc when waits for something holds. A deadlock is a cycle in the wait-for graph.
Three families of resolution:
1 · Timeout
Kill and restart a transaction after a fixed wait. Simplest, widely used historically. The whole difficulty is choosing the value: too long wastes time on real deadlocks, too short kills transactions that were merely slow.
2 · Prevention
Kill transactions that could deadlock. Resource-based: request everything at once, or request resources in a global order — hard, because transactions rarely know their requests in advance. Transaction-based: use ids as ages (see below). Problem: many needless kills, since waiting is far more likely than deadlock.
3 · Detection
Kill only transactions that are deadlocked, by finding cycles in the wait-for graph. Needs a real algorithm, especially when resources are distributed — Obermarck’s, next section.
The two transaction-based prevention schemes are exam vocabulary. Both compare the age of the requesting transaction (RT) with that of the conflicting holder (CT), where a lower timestamp means older, and in both the oldest transaction always survives:
| Scheme | RT older than CT | RT younger than CT | Style |
|---|---|---|---|
| Wait-die | RT waits | RT dies | non-preemptive |
| Wound-wait | RT wounds CT | RT waits | preemptive |
A killed transaction restarts with its original timestamp, so it becomes progressively older and cannot starve.
How likely is any of this? With records and transactions making two accesses each under a uniform distribution, conflict probability is and deadlock probability — much rarer, but real: the lectures quote once a minute in a mid-sized bank. The probability is linear in the number of transactions and quadratic in their length, which is the practical argument for keeping transactions short.
06 · Deadlock
Obermarck’s distributed detection
When a transaction spans nodes, no single node can see the whole wait-for graph. Obermarck’s algorithm detects the cycle anyway, by having each node forward summaries of what it knows.
The setting: transactions run on one main node but may spawn sub-transactions elsewhere, suspending until the sub-transaction returns. So there are two kinds of waiting — waits for on the same node because of a data lock, and a sub-transaction of waits for another sub-transaction of on a different node, via an external call.
Each node records its local situation as strings of the form
E_B → T2 → T1 → E_Cread as: a remote transaction at node B is waited for by , which waits for (a local data lock), which in turn waits for something at node C. The markers are external-call nodes.
The forwarding rule decides who tells whom, and it exists to stop every node from independently detecting the same deadlock:
Obermarck's forwarding rule
Node A sends its string to node B only when A contains a transaction that is waited for by a remote transaction and that waits for a transaction active on B — and .
Mnemonically: I send my information to you if a distributed transaction listed at me waits for a distributed transaction listed at you with a smaller index.
The algorithm runs periodically at each node, in four steps: receive graph information from the preceding nodes; merge it into the local graph; check for cycles among transactions and, if one is found, select a transaction in it and kill it; send updated information onward, propagating news of any kill.
Obermarck has opened the February session every year since 2024
Distributed deadlock detection appears in 3 of the 14 papers — 2024-02-12, 2025-02-05 and 2026-02-13 — and those are every February session in the bank, three for three. It appears in no other month. The ask is always the same: run the algorithm under both conventions ( and ) and report every message sent at every iteration. February 2026 adds the question of whether the convention changes the number of steps; it does — 2 iterations versus 4 on the same input, detecting different cycles of the same global deadlock. Both are correct.
Three ways the message trace goes wrong
Comparing the wrong indices. The test uses the first and last transaction of the node’s distributed string; purely local transactions never enter the comparison.
Forgetting to re-evaluate after a merge. A node that could not transmit initially very often can once it has received a string — that is how the chain propagates.
Reporting the whole path. The message carries the summarized first-to-last dependency, not every intermediate hop.
07 · Refinements
The update lock
One deadlock pattern is far more common than all others, and one extra lock mode removes it.
The pattern is two transactions that both read an object and then both decide to write it:
r1(x) r2(x) w1(x) w2(x)
with SL/XL only: SL1 granted, SL2 granted, XL1 → T1 waits, XL2 → T2 waits. Deadlock.Each holds a shared lock the other’s upgrade is waiting on. The update lock (UL) is requested by a transaction that intends to read and later write. Its compatibility is asymmetric — it tolerates existing readers, but no second UL:
| Request | free | SL | UL | XL |
|---|---|---|---|---|
| SL | OK | OK | OK | No |
| UL | OK | OK | No | No |
| XL | OK | No | No | No |
Replay the pattern with it: UL1 granted, UL2 waits immediately, XL1 granted — T1 finishes
and T2 proceeds. No deadlock, and the cost is only that T2 waited a little earlier than it strictly
had to. Update locks are requested through SELECT … FOR UPDATE:
START TRANSACTION;
SELECT * FROM t WHERE i = 2 FOR UPDATE; -- rows write-locked until commit
SELECT * FROM t WHERE i = 2 FOR UPDATE NOWAIT; -- error instead of waiting
SELECT * FROM t FOR UPDATE SKIP LOCKED; -- skip rows locked by othersNOWAIT executes a command only if it does not run into a conflict; SKIP LOCKED returns only the
unclaimed rows — the standard trick for queue tables, where avoiding conflict matters more than
seeing every row.
08 · Refinements
Hierarchical (intention) locking
Update locks prudently extend the interval a resource is locked. Hierarchical locking answers the other question: locking what?
Locking a whole table destroys concurrency; locking single tuples means an enormous number of locks and slow conflict detection. So resources form a hierarchy — schema, table, fragment, page, tuple, field — and the objectives are to lock the minimum amount of data while recognising conflicts as early as possible.
The protocol has two halves:
- Requests go top-down, from the root toward the granule you actually want.
- Releases go bottom-up.
Beyond SL and XL, three modes declare intent at the coarser levels:
- ISL — intention to lock a sub-element in shared mode.
- IXL — intention to lock a sub-element in exclusive mode.
- SIXL — lock this element in shared mode and intend to lock a sub-element exclusively
(
SL+IXL).
The rules for descending: to request SL or ISL on a non-root element you must already hold ISL
or IXL on its parent; to request IXL, XL or SIXL you must already hold SIXL or IXL on the
parent. And the granting table:
| Request | free | ISL | IXL | SL | SIXL | XL |
|---|---|---|---|---|---|---|
| ISL | OK | OK | OK | OK | OK | No |
| IXL | OK | OK | OK | No | No | No |
| SL | OK | OK | No | OK | No | No |
| SIXL | OK | OK | No | No | No | No |
| XL | OK | No | No | No | No | No |
Reading it pays off: ISL conflicts only with XL, so many readers can descend in parallel; XL
conflicts with everything, so an exclusive lock high in the tree blocks all traffic beneath it.
The setup
Hierarchy X(Y(A,B), Z(S,T)). Arrival sequence r1(S) w1(A) w2(Z) r2(A) r3(X) w1(Y) w3(X) w1(B). Locks are released after each transaction commits, which happens immediately after its
last operation. A waiting transaction does not proceed with its later operations.
r1(S) — descend for a read
T1 takes ISL on X (root), ISL on Z, then SL on S. Top-down, one level at a time.
w1(A) — descend for a write
T1 needs exclusive access below Y, so it upgrades its ISL on X to IXL, takes IXL on Y,
then XL on A.
w2(Z) — the first conflict
T2 takes IXL on X (compatible with T1’s IXL), then requests XL on Z — which T1 holds ISL
on. The table says No. T2 waits, and therefore its next operation r2(A) is suspended too.
r3(X) — the second conflict
T3 requests SL on the root. Two IXLs are held there, and SL versus IXL is No. T3
waits, suspending its w3(X) as well.
w1(Y), w1(B) — T1 finishes
T1 upgrades IXL on Y to XL; B sits beneath a node it now holds exclusively. T1 commits and
releases everything bottom-up.
The queue drains
T2’s XL on Z is now granted, r2(A) proceeds (ISL on Y, SL on A), T2 commits. Then T3’s
SL on X is granted, upgraded to XL for w3(X), and T3 commits. The post-commit replay is
part of the answer — an answer that stops at the deadlock-free conflict is incomplete.
Hierarchical locking is rising
It is absent from every paper before mid-2025, then appears in 2025-06-04 and 2026-07-02 — two of the last five sessions, both summer sittings. The deliverable is a
step-by-step table with one row per operation or commit and one column per resource
, showing every lock state including the waits. Given the trend, treat it as likely rather than exotic.
In hierarchy X(Y(A,B)), transaction T1 holds ISL on X and SL on Y. T2 now wants to write tuple A. What happens?
Load-bearing ideas
- Locks alone are not enough. Obeying the conflict table still permits nonrepeatable reads; the missing ingredient is when you may release.
- Two-phase rule: no lock acquired after any lock released. 2PL ⊂ CSR ⊂ VSR, strictly — the
counter-example
r1(x) w1(x) r2(x) w2(x) r3(y) w1(y)is CSR but not 2PL. - Strict 2PL holds locks to commit/rollback, which is what blocks dirty reads — and long-duration write locks are mandatory at every isolation level, because otherwise abort processing itself becomes impossible (dirty write).
- Four isolation levels = four read-lock durations. No read locks → dirty reads; short → nonrepeatable; long on data → phantom inserts remain; long on data + predicate → serializable.
- Deadlock = a cycle in the wait-for graph. Timeout, prevention (wait-die non-preemptive, wound-wait preemptive — the older transaction always survives, restart keeps the timestamp), or detection. Deadlock probability is quadratic in transaction length: keep transactions short.
- Obermarck forwards a node’s summarized string when (or — both conventions work, in different numbers of steps), merges on receipt, and looks for a cycle.
- Update lock removes the
r1 r2 w1 w2upgrade deadlock;SELECT … FOR UPDATE. - Hierarchical locking: request top-down, release bottom-up, with ISL/IXL/SIXL declaring intent.
- Exam radar. Re-derive before the exam: the 2PL refutation (name the release and the later acquisition); the Strict 2PL refutation (a transaction forced to release before its own commit point); the Obermarck message trace under both conventions; and the hierarchical state table including the post-commit replay.