skip to content

MVCC Mechanics

Multi-version concurrency control as an engine-agnostic mechanism: keep old row versions around so each transaction reads a consistent snapshot instead of waiting on locks. Interviewers ask this to see whether I can explain how 'readers don't block writers' is physically implemented, not just recite the slogan.

part ofRelational database conceptsoverview, primer and where to startread it →
on this pageshow

questions

16

In a database that uses multi-version concurrency control (MVCC), why can a long-running SELECT keep reading rows while other transactions update those same rows, with neither side waiting on the other?

level: juniorimportance: must knowfreq 72%

answer

  1. Update = new version, not overwrite
  2. Snapshot picks the visible version
  3. No shared lock needed to read
  4. Remaining conflict: writer vs writer
  5. Cost: old versions + cleanup

basics

~20 s

An update writes a new version of the row instead of overwriting it in place. The reader keeps seeing the version that was current when its snapshot began, so it needs no lock on the row, and the writer has no reader lock to wait behind.

solid answer

~50 s

MVCC engines do not destroy the current row image when a row is updated. An UPDATE creates a **new version** of the row and leaves the previous version in place until no running transaction can still need it. Every transaction reads against a **snapshot**: a rule that decides, for each row, which version that transaction is entitled to see. Because of that, a plain SELECT does not need a shared lock to get a stable read. The version it may see cannot be mutated out from under it, since writers append versions rather than overwrite. The consequences are the classic pair: - readers do not block writers (the reader holds no lock the writer must acquire), and - writers do not block readers (the reader uses an older version, not the one being written). The costs are extra storage for old versions and background work to reclaim them. The blocking that remains is writer-versus-writer on the same row, plus schema-level locks.

code

text · 5 lines
text
T1: BEGIN; SELECT sum(amount) FROM orders;   -- long scan, snapshot at t0
T2:        BEGIN; UPDATE orders SET amount=5 WHERE id=7;  -- creates new version
T2:        COMMIT;                                        -- no wait for T1
T1: ...still scanning, still sees the t0 version of row 7
T1: COMMIT;

go deeper

for a junior

Recall the mechanism in one breath: updates create a new version, a reader reads the version its snapshot allows, so no read lock is needed and nothing waits.

for a middle

