skip to content

A hot table takes thousands of writes per second, and one code path must be able to trust that no other row matches its search condition before it inserts. Compare the ways to eliminate the phantom in that check — SERIALIZABLE with retries, explicit range locking, and pushing the rule into an index constraint — and explain how you would choose.

level: principalimportance: nice to knowfreq 28%

answer

  1. Constraint > SSI > range locks > async repair
  2. No window at all if the index enforces it
  3. SSI trades blocking for aborts — retry loop mandatory
  4. Abort rate scales with transaction length and range width
  5. Partition the key space to make hot ranges cold

basics

~20 s

Three options: SERIALIZABLE (no blocking, but aborts you must retry, and abort rate grows with contention), range/next-key locking (deterministic but serializes the hot range and invites deadlocks), or a unique/exclusion constraint that makes the index enforce the rule with no extra locking. Prefer the constraint when the rule fits an index.

solid answer

~60 s

**Constraint first.** If the rule is "no two rows with this key" or "no two overlapping ranges", a unique index or an exclusion constraint enforces it inside the index structure. There is no read-then-write window at all, cost is one index probe, and it holds no matter what isolation level or ORM path the caller used. The application handles a constraint violation instead of a phantom. **Serializable snapshot isolation** when the rule is too complex for an index. It does not block, so throughput stays high while conflicts are rare, but it converts conflicts into aborts. That demands idempotent, retryable transactions, a bounded retry policy with backoff, and an abort-rate metric — and abort rate climbs superlinearly as transactions get longer or the hot range narrows. **Explicit range locking** when you need determinism and predictable latency, and the range is narrow and index-backed. It gives no aborts but serializes everything touching that key range, so it becomes a throughput ceiling and a deadlock source. Choice drivers: does the invariant fit an index; how narrow and how hot is the key range; can the caller retry; and do you need bounded latency or maximum throughput.

go deeper

for a junior

Recognize that the safe answer is usually a unique constraint enforced by the database rather than a check in application code.

for a middle

Contrast the three mechanisms and state their costs: constraints are cheap but limited in expressiveness, serializable trades blocking for aborts, range locks trade throughput for determinism.

for a senior

Bring the operational obligations — retry policy, idempotency, abort-rate monitoring, deadlock handling, index-driven lock width — and pick per-invariant rather than globally.

for a principal

Rank enforcement by where it lives and select the cheapest sufficient layer, quantify the throughput cost of each, and treat asynchronous detect-and-compensate as a legitimate option that requires an explicit product decision.

