MVCC: How Readers Avoid Blocking Writers

Keep the old version of a row alongside the new one, and a reader never has to wait for a writer.

Overview

The problem with locks

The simple way to keep transactions from interfering is locking: a reader takes a shared lock, a writer takes an exclusive one, and the two cannot be held at once.

It is correct, and on a busy system it is miserable. A report that scans a large table holds read locks for its whole run, so every write to that table waits. Meanwhile a long write transaction blocks every reader. The database spends its time queueing rather than working, and the symptom is an application that is fast until it is inexplicably not.

MVCC: How Readers Avoid Blocking Writers

This module needs JavaScript: it steps through two transactions rather than showing a finished picture.

Worth knowing

A write does not overwrite. It writes a new version of the row and leaves the old one for whoever is still reading it.
Each transaction gets a snapshot: a rule for which versions it is allowed to see, fixed at the moment it started.
Readers never block writers and writers never block readers. Two writers to the same row still conflict.
The cost is garbage: dead versions accumulate and something has to clean them up. In PostgreSQL that is VACUUM.

MVCC: How Readers Avoid Blocking Writers

The idea that lets a long report run while the database keeps taking writes.

Versions instead of locks

Multi-version concurrency control removes the conflict by refusing to destroy anything. A write does not overwrite a row; it creates a *new version* of it, and the old version stays until nobody can still need it.

Every transaction is given a snapshot — effectively a rule saying which versions count as visible — fixed at the moment it starts, or at the moment each statement starts, depending on the [isolation level](isolation_levels.html).

A reader that began before a write simply continues to see the older version. Nothing waits.

What each version carries

Conceptually each row version records which transaction created it and which transaction deleted it:

id  | balance | created_by | deleted_by
----+---------+------------+-----------
7   |   1000  |    100     |    142
7   |    850  |    142     |    NULL

Transaction 142 changed the balance. It did not edit the first line; it marked it deleted and appended the second. A transaction with a snapshot older than 142 is shown the first version, a newer one the second. Visibility becomes an arithmetic comparison of transaction ids rather than a queue.

What MVCC does not solve

This is the part that gets missed. MVCC eliminates reader/writer conflicts. It does not eliminate writer/writer conflicts.

Two transactions updating the same row still contend: the second must wait for the first to commit or roll back, because they would otherwise both produce a new version from the same old one and one update would vanish. That is the [lost update](isolation_levels.html) problem, and MVCC's answer to it is either a lock on that row or a serialisation failure at commit.

So the rule is: readers never wait, writers to the same row still do.

The cost: garbage

Old versions accumulate. Something has to decide when a version can no longer be seen by any live snapshot and reclaim the space.

In PostgreSQL that job is VACUUM, usually run by autovacuum. When it cannot keep up — typically because a very old transaction is still open and pinning every version created since — tables bloat: the row count is unchanged while the file grows, and every scan reads more pages for the same data.

The practical consequence is worth stating plainly: a transaction left open does damage even when it is doing nothing, because it holds back cleanup for the whole database. An idle-in-transaction connection is a bug, not a small inefficiency.

Different engines pay this differently. PostgreSQL keeps old versions in the table itself and vacuums them. MySQL's InnoDB keeps them in a separate undo log and purges it. Oracle uses undo segments, and the same open-transaction problem appears as ORA-01555 snapshot too old — the old version needed was already discarded.

Where it goes wrong

Long-running transactions. They pin versions across the entire database. Keep them short, and never leave one open across a user interaction.

Assuming no waiting at all. Writers to the same row still contend, and the error surfaces as a lock wait or a serialisation failure.

Treating table bloat as a disk problem. It is a symptom of vacuum not keeping up, and adding disk hides it rather than fixing it.

Counting rows to check for bloat. The row count is right; the file size is the thing that grew.

Check yourself

0 of 3

Answer without scrolling back up.

  1. What does a write do under MVCC?

  2. Which conflict does MVCC NOT remove?

  3. Why does an idle-in-transaction connection cause table bloat?

Cheat sheet

MVCC: How Readers Avoid Blocking Writers

The simple way to keep transactions from interfering is locking: a reader takes a shared lock, a writer takes an exclusive one, and the two cannot be held at once.

DATABASE · vizlearn.in/database/mvcc_in_databases.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.