Add the reciprocal statement (writers don't block readers either) and name the remaining conflict class — writer versus writer on the same row — plus the storage and cleanup cost.

for a senior

Connect it to operations: long readers hold back version cleanup, snapshot reads can be stale so read-modify-write needs explicit protection, and lock waits you observe are write-side, not read-side.

for a principal

Frame it as a design tradeoff: MVCC trades space and reclamation work for read-path concurrency, moves the contention point to hot rows, and still requires an extra mechanism if you need true serializability.

## The problem Concurrency control must give each transaction a coherent view of the data while other transactions change it. The oldest general answer is locking. Before reading a row you take a **shared (S) lock**; before writing it you take an **exclusive (X) lock**. S locks are compatible with each other but not with X, so a reader and a writer on the same row must take turns. Under strict two-phase locking those locks are held to commit, so a long report can stall writes for its whole duration, and a long write transaction can stall readers. ## What MVCC changes MVCC (multi-version concurrency control) keeps **several physical versions of the same logical row**. An UPDATE does not overwrite the stored bytes of the live row; it produces a new version and marks the old one as superseded by the updating transaction. A DELETE similarly marks the version dead rather than erasing it immediately. Old versions stay readable until the engine can prove nobody needs them. Each transaction gets a **snapshot**: enough bookkeeping (typically which transactions had committed at a point in time) to decide, for any version it encounters, whether that version is visible to it. A read then means: walk the row's versions and pick the one the snapshot says is current for me. At READ COMMITTED a new snapshot is usually taken per statement; at REPEATABLE READ / SNAPSHOT the snapshot is taken once for the transaction. Because visibility is decided from immutable version metadata, a reader needs **no lock at all** on the rows it reads. Nothing another transaction does can change which version this reader is entitled to see: a concurrent UPDATE only adds a version that this snapshot will reject. That is the whole mechanism behind the slogan *readers don't block writers, and writers don't block readers*. ## What this buys you - **Reporting alongside OLTP.** A ten-minute analytical scan runs against a consistent point-in-time view without freezing the write path. - **Predictable read latency.** Reads do not queue behind write transactions holding X locks. - **Fewer deadlocks.** Deadlock needs at least two lock waiters; removing read locks removes a large class of lock cycles. - **Consistent reads for free.** The reader sees a single point in time, not a smear of rows read at different moments, without holding locks to get it. ## What it does not buy you - **Write-write conflicts still block.** Two transactions updating the same row still serialize: the second waits on the first transaction's row lock and only proceeds when it commits or rolls back. - **Stale-by-design reads.** A reader is looking at a snapshot, so what it returns may already be out of date at the moment it returns. This is the source of the classic *lost update* bug when application code reads, computes, then writes back without extra protection. - **Not full serializability by itself.** Plain snapshot isolation permits anomalies such as write skew; engines add either explicit locking or a serializable layer (for example serializable snapshot isolation) on top. - **Space and maintenance.** Superseded versions accumulate and must be reclaimed, either in the main storage structure or in a separate undo/rollback area. Long-running readers hold back that reclamation, because their snapshot may still need old versions. ## Where the versions physically live Two broad designs exist, and the difference matters for costs, not for the readers-don't-block-writers property. In an **append-in-place** design (PostgreSQL-style), new versions are written into the table's own pages and old versions are cleaned up later by a background process. In an **undo-log** design (InnoDB, Oracle-style), the row in the table holds the newest version and readers reconstruct older versions by applying undo records backwards. Either way the reader gets its version without taking a row lock; either way the engine must eventually discard versions nobody can see. ## How to say it in an interview State the mechanism first (updates create versions, reads pick a version via a snapshot, therefore reads take no row locks), then the two implications (readers don't block writers, writers don't block readers), then the honest limits (writer-writer conflicts still block; versions cost storage and cleanup; snapshot reads can be stale). That order shows you understand the cause rather than reciting the slogan.

  • If readers take no locks, what still blocks in an MVCC engine?
    Writer-versus-writer conflicts on the same row: the second updater waits on the first transaction's row lock until it commits or aborts. Explicit locking requests (such as SELECT ... FOR UPDATE) deliberately reintroduce blocking. Schema changes and other object-level locks can also block both readers and writers.
  • What is the price the engine pays for never overwriting rows in place?
    Multiple versions of the same row exist simultaneously, so tables and indexes consume more space and scans may touch versions they end up discarding. The engine must run cleanup to reclaim versions no snapshot can see, and a long-running transaction holds back that cleanup because its snapshot may still need old versions.
  • Does MVCC mean a SELECT always returns current data?
    No. It returns the data as of its snapshot, which may be older than the committed state at the instant the result is delivered. That is why read-then-write logic in application code needs explicit locking or a conflict check; a snapshot read alone gives no guarantee that the row is unchanged when you write it back.

Like a wiki page: an editor saves a new revision instead of erasing the old one, so a reader who opened revision 12 keeps reading revision 12 undisturbed while revision 13 is being written.

saying these in an interview costs you the question

  • Saying MVCC means there are no locks at all, when write-write conflicts still block
  • Claiming readers see 'the latest data' rather than their snapshot
  • Describing an UPDATE as overwriting the row and 'copying the old value somewhere for rollback only'
  • Assuming MVCC gives serializability by itself
  • Ignoring that old versions consume space and require cleanup

context

open as a page

In a database engine that keeps multiple physical versions of each row so concurrent transactions read consistent snapshots, what is table and index bloat, and why does it accumulate?

level: juniorimportance: must knowfreq 58%

basics

~20 s

Updates and deletes leave the old row version behind so older snapshots can still read it. Until a background cleanup process reclaims those dead versions, tables and indexes hold space no query needs. That waste is bloat.

open as a page

In a database that keeps multiple versions of each row, what stamps are recorded on a row version when it is created or superseded, and how does the engine use those stamps to decide whether a particular transaction may read that version?

level: juniorimportance: must knowfreq 62%

basics

~20 s

Every row version records the id of the transaction that created it and, once superseded or deleted, the id of the transaction that expired it. A reader sees a version only if its creator had committed as of the reader's snapshot and its expirer had not.

open as a page

Two concurrent transactions each issue an UPDATE against the same row in an MVCC database. Walk through what each transaction experiences from the moment the second UPDATE is issued until both finish.

level: middleimportance: must knowfreq 60%

basics

~20 s

The first updater takes an exclusive row lock and creates a new version. The second UPDATE finds the row locked and blocks. When the first commits, the second either re-reads the new version and applies its change, or aborts with a serialization error, depending on isolation level.

open as a page

Why can a single session that has held one transaction open for hours stop a database's background version cleanup from reclaiming dead rows across every table, not just the tables that session touched?

level: middleimportance: must knowfreq 62%

basics

~20 s

Cleanup may only remove versions that no live snapshot can see. The oldest active snapshot sets a single global horizon; anything that died after it must be kept. One ancient transaction holds that horizon back, so dead rows everywhere become unreclaimable.

open as a page

When a database takes a read snapshot for a transaction, what does that snapshot actually consist of, and why must it record the set of transactions that were still in flight at that instant rather than just a single cutoff number?

level: middleimportance: must knowfreq 58%

basics

~20 s

A snapshot is typically a lower bound, an upper bound, and the list of transaction ids in flight at that moment. It needs the list because ids are assigned at start but commits happen out of order, so a low id can still be uncommitted when a higher one has committed.

open as a page

Compare how a plain SELECT behaves in a relational engine that uses strict two-phase locking with shared read locks against one that uses multi-version concurrency control, and explain what that difference costs and buys.

level: middleimportance: should knowfreq 45%

basics

~20 s

Under two-phase locking a SELECT takes shared locks held to commit, so it blocks any writer of those rows and can deadlock. Under MVCC it takes no row locks and reads a snapshot, so it never blocks writers, but it may read data that is already stale and the engine must store and reclaim old versions.

open as a page

Relational engines take two broad approaches to storing superseded row versions: writing the old image into a separate undo or rollback area, or keeping every version inside the table's own data pages. Compare how each reclaims space and what operational problems each creates.

level: middleimportance: should knowfreq 40%

basics

~20 s

Undo-based engines update rows in place and keep before-images in a separate area that a purge process trims; the table stays compact but undo can explode and old reads get slower. In-page engines append new versions into the table and rely on a vacuum-style process; current reads stay fast but tables and indexes bloat.

open as a page

A read snapshot can be taken once per statement or once for an entire transaction. What is the practical difference in what a long-running transaction reads, and what problems does each choice create?

level: middleimportance: should knowfreq 50%

basics

~20 s

A per-statement snapshot makes each statement see everything committed up to its own start, so repeated reads inside one transaction can change. A transaction-lifetime snapshot freezes one instant for all statements, giving stable, mutually consistent reads but an increasingly stale view.

open as a page

An application reads a row, computes a new value in application code, and writes it back inside one transaction in an MVCC database at READ COMMITTED. Why can concurrent runs silently lose one of the updates, and what are the options for making it correct?

level: seniorimportance: should knowfreq 54%

basics

~20 s

The read took no lock, so another transaction can commit a change to that row between the read and the write. The blind write then overwrites it. Fix by locking the row on read (SELECT ... FOR UPDATE), by writing conditionally on the value or a version column and retrying, by computing in SQL, or by using serializable isolation with retries.

open as a page

A frequently updated 40 GB table holds roughly 6 GB of live data and full-table scans have become several times slower. Walk through how you confirm the extra space is dead-version bloat, reclaim it while the table stays online, and stop it recurring.

level: seniorimportance: should knowfreq 45%

basics

~20 s

Confirm by comparing live rows times average width against physical size, including indexes. Then find why cleanup is not reclaiming: a held snapshot horizon, starved cleanup workers, or a stale replication slot. Fix the cause, reclaim with an online rewrite rather than a locking one, and prevent recurrence with short transactions, per-table cleanup tuning and partitioning.

open as a page

Some engines abort a long-running read with an error saying the row version it needed is no longer available; Oracle's ORA-01555 'snapshot too old' is the classic example. Explain what causes this class of failure and what tradeoff the database is making.

level: seniorimportance: should knowfreq 33%

basics

~20 s

The reader needed an old version whose stored before-image had already been reclaimed or overwritten, because retention is bounded. The engine chose to cap version-history space and abort late readers instead of letting history grow without limit.

open as a page

Multi-version engines store prior row images in two broadly different ways: writing every new version as an additional tuple inside the table itself, or keeping the current row in place and chaining prior images in a separate undo/rollback area. Compare the read and write consequences of these two designs.

level: seniorimportance: should knowfreq 42%

basics

~20 s

Append-in-table designs make every update a new tuple, so reads of any version are direct but indexes must point at the new location and the table grows. Undo-chain designs keep the current row in place, so current reads are fastest and indexes churn less, but old snapshots pay to reconstruct versions and can exhaust undo space.

open as a page

A transaction that updates a row and then re-reads it sees its own uncommitted change, even though no other transaction can. Beyond transaction-id stamps and the in-flight list, what extra bookkeeping does the engine need so that a single statement does not repeatedly process rows it has just written itself?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

Own writes are visible because the visibility test treats the reader's own transaction id as visible. To keep one statement from re-processing its own output, engines also stamp each version with a statement/command sequence number and hide versions created by the current or later commands of the same transaction.

open as a page

A service keeps a per-tenant counter in a single row that is updated thousands of times per second. Read traffic scales fine, but write latency spikes and throughput plateaus. Explain why multi-version concurrency control does not help this workload, and how you would weigh the options for fixing it.

level: principalimportance: nice to knowfreq 32%

basics

~20 s

MVCC removes read locks, not write locks. Every updater of that one row serializes behind an exclusive row lock held to commit, so throughput is capped near one update per lock-hold duration. Fix by shortening the hold, sharding the counter into many rows, or appending events and aggregating on read.

open as a page

You own a busy transactional database that also serves hour-long analytical reports and feeds a downstream change-data-capture consumer. How would you set a policy for how long old row versions are retained, and keep cleanup from either falling behind or starving the workload?

level: principalimportance: nice to knowfreq 26%

basics

~20 s

Pick a maximum history depth the platform will fund, enforce it mechanically with transaction and slot limits, and decide up front which failure you prefer: aborted long readers or unbounded bloat. Then size cleanup so its throughput exceeds the rate dead versions are created, and isolate analytics and change capture so neither pins the writer's horizon.

open as a page