Deadlocks in Databases
Imagine two bank transfers running concurrently. Transaction locks account and wants to lock account . Meanwhile, has already locked and wants to lock . Each waits for the other forever. Neither can proceed, yet neither has failed. The system has simply frozen a piece of itself. This is a deadlock, and it is not a bug in any one transaction. It is an emergent property of concurrent locking. Deadlocks matter because Two-Phase Locking (2PL), the standard mechanism for guaranteeing serializable schedules, creates the conditions for deadlock as a side effect of guaranteeing correctness. 2PL forces transactions to acquire locks before use. It also forbids releasing-then-reacquiring. This rigidity is exactly what lets circular waits form. A database that only prevents wrong answers but never resolves frozen ones is not production-ready. Real systems must detect or prevent deadlocks. They must recover automatically, invisibly to the application. This article develops the theory precisely. It covers what a deadlock is, and the necessary conditions for one to form. It also covers three major engineering strategies used in real systems. These are timeout-based crude detection, formal cycle detection via wait-for graphs, and prevention schemes. Prevention schemes use transaction timestamps to forbid cycles from ever forming. We derive the complexity of graph-based detection. We prove why cycle detection is both necessary and sufficient for identifying deadlock. We also walk through a fully worked multi-transaction trace. It shows a cycle forming and being broken. By the end you will be able to construct a wait-for graph from a lock table. You will be able to run cycle detection on it. You will be able to choose a victim using a principled cost function. You will also be able to articulate why livelock is a distinct danger, even after deadlocks are solved. This sets up naturally for Isolation Levels. There, weaker isolation is sometimes chosen precisely to reduce the surface area for deadlock.
- A deadlock is a cycle of transactions each waiting for a lock held by the next; none can ever proceed.
- 2PL guarantees serializability but does not prevent deadlock — the two problems are orthogonal.
- Detection builds a wait-for graph and periodically checks for cycles; a cycle iff deadlock.
- Prevention uses transaction timestamps (wait-die / wound-wait) so a cycle can never form.
- Recovery requires choosing a victim, rolling it back, and releasing its locks — with care to avoid starvation.
- Timeouts are a cheap, imprecise fallback used widely when full graph detection is too costly.
A deadlock is exactly a cycle in the wait-for graph — so detecting deadlock reduces to detecting cycles, and preventing deadlock reduces to making cycles structurally impossible.
The idea
A deadlock is a state in which a set of transactions each wait for a resource (lock) held by another transaction in the set, forming a cycle of waiting with no way out without external intervention. Formally, transaction is blocked waiting for a lock held by ; if this "waits-for" relation contains a cycle , every transaction on the cycle is permanently stuck, since each can only proceed once the next one releases a lock — which will never happen. Deadlock requires four conditions to hold simultaneously, a classical characterization (originally from operating systems, and equally applicable to database locking): 1. Mutual exclusion — a lock can be held by at most one transaction (for exclusive locks). 2. Hold-and-wait — a transaction holds some locks while waiting for others. 3. No preemption — a lock cannot be forcibly taken from a transaction; it must release voluntarily. 4. Circular wait — there exists a cycle of transactions each waiting on the next. Under Two-Phase Locking, conditions 1–3 are essentially unavoidable: exclusive locks are mutual exclusion, growing-phase lock acquisition is hold-and-wait, and locks are only released at commit/abort, never preempted. This means the only condition left to control is circular wait — and that is exactly where both major strategies aim their effort. Deadlock prevention restructures how transactions request locks so that a circular wait can never arise (attacking condition 4 structurally). Deadlock detection lets circular waits happen but periodically checks for them and breaks any that are found (accepting condition 4 but bounding its damage). A crucial distinction from an unrelated pathology is worth stating cleanly: starvation is a transaction perpetually failing to acquire a lock even though no cycle exists — for example, if the scheduler always favors other transactions. Deadlock is a structural graph property (a cycle); starvation is a fairness failure with no cycle required. Both need distinct fixes, and later remedies for one (e.g., always aborting the youngest transaction) can inadvertently create the other if not designed carefully.
How it works
Building the wait-for graph
The central data structure for detection is the wait-for graph : vertices are active transactions, and there is a directed edge if is blocked waiting to acquire a lock currently held by . The graph is maintained incrementally by the lock manager:
- When requests a lock held (incompatibly) by , add edge and block .
- When releases a lock and acquires it, remove edge .
- When a transaction commits or aborts, remove all its edges (incoming and outgoing).
Claim: the system is deadlocked if and only if the wait-for graph contains a directed cycle. Proof sketch. () Suppose a cycle exists. By definition of the edge relation, cannot proceed until releases a lock, cannot proceed until releases a lock, ..., cannot proceed until releases a lock. Under 2PL no transaction releases a lock before it can proceed (locks are released only after the transaction resumes execution and reaches commit/abort). So no transaction on the cycle can ever make progress — this is precisely a deadlock. () Suppose the system is deadlocked: some non-empty set of transactions are permanently blocked. Each blocked transaction has at least one outgoing wait-for edge (it is waiting for some lock held by someone). Follow outgoing edges starting from any : since is finite and every transaction visited is also blocked (blocked transactions can only be waiting on other transactions that are, transitively, part of the stuck set — a transaction holding the needed lock and not blocked would eventually release it), the walk must revisit a transaction, producing a cycle. This iff-characterization is what makes detection exact: no false positives, no false negatives, provided the graph is maintained correctly.
Detection algorithm
``` // Wait-for graph maintained by the lock manager as transactions // request, acquire, and release locks. structure WaitForGraph: nodes: set of active transaction IDs edges: set of (Ti, Tj) meaning "Ti waits for Tj" function ADD_WAIT(Ti, Tj): edges.add((Ti, Tj)) function REMOVE_TRANSACTION(Ti): edges.remove_all_incident_to(Ti) nodes.remove(Ti) // Run periodically (fixed interval) or when a new wait edge is added. function DETECT_DEADLOCK(graph): visited = {} // GRAY = on current DFS stack, BLACK = fully done cycle_found = [] function DFS(u, stack): visited[u] = GRAY stack.push(u) for each edge (u, v) in graph.edges: if visited[v] == GRAY: // v is an ancestor on the current path -> cycle idx = stack.index_of(v) cycle_found = stack[idx:] // extract the cycle return true if visited[v] is undefined: if DFS(v, stack): return true stack.pop() visited[u] = BLACK return false for each node u in graph.nodes: if visited[u] is undefined: if DFS(u, []): return cycle_found // a deadlock cycle return NONE // no deadlock function RESOLVE(cycle): victim = CHOOSE_VICTIM(cycle) // e.g., youngest transaction, // fewest locks held, least work done ABORT(victim) RELEASE_ALL_LOCKS(victim) REMOVE_TRANSACTION(victim) // update wait-for graph // remaining transactions in the cycle can now proceed ``` This is a standard depth-first-search cycle detector on a directed graph, with the coloring scheme WHITE (unvisited) / GRAY (in progress, on the recursion stack) / BLACK (finished) used to distinguish a back edge (to a GRAY node — a real cycle) from a cross/forward edge (to a BLACK node — no cycle through here). Real systems rarely rerun full detection from scratch on every lock request; instead they run it periodically (every few hundred milliseconds) or incrementally re-check only the portion of the graph reachable from the transaction that just got blocked, since a new edge is the only way a new cycle can appear.
Prevention: wait-die and wound-wait
An alternative to detection is to prevent cycles from forming at all, using a global order — the transaction's timestamp (assigned at start, monotonically increasing, so older transactions have smaller timestamps). When requests a lock held by , one of two symmetric rules is applied:
Wait-die (non-preemptive — older transactions wait, never preempt): ``` if timestamp(Ti) < timestamp(Tj): // Ti is older Ti waits for Tj else: // Ti is younger Ti dies (aborts), restarts later with the SAME timestamp ```
Wound-wait (preemptive — older transactions preempt younger ones): ``` if timestamp(Ti) < timestamp(Tj): // Ti is older Ti wounds Tj: Tj is aborted (unless Tj already committing), Ti proceeds else: // Ti is younger Ti waits for Tj ``` Both schemes guarantee no deadlock. The key invariant: in wait-die, waiting always flows from younger timestamps waiting for effectively-younger — actually the invariant is that along any wait-for edge, timestamps strictly decrease in the direction of waiting (older waits for younger is impossible; the older one never waits, it kills). In wound-wait, along any wait-for edge, timestamps strictly increase (younger waits for older, and older transactions never wait — they wound). Either way, wait-for edges are consistent with a strict total order (timestamps, which are unique and never reused for a restarted transaction retaining its original stamp), so no cycle can form — a cycle would require timestamps to both increase and decrease around the loop, a contradiction. Both schemes require a restarted transaction to keep its original timestamp, which guarantees that a transaction restarted enough times eventually becomes the oldest in the system and stops being aborted — this is what prevents starvation, a subtlety many descriptions omit.
Complexity & correctness
Detection (DFS cycle check). Let = number of active transactions (nodes) and = number of outstanding wait-for edges. Standard DFS visits every node once and every edge once:
Space is for the adjacency representation plus for the color array and recursion stack. Since in practice each transaction waits for at most a handful of locks at a time (bounded by concurrency degree), in typical workloads, making detection effectively linear in the number of active transactions. Running detection on every single lock request would cost each time, which is wasteful; production systems amortize this by running detection on a timer (e.g., every 1–5 seconds) or only triggering a check when a new wait edge is added (since only new edges can create new cycles, you only need to search for a cycle reachable from the newly blocked transaction, not re-scan the whole graph) — this localized recheck costs in the worst case but is typically far cheaper.
Correctness of detection. Already proven above: cycle in wait-for graph deadlock. This is airtight provided the graph reflects the true, current locking state — a real engineering subtlety, since distributed systems must be careful about propagation delay (a phantom deadlock: a cycle appears to exist based on stale information, but has already resolved itself). Centralized, single-node lock managers avoid this entirely since the graph is updated atomically with the lock table under the same mutex.
Prevention (wait-die / wound-wait). No graph is maintained at all — checking a rule against two timestamps is per lock conflict. The tradeoff is more aborts than strictly necessary: prevention schemes may abort a transaction even in situations where, had the request simply been queued, no cycle would ever have formed (Prevention is conservative: it forbids cycles by forbidding some non-cycles too). Detection is more permissive (it allows waiting until a cycle would actually occur) but pays the cost of graph maintenance and periodic scanning, plus the latency of only discovering the problem after it has already happened (transactions sit blocked until the next detection pass).
Timeout-based pseudo-detection. The cheapest fallback: abort any transaction that has waited longer than threshold . This costs bookkeeping per lock wait (a timer) but is neither necessary nor sufficient as a deadlock test — a transaction can wait past due to ordinary contention with no cycle (false positive, wasted abort), and a cycle can form and persist for less than before... no, actually a real cycle never resolves on its own, so eventually a true deadlock will trigger a timeout, but only after unnecessary delay, and short-lived high-contention (non-cyclic) waits are frequently misclassified as deadlocks. This is a heuristic traded for simplicity, common in systems where the overhead of a real wait-for graph is judged not worth it.
Victim selection and correctness of recovery. Aborting any transaction on the discovered cycle breaks that cycle (removing one node removes at least one edge from every cycle through it), so correctness of recovery is trivial — the harder engineering question is which victim minimizes wasted work and avoids repeatedly picking the same transaction (which would starve it). Common cost functions: amount of work already done (prefer to abort transactions that just started), number of locks held, number of transactions that would need to be aborted transitively. A well-designed victim-selection policy folds in "age" (like wait-die's timestamp) specifically to guarantee that a transaction is never picked as victim infinitely many times — otherwise deadlock recovery merely converts deadlock into perpetual starvation of one unlucky transaction.
Worked example
Consider three transactions operating on accounts with exclusive locks under strict 2PL.
| Time | Event | Lock table after event | Wait-for graph |
|---|---|---|---|
| locks | |||
| locks | |||
| locks | |||
| requests (held by ) → blocks | same | ||
| requests (held by ) → blocks | same | ||
| requests (held by ) → blocks | same |
At the edge closes the cycle . Running `DETECT_DEADLOCK` (as scheduled, say at ) performs DFS from : ``` DFS(T1): color T1 GRAY, push [T1] edge T1 -> T2: T2 unvisited -> DFS(T2) DFS(T2): color T2 GRAY, push [T1, T2] edge T2 -> T3: T3 unvisited -> DFS(T3) DFS(T3): color T3 GRAY, push [T1, T2, T3] edge T3 -> T1: T1 is GRAY -> BACK EDGE -> cycle found cycle = stack[index_of(T1):] = [T1, T2, T3] return true return true return true return true result: cycle_found = [T1, T2, T3] ``` The scheduler now calls `CHOOSE_VICTIM([T1, T2, T3])`. Suppose has done the least work (just started, holds only lock on , no writes yet applied). is aborted: its lock on is released, and the edge (and ) is removed. now acquires and proceeds to completion, then releases ; acquires and proceeds. The cycle is broken with a single, minimal-cost abort — exactly what the correctness argument guarantees (removing any one node from a cycle destroys that cycle).
Contrast with prevention (wound-wait). Suppose instead timestamps are assigned at start: (lower = older). At , when requests held by : since , is younger, so waits rather than wounding — wait, under wound-wait the requester must be older to wound; here (younger) is the requester and (older) holds the lock, so must wait. But at , (younger, ts 105) requested held by (older is ? No — means is older) — so wounds , aborting immediately at , before the cycle-closing edge at ever has a chance to form. The cycle is prevented structurally, not detected after the fact — illustrating the core tradeoff: prevention pays its cost earlier (an eager abort at ) in exchange for never needing a wait-for graph at all.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Confusing deadlock with starvation | Both look like "a transaction never finishes" | Check for an actual cycle in the wait-for graph; starvation has none |
| Forgetting to remove edges on commit/abort | Stale edges make phantom cycles appear | Atomically update the graph inside the same critical section as the lock table |
| Always aborting the same "cheap" victim | Cost function ignores history, cheapest transaction keeps losing | Factor in age / retry count so a transaction eventually becomes protected |
| Running full detection on every lock request | Naively re-scans every time, huge overhead | Run periodically, or localize the search to edges reachable from the new wait |
| Treating timeout as true detection | A slow-but-non-cyclic wait looks identical to deadlock from a timer's perspective | Use timeouts only as a cheap approximation, with real detection when precision matters |
| Restarting a transaction with a new timestamp in wait-die/wound-wait | Breaks the monotonic-timestamp invariant that prevents starvation | Preserve the original timestamp across restarts |
Pioneers & history
The foundational vocabulary of deadlock — mutual exclusion, hold-and-wait, no preemption, circular wait — originated in operating systems research on resource allocation, then was carried directly into database concurrency control once locking-based schedulers (2PL) became standard. The wait-die and wound-wait schemes were formalized as part of the broader theory of concurrency control developed alongside serializability theory in the 1970s, when database researchers needed provably deadlock-free alternatives to detection for systems where graph maintenance was too expensive.
Origins · 1 minEdward G. Coffman Jr. and how this was discovered
Coffman co-authored the 1971 survey that first crisply stated the four necessary conditions for deadlock (mutual exclusion, hold-and-wait, no preemption, circular wait), giving system designers — first in operating systems, soon after in databases — a precise target: break at least one condition and deadlock becomes impossible. This framing directly underlies both prevention (breaking circular wait via timestamp ordering) and detection (finding circular wait exactly, via graph cycles) as complementary strategies still taught and deployed today.
Where this matters
Every production RDBMS with lock-based concurrency control — PostgreSQL, MySQL/InnoDB, Oracle, Microsoft SQL Server, IBM Db2 — ships a background deadlock detector that periodically scans the internal lock manager's wait graph and aborts a chosen victim, surfacing it to the application as a serialization error the app is expected to retry. Distributed and geo-replicated systems (e.g., distributed SQL databases like CockroachDB or Google Spanner-style systems) must additionally solve the distributed version of detection, where wait-for edges live on different machines and need a coordinator or a token-passing protocol to assemble a global picture without false phantom cycles. Prevention-style timestamp schemes appear in optimistic and hybrid concurrency control designs where the cost of maintaining a live graph is considered too high relative to occasional wasted restarts. Application frameworks that wrap database transactions (ORMs, connection pools) build automatic retry-on-deadlock logic directly into their transaction decorators, because deadlock — unlike a logic bug — is an expected, recoverable event in any sufficiently concurrent locking system, and understanding it precisely is what lets engineers write correct retry loops instead of treating it as a mysterious failure.
Ask the tutor: How would a wait-for graph detector need to change if the database used shared (read) locks in addition to exclusive (write) locks?
Does using Two-Phase Locking guarantee no deadlocks?
No — 2PL guarantees conflict-serializable schedules, a correctness property about the order of operations, but it says nothing about liveness. In fact 2PL's growing/shrinking phase structure (acquire everything before releasing anything) is precisely what enables hold-and-wait, one of the four necessary conditions for deadlock. Deadlock and serializability are orthogonal concerns: you can have serializable schedules that deadlock, and (with prevention schemes) serializable schedules that provably never deadlock. This is why every serious lock-based database needs a separate deadlock-handling layer — either detection or prevention — layered on top of 2PL itself.
Why not just always use deadlock prevention and skip detection entirely?
Prevention (wait-die/wound-wait) trades false positives for simplicity: it aborts transactions in some situations where no deadlock would ever actually have occurred, because it enforces a conservative global rule (timestamp ordering) rather than checking the true, current graph. In workloads with heavy lock contention but few genuine deadlocks, this can cause far more aborts — and therefore more wasted work and retries — than a detection scheme that only aborts when a real cycle is confirmed. Detection is more precise but costs graph maintenance and periodic scanning. Most production systems favor detection for exactly this reason, reserving prevention-style approaches for specialized settings like some distributed or real-time systems where maintaining a global wait-for graph is impractical.
How often should a real system run the deadlock detection scan?
There is a genuine tradeoff. Scanning too frequently wastes CPU cycling over a graph that rarely has changed meaningfully, especially since each scan costs . Scanning too rarely lets deadlocked transactions sit blocked for longer than necessary, hurting latency and holding locks that block still more transactions. Common designs use a fixed interval (on the order of one second) combined with a triggered check whenever a new wait edge is added and the waiting transaction has been blocked past a short threshold — this hybrid catches most deadlocks quickly while avoiding constant full rescans under light contention.
What is a phantom deadlock and why is it a distributed-systems problem specifically?
A phantom deadlock is a cycle that appears to exist in a coordinator's assembled wait-for graph but has already been broken in reality, because the messages reporting local wait edges from different sites arrived with different delays. A single-machine lock manager avoids this entirely, since it updates the lock table and the wait-for graph under the same lock, atomically and instantaneously. In a distributed database, though, each site only knows its own local waits, and stitching together a global graph inherently involves network latency, so the coordinator's view can be stale by the time it decides to abort a victim — wasting work for a deadlock that no longer exists.
Can two transactions deadlock over a single lock, or do you always need a cycle of at least two distinct locks?
You always need at least two distinct resources (locks) and at least two transactions for a true deadlock — a cycle requires at least two nodes and at least two edges pointing in a loop. A transaction waiting on one lock it does not yet hold is just ordinary blocking, not deadlock, since the holder will eventually finish and release it (assuming no cycle back). The minimal deadlock is two transactions and two locks: holds lock and wants ; holds and wants . Larger deadlocks (three, four, or more transactions in a ring) follow the identical structural logic — the cycle-length is irrelevant to whether it counts as deadlock.
How does the choice of victim affect correctness versus performance?
Correctness is unaffected by which victim is chosen: aborting any single transaction on a detected cycle removes at least one edge from every cycle passing through it, so the deadlock is guaranteed to be broken regardless of the pick. What varies is efficiency and fairness. A poor policy (e.g., always aborting whichever transaction happens to be discovered first by DFS) can repeatedly punish the same transaction, an unwanted echo of starvation layered on top of solved deadlock. Good policies weigh work already completed, locks held, and prior abort history, aiming to minimize wasted work while guaranteeing every transaction eventually succeeds.
Does deadlock ever occur under optimistic concurrency control or multiversion schemes instead of locking?
Classical lock-wait deadlock, as described here, requires transactions to physically block waiting for a lock — so pure optimistic concurrency control (validate-at-commit, no blocking) and pure multiversion concurrency control (readers never block writers) largely sidestep it by construction, since there is no hold-and-wait on locks. However, most real systems are hybrids: they use MVCC for reads but still take locks for writes, or use short-lived latches internally, so lock-wait deadlocks can still arise on the write path even in an MVCC-based database. This is one motivation for exploring Isolation Levels and weaker-than-serializable guarantees, which reduce how much locking (and thus how much deadlock exposure) a workload needs.