skip to content

Your transaction takes a lock on every row a range query returns, yet re-running that same range query still returns extra rows. Explain why row-level locking cannot prevent phantoms, and what locking mechanisms do.

level: middleimportance: must knowfreq 52%

answer

  1. Locks need an identity; phantoms have none yet
  2. Predicate lock = correct, undecidable in general
  3. Gap lock + record lock = next-key lock
  4. Locked range follows the chosen index
  5. No index → scan range locked → near table lock

basics

~20 s

Row locks only cover rows that exist; a phantom is a row inserted into the gap between them. Prevention needs predicate locking, approximated in practice by index range locks — gap locks and next-key locks that lock the empty key space too.

solid answer

~60 s

Row locks are locks on *identities*. A phantom has no identity when you take the lock, so it slips into the unlocked gaps between the rows you did lock. The theoretically correct fix is **predicate locking**: lock the logical condition `status='PENDING'`, so any writer whose new row would satisfy it must wait. Real engines don't do that — deciding whether two arbitrary predicates overlap is expensive and undecidable in general. Instead they approximate it with **index-range locking**: because a predicate that an index can serve maps to a contiguous stretch of key space, the engine locks that stretch, including the gaps. Concretely that is **gap locks** (the open interval between two adjacent index keys) and **next-key locks** (an index record plus the gap before it). An insert must acquire an insert-intention lock in the gap and blocks against them. The big operational consequence: the lock range follows the *index that was actually used*. With no usable index the engine must lock the scanned range of the whole table, so a missing index turns a narrow predicate lock into something close to a table lock, with a matching collapse in concurrency and a spike in deadlocks.

code

text · 11 lines
text
index keys:   ...  90      120      170      240 ...
                    |        |        |        |
scan range:        [<-- 100 ..... 200 -->]

record locks:           120      170
gap locks:        (90,120)  (120,170)  (170,240)
next-key locks:  (90,120]  (120,170]  (170,240]

=> an INSERT of amount=150 needs an insert-intention lock in (120,170) and waits
=> an INSERT of amount=210 ALSO waits: it falls in the (170,240] next-key lock
   even though 210 does not satisfy the predicate  <- false conflict

go deeper

for a junior

Know the core idea: locks need something to lock, a phantom row does not exist yet, so the database must lock the gaps as well as the rows.

for a middle

Name the mechanisms — predicate locking in theory; record, gap and next-key locks in practice — and explain that an insert must acquire permission in the gap and therefore waits.

for a senior

Lead with the operational consequence: the locked range follows the chosen access path, so a missing or unselective index turns a narrow predicate into a huge lock footprint with deadlocks and false conflicts. Prescribe indexing and shorter transactions.

for a principal

Compare families — blocking range locks versus SSI's read-dependency tracking versus pushing the invariant into a unique or exclusion constraint — and choose based on write hot-spotting, abort tolerance and how well the invariant maps to an index.

## Why row locks structurally cannot do it A lock manager associates a lock with an identifiable object — a row id, a page, an index key. When a transaction reads `WHERE amount BETWEEN 100 AND 200` and locks the four rows it found, it has protected four points on the number line. The interval between and around those points is unprotected, and that is exactly where a concurrent INSERT lands. The anomaly is about the *predicate's extension* (the set of all rows that could match), not about the rows currently in it. ## Predicate locking: the correct-but-impractical answer The original theory says: lock the predicate. A writer wanting to insert a row must check its row against every predicate lock held; if the row would satisfy one, it waits. This is provably sufficient. It is also impractical: - Deciding whether an arbitrary new row satisfies an arbitrary held predicate, for every insert, is expensive. - Deciding whether two arbitrary predicates can overlap (needed to conflict-check two readers/writers cheaply) is, in general, undecidable. So no mainstream engine does true general predicate locking on the write path. ## Index-range locking: the practical approximation The key insight is that a predicate an index can serve corresponds to a **contiguous range of index key space**. Lock the range and you have conservatively locked the predicate — you may lock slightly more than the predicate strictly needs (false conflicts), but never less (no missed phantoms). The building blocks: - **Record lock**: the index entry itself. - **Gap lock**: the open interval *between* two adjacent index keys, containing no rows. It exists purely to stop inserts. - **Next-key lock**: a record lock plus the gap preceding that record — the usual unit engines take when scanning an index under a phantom-preventing isolation level. - **Insert-intention lock**: what an INSERT requests in a gap; it conflicts with gap locks, so the insert waits, but two insert intentions at different points in the same gap do not conflict with each other. A range scan therefore takes a chain of next-key locks covering the whole scanned interval, including the boundary gaps at each end, so nothing can be inserted into the range until the scanning transaction commits. ## The index dependency — the part interviewers push on The locked range is determined by the **access path the optimizer chose**, not by the text of the predicate. Consequences: - **No usable index** → the engine scans the table and must lock every row and gap it examined, which for a large scan is effectively a table-wide lock. A `WHERE email = ?` check with no index on `email` can block all inserts into the table. - **A less selective index** → a wider locked range than logically necessary, so unrelated transactions block on rows that do not even match the predicate. A common symptom is "we get lock waits on rows the query never returned". - **Locks on non-matching rows** → because gap locks straddle keys, a transaction can block an insert of a value that would not have satisfied the predicate at all. This is a false conflict, and it is the deliberate price of a conservative approximation. - **Deadlock risk rises**, because two transactions scanning overlapping ranges in different orders each hold part of the other's range. ## Other mechanisms in the family - **Key-range locks** in lock-based engines are the same idea with a richer mode matrix (range-shared, range-insert, range-exclusive). - **Snapshot reads** avoid read phantoms without locking at all — the reader has a stable view — but they protect only the reader's own view, not an invariant the reader intends to act on. - **Serializable Snapshot Isolation (SSI)** takes a different tack: no blocking locks, but tracked read dependencies (typically via SIREAD markers on index ranges) and an abort when a dangerous cycle forms. Phantoms are caught by tracking the *ranges scanned*, not the rows returned. - **A unique or exclusion constraint** enforces the rule inside the index itself, converting a race into a constraint violation, and is often the cheapest correct answer for "there must not already exist a row like this". ## How to reason about it in practice When a transaction's correctness depends on "nothing else matches this predicate", ask three questions: which index will serve the predicate, how wide a key range does that make the engine lock, and how long will the transaction hold it? Narrow the range with a better index, shorten the hold time by doing the scan late in the transaction, and prefer a declarative constraint whenever the invariant can be expressed as one.

  • What happens to the locked range if the predicate column has no index?
    The engine has no key space to lock narrowly, so it scans and locks every row and gap it examines — for a full scan that is effectively the whole table. Concurrent inserts and updates block, throughput collapses, and deadlocks become common. Adding a selective index on the predicate column is usually the single highest-impact fix, because it shrinks both the scan and the lock footprint.
  • Can a transaction block an insert of a row that would not even have matched its query?
    Yes, and this is expected. Gap and next-key locks cover contiguous key space bounded by the adjacent existing keys, which can extend beyond the logical predicate. That over-approximation is deliberate: it is cheap to compute and can never miss a phantom, at the cost of occasional false conflicts. Only true predicate locking would be exact, and it is not practical to implement.

saying these in an interview costs you the question

  • Believing SELECT ... FOR UPDATE on the matching rows is enough to stop new inserts
  • Saying gap locks lock rows — they lock the empty space between keys, specifically to block inserts
  • Assuming the locked range comes from the WHERE clause rather than from the index the optimizer used
  • Claiming false conflicts are a bug rather than the deliberate price of approximating predicate locks
  • Thinking a snapshot read protects an invariant the transaction is about to act on

context