Storage engines answer how data is stored and changed. Transactions answer what happens when many people change it at once — same database, completely different failure mode. A transaction is a group of reads and writes that should behave as one indivisible unit (ACID). Atomicity, Consistency, and Durability are the easy three — Durability is literally the write-ahead log doing its job. The deep one is Isolation: how concurrent transactions should appear to each other.
The simplest thing that breaks
A counter sits at views = 100. Two requests each add 1:
T1: read views → 100
T2: read views → 100
T1: write 101, commit
T2: write 101, commit
final: 101 (should be 102)
One update silently vanished — the lost update anomaly. The unsettling part: neither transaction did anything wrong on its own. The corruption emerged purely from the interleaving.
Locking, and the traffic jam it creates
The first fix everyone reaches for is row-level locking: a writer takes an exclusive lock on the row, and other writers wait. Run the counter through it and the lost update is gone — T2 only reads after T1 commits, so it sees 101 and writes 102.
But now the sharp question: does a read have to wait for a write? Under pure two-phase locking, writers block readers and readers block writers — correct, but a single-file line. One slow analytics query holding (or waiting on) locks can stall every checkout on your site; one heavy write can freeze every dashboard. Correct and nearly useless at scale.
MVCC: hand the reader the old version
Here’s the heresy that fixes it. While a writer is turning 100 into 101, the old value 100 is still the committed truth right up until commit. So why make a reader wait for a value that already exists? Multi-Version Concurrency Control never overwrites a row in place. It keeps multiple versions, each stamped with the transaction that created it. A reader gets a consistent snapshot — the versions committed as of when it started — and never waits for a writer. Writers still coordinate with writers (the write-write lock survives), but readers don’t block writers and writers don’t block readers. That property is what lets Postgres run a 10-minute analytics query and thousands of checkouts at the same time without queuing behind each other.
The bill: version bloat and VACUUM
Keeping old versions around isn’t free. They accumulate, and once a version is
committed and no live snapshot could still need it, it’s dead weight — version
bloat. Something has to reclaim it in the background: VACUUM, the garbage-
collection analog of LSM compaction one layer
up. And because visibility lives in these heap row-versions rather than in indexes,
a bloated, un-vacuumed table also loses its index-only scans
until VACUUM catches up. Deletes are versions too — the whole system is
copy-on-write, cleaned lazily.