Contrast lock-based concurrency control using two-phase locking with multi-version concurrency control (MVCC): when one transaction reads a row and another writes the same row, what happens under each scheme, and what are the tradeoffs?
answer
- 2PL: S vs X conflict → readers block writers
- MVCC: new version + snapshot → readers never block writers
- Writers still block writers under both
- MVCC costs: version bloat, GC, long readers pin snapshots
- SI ≠ serializable — write skew; needs SSI or locking reads
basics
~20 sUnder 2PL the reader's shared lock and the writer's exclusive lock conflict, so one blocks. Under MVCC the writer creates a new version and the reader sees an older snapshot, so readers never block writers. MVCC costs version storage and cleanup, and snapshot isolation alone is not serializable.
solid answer
~60 s**2PL**: a read takes a shared lock, a write an exclusive lock; the two conflict, so whichever arrives second waits. Readers block writers and writers block readers. That mutual blocking is what makes serializability fall out for free, but read-heavy workloads pay for it, and long readers can stall all writers on the rows they touch. **MVCC**: a write creates a **new version** of the row, tagged with the writing transaction; each reader sees the version consistent with its **snapshot**. Readers never block writers and writers never block readers. Writers still conflict with writers — row-level write locks or first-updater-wins. Tradeoffs. MVCC buys huge read concurrency and consistent point-in-time reads without locks. It costs: version storage and background cleanup, visibility checks on every row, and long-running readers pinning old versions and delaying reclamation. Critically, plain **snapshot isolation is not serializable** — write skew slips through — so serializable MVCC needs runtime dependency checking (SSI) or explicit locking reads. Most modern engines are hybrid: MVCC for reads, locking for writes.
code
text · 11 linesinvariant: at least one doctor remains on call
start: alice = on_call, bob = on_call
T1 (alice) T2 (bob)
snapshot: sees bob on_call snapshot: sees alice on_call
check passes check passes
UPDATE alice -> off_call UPDATE bob -> off_call
COMMIT COMMIT
no write/write conflict -> both commit -> zero doctors on call
under 2PL: T1's shared lock on bob's row blocks T2's writego deeper
State the core difference: locks make one side wait, versions let readers see an older consistent copy so readers don't block writers.
Add that writers still conflict with writers under MVCC, and name the costs — version storage and cleanup.
Lead with write skew and the fact that snapshot isolation is weaker than serializable, plus the long-reader/version-retention failure mode you have to operate around.
Frame it as where the cost lands — waiting versus space and aborts — and choose per workload, including hybrid designs and when an optimistic scheme collapses under contention.
## The same question, two answers Both schemes answer "what happens when concurrent transactions touch the same row?", but they start from opposite assumptions. Two-phase locking is **pessimistic**: assume conflict, prevent it by mutual exclusion, make everyone else wait. MVCC is built on a different observation — a reader does not actually need the *current* value, it needs *a consistent* value — so it keeps multiple versions and hands each transaction the one its snapshot should see. ## The reader/writer case, step by step **Under 2PL.** A read acquires a shared (S) lock, a write an exclusive (X) lock; S and X are incompatible. If the reader got there first, the writer waits until the reader releases — and under rigorous 2PL that means until the reader *commits*. If the writer got there first, the reader waits for the writer's commit. So a long analytical query holding shared locks stalls every writer on its rows, and a slow writer stalls every reader. The upside is that this blocking *is* the correctness argument: the two-phase rule plus lock conflicts yields conflict-serializable schedules with no extra machinery. **Under MVCC.** The writer does not overwrite; it appends a new **version** of the row stamped with its transaction identifier (or a commit timestamp) and links it to the previous version. Each transaction has a **snapshot** — a rule for deciding which versions are visible, typically "versions committed before my start, excluding transactions still in flight then". The reader walks the version chain and takes the newest version visible to it. Nothing is locked on the read path, so: - readers do not block writers, - writers do not block readers, - **writers still block writers** — two transactions updating the same row conflict, resolved by a row-level write lock (the second waits) or first-updater-wins (the second aborts). That last point is the one candidates most often get wrong. MVCC removes the read/write conflict, not the write/write conflict. ## What MVCC costs Nothing is free; the costs are just moved from waiting to space and bookkeeping. 1. **Version storage.** Old versions must live somewhere — inline in the table with a background reclaimer, or in a separate undo/rollback area that readers walk backwards through. Either way, write amplification and space grow. 2. **Garbage collection.** Versions older than the oldest active snapshot are dead and must be reclaimed. This is a continuous background cost, and it is the source of the classic operational failure mode: **a long-running reader (or an idle-in-transaction session) pins the oldest snapshot, so nothing can be reclaimed, and version chains and table size grow without bound.** Read latency then degrades as readers traverse long chains. 3. **Visibility checks.** Every row touched requires evaluating whether its version is visible to this snapshot, which costs CPU and sometimes extra I/O for commit-status metadata. 4. **Isolation subtleties.** Snapshot reads mean a transaction can act on a view of the world that is already stale by commit time. ## The correctness gap: snapshot isolation is not serializability This is the heart of a senior answer. **Snapshot isolation (SI)** — every transaction reads from its own consistent snapshot, and concurrent writers to the same row conflict — eliminates dirty reads, non-repeatable reads and the usual phantom problems, yet it is *weaker than serializable*. The counterexample is **write skew**: two transactions each read an overlapping set of rows, check an invariant across them, and each writes a *different* row. No write/write conflict occurs, so both commit, and the invariant spanning the rows is violated — for example, two on-call doctors each check "at least one other person is on duty", see the other, and each removes themselves. Under 2PL this cannot happen: the reads take shared locks that conflict with the other's write, so one transaction blocks and then re-reads reality. MVCC has to recover the guarantee some other way: - **Serializable Snapshot Isolation (SSI)** — track read/write dependencies at runtime and abort a transaction when a dangerous structure (two consecutive rw-dependency edges around a pivot) appears. Optimistic: no blocking, but real workloads see serialization failures and the application must retry. - **Explicit locking reads** — the application promotes the relevant reads to locking reads, reintroducing 2PL-style blocking exactly where an invariant needs it. So the honest framing is: MVCC does not make serializability cheaper for free; it makes *read-mostly* work far cheaper, and pushes the serializability cost into either aborts (SSI) or selective locking. ## Hybrids, which is what you actually run Almost every mainstream relational engine is a hybrid: MVCC on the read path (snapshot reads, no shared locks) and locking on the write path (row write locks, held to commit, i.e. still strict). Some layer range/predicate locking on top of MVCC for their strongest level; others use SSI. The pure 2PL engine — shared locks on every read, held to commit — is now mostly found in older systems and in in-memory or deterministic databases where lock durations are microseconds and versioning would be pure overhead. ## Choosing between them - **Read-heavy, analytics mixed with OLTP, long queries** → MVCC decisively; blocking readers would be fatal, and consistent point-in-time reads are exactly what reporting wants. - **Short, uniform, write-heavy transactions on hot rows** → the version machinery mostly overhead; conflicts are write/write anyway, which both schemes serialize, and 2PL's blocking avoids the abort-and-retry storms optimistic schemes produce under high contention. - **Strong invariants across rows** → be explicit: either true serializable (SSI or locking reads) or accept SI and enforce the invariant with a constraint, a single-row aggregate, or deliberate locking. "We use snapshot isolation so we're safe" is not an answer. The framing to close with: 2PL pays for correctness in **waiting**; MVCC pays in **space, cleanup, and aborts**. Which currency is cheaper is a property of your workload, not of the engine.
- Under MVCC, do writers still block each other?Yes. Two transactions updating the same row genuinely conflict, and versioning cannot make that go away. Engines resolve it either by making the second updater wait on a row write lock until the first commits or aborts, or by first-updater-wins, aborting the second with a serialization failure. MVCC removes the reader/writer conflict only.
- What operational problem do long-running read transactions cause in an MVCC engine, and why doesn't 2PL have it?A long reader holds the oldest snapshot, so every row version newer than its snapshot boundary must be retained; garbage collection stalls, tables and version chains grow, and read performance degrades for everyone. Idle-in-transaction sessions cause the same thing without doing any work. A 2PL engine keeps no versions, so it has no such retention problem — instead the long reader shows up immediately as blocked writers, which is more visible but also more disruptive.
- If snapshot isolation is not serializable, how do you protect a cross-row invariant on an MVCC engine?Three options. Use the engine's true serializable level, which typically adds runtime dependency tracking and aborts one participant of a dangerous pattern — then implement retry. Or promote the reads that establish the invariant to locking reads, so the conflicting write blocks. Or restructure so the invariant lives in a single row or a database constraint, turning the cross-row check into a write/write conflict the engine already serializes.
2PL is a single shared whiteboard: while you are reading it nobody may erase, and while someone is rewriting it nobody may read. MVCC is a photocopier — the writer posts a new sheet, and everyone who started earlier keeps reading the copy they were handed, so nobody waits, but somebody has to throw away the old sheets eventually.
saying these in an interview costs you the question
- Claiming MVCC eliminates all locking, including between writers
- Saying snapshot isolation is the same as serializable
- Ignoring version cleanup and asserting MVCC has no cost
- Believing a reader under MVCC sees the latest committed row rather than its snapshot
- Presenting the two as engine branding rather than as different placements of the same cost