## Frame the problem correctly The code path is a read-then-write decision over a predicate: "nothing matches, so I may insert". Preventing the phantom means making that decision durable against every concurrent transaction. Every option below is a different place to pay for that: in the index, in aborted work, or in blocked work. ## Option 1 — push the rule into the index A **unique constraint** covers equality-shaped rules ("one active membership per user"). An **exclusion constraint** covers overlap-shaped rules ("no two bookings for a room with overlapping time ranges"), which are the classic case where a unique index cannot express the rule. Why it is usually the right default: - There is no window. The check and the write are the same index operation, so no isolation level is required to make it correct. - It is enforced for every writer regardless of code path, ORM, migration script or ad-hoc session. Application-level checks are only as strong as the least careful caller. - The cost is one index probe plus normal insert maintenance. Contention is per-key, not per-range. Limits: the rule must be expressible over the columns of one row (or one index expression). Rules that quantify over other rows — "at most N rows matching", "the sum stays under a limit" — do not fit and need one of the other options, or a redesign that materializes the aggregate into a single row that can be locked or constrained. ## Option 2 — SERIALIZABLE via serializable snapshot isolation SSI-style implementations run transactions optimistically on snapshots, track which key ranges each transaction *read*, and abort a transaction when a dependency pattern that could produce a non-serializable outcome is detected. Phantoms are covered because reads are tracked at range granularity, not row granularity. What it buys: correctness for arbitrary invariants, without blocking, and without you having to reason about lock ordering. What it costs, and what you must build: - **A retry loop is mandatory.** Serialization failures are a normal outcome, not an error condition. Transactions must be idempotent from the client's perspective — either naturally, or via a request key — and retries need bounded attempts plus jittered backoff to avoid a retry storm. - **Abort rate is your capacity metric.** It rises with transaction duration, with the width of ranges read, and with how concentrated writes are on a hot key range. Long transactions are the usual culprit: do external calls outside the transaction, keep the write set small, and never hold a transaction open across user think time. - **False aborts exist.** Conflict tracking is conservative and coarsens under memory pressure, so some aborts correspond to executions that were actually fine. That is acceptable in exchange for never missing a real conflict, but it means abort rate is not the same as true conflict rate. - **Latency becomes multi-modal**: fast path plus occasional retried path. Set SLOs on the retried distribution, not the mean. ## Option 3 — explicit range locking Take locks that cover the predicate's key range for the duration of the transaction. No aborts, deterministic ordering, easy to reason about locally. Costs: - **It is a throughput ceiling.** Everything touching that range serializes. On a hot table, that is the whole point of the exercise and also the bottleneck. - **The lock footprint follows the access path**, so an unselective or missing index silently widens it, sometimes to a full scan's worth of key space. - **Deadlocks** appear whenever two transactions acquire overlapping ranges in different orders, which means you still need a retry path — so you pay for both mechanisms. It is the right tool when contention is genuinely low but must be handled correctly, when latency must be bounded and retries are unacceptable, or when the range is naturally partitioned so each lock is narrow. ## Reducing contention regardless of mechanism - **Partition the key space** so hot ranges become many cold ones — per-tenant, per-day, per-shard prefixes. This shrinks lock ranges and conflict windows for every option. - **Keep transactions short**, since every mechanism's cost scales with hold time or read-tracking window. - **Move the invariant to a narrower object** when possible: a counter row you lock, or a queue slot you claim, is a single-row conflict rather than a predicate conflict. - **Consider whether the invariant must be synchronous.** Some rules tolerate detect-and-repair — accept the write, detect the violation asynchronously, and compensate — and that trade buys enormous throughput. It is a business decision, not a database one, and should be made explicitly rather than by accident. ## How to present the choice Rank by where the enforcement lives: index constraint (strongest, cheapest, least expressive) → SSI with retries (fully expressive, costs aborts) → explicit range locks (deterministic, costs concurrency) → asynchronous detection (cheapest at runtime, weakest guarantee). Pick the leftmost option the invariant actually fits, and be explicit about the operational obligations each one creates.

  • How do you decide the retry policy for a transaction running under serializable snapshot isolation?
    Bound the attempts (typically a small number such as three to five) and use jittered exponential backoff, because unbounded immediate retries synchronize contenders and amplify the conflict. The transaction must be safe to run twice from the caller's viewpoint, which usually means idempotency keyed on a client-supplied request identifier. Instrument attempts per transaction and abort rate as first-class metrics, since a rising abort rate is the earliest signal that transactions are getting longer or a key range is heating up.
  • When would you accept detecting a violated invariant after the fact instead of preventing it?
    When the cost of a rare violation is bounded and reversible, and the throughput or latency cost of synchronous enforcement is not. Overbooking that can be resolved by compensation, or a duplicate that a downstream job merges, are typical cases. It must be an explicit product decision with a detection job, an alert and a compensating action defined — never the accidental result of choosing a weak isolation level and hoping.

saying these in an interview costs you the question

  • Reaching for SERIALIZABLE without adding a retry loop or making the transaction idempotent
  • Treating serialization failures as bugs to be logged rather than a normal control-flow outcome
  • Assuming an application-level SELECT check is equivalent to a database constraint
  • Ignoring that range locks widen when the predicate has no selective index
  • Claiming the constraint approach always works, including for aggregate or count-based rules

context