Lesson 0002 · Storage & MVCC
Lesson 0001 left dead tuples piling up in the heap. This lesson follows them: who removes them, when autovacuum decides to run, and the one trick — HOT — that stops an UPDATE from touching every index.
Before new material, pull the keystone back from memory — retrieval is what makes it stick. Answer from your head, not by re-reading 0001.
In Postgres, an UPDATE to a row physically results in:
Complete the chain: never update in place →
Good. You know dead tuples accumulate and that VACUUM
reclaims them. Two questions were left open: when does that cleanup actually
fire, and can we make an UPDATE cheaper in the first place? Both answers
matter because — remember from 0001 — every Postgres index is secondary,
so by default a new row version means a new entry in every index on the table.
Here is the fact that surprises people: plain VACUUM does not
shrink your table on disk. It marks dead-tuple space reusable inside the table,
but does not hand it back to the operating system.
— PostgreSQL Docs: "The standard form of VACUUM removes dead row versions… and marks the space available for future reuse. However, it will not return the space to the operating system…"
So after a bloat episode, a vacuumed table stays large on disk but stops growing —
new rows fill the freed slots. To physically shrink it you need
VACUUM FULL, which rewrites the whole table with no dead space — but takes
an ACCESS EXCLUSIVE lock, blocking all reads and writes for the duration.
— PostgreSQL Docs: "VACUUM FULL actively compacts tables by writing a complete new version of the table file with no dead space… requires an ACCESS EXCLUSIVE lock."
Reaching for VACUUM FULL to "fix bloat" on a live table locks it
completely — an outage. Routine bloat is meant to be held in check by
autovacuum (below), so the table reaches a steady state and reuses its own
freed space. VACUUM FULL is a last resort for a table that already
bloated badly, run in a maintenance window.
You rarely run VACUUM by hand. Autovacuum automates it.
— PostgreSQL Docs: "PostgreSQL has an optional but highly recommended feature called autovacuum, whose purpose is to automate the execution of VACUUM and ANALYZE commands."
A table is vacuumed once its number of dead tuples crosses a threshold that is
proportional to the table's size:
— formula and defaults from the docs: base threshold default 50 tuples, scale factor default 0.2 (20% of table size). (Recent versions also cap it with a Minimum(...) max-threshold; the proportional term is the mental model that matters.)
Read the shape, not the arithmetic: a table is vacuumed after roughly 20% of its rows have become dead. Two consequences fall straight out:
autovacuum_vacuum_scale_factor (or set a per-table override) so large
tables get vacuumed sooner.In InnoDB the equivalent cleanup (purging old versions from the undo log) is a
continuous background job you never configure per table. Postgres exposes the
janitor: it has a trigger you can see (n_dead_tup) and knobs you can turn.
More responsibility, more control — the recurring theme of this course.
Now the payoff. Normally an UPDATE writes a new tuple and, because
every index is secondary and points at a physical tuple, must add a new entry to
every index on the table — write amplification that also creates more
index bloat. Heap-Only Tuples (HOT) is the optimization that avoids it
entirely, when two conditions hold:
When both hold, the new version is chained to the old one within the page and no new index entries are created — the existing index entry still points at the page, and a redirect leads to the live version. — PostgreSQL Docs: "New index entries are not needed to represent updated rows…"
You raise HOT's hit rate by leaving free space on each page — lower the table's
fillfactor below 100 so pages start with room for future
in-page versions.
— PostgreSQL Docs: "You can increase the likelihood of sufficient page space for HOT updates by decreasing a table's fillfactor."
There's a second gift: HOT chains can be pruned during ordinary reads, reclaiming the
intermediate dead versions without waiting for a full VACUUM.
| Question | InnoDB | PostgreSQL |
|---|---|---|
| Who removes old versions? | Background purge (automatic) | Autovacuum (tunable, per-table) |
| When is it triggered? | Continuous, not per-table | ~20% of rows dead (scale factor) |
| Does cleanup shrink the file? | N/A (in-place) | Plain VACUUM: no. VACUUM FULL: yes, with a lock |
| UPDATE that changes no indexed col | In place; indexes untouched anyway | HOT: heap-only, indexes untouched |
| How to help the cheap path | — | Lower fillfactor to leave page room |
From memory — the effortful recall is the point. Two items reach back to earlier ideas on purpose.
After plain VACUUM on a bloated table, the file on disk usually:
Roughly how many rows must go dead before default autovacuum stirs?
On a huge, hot table the usual autovacuum tuning move is to:
A HOT update is possible only when the update:
Why does a normal (non-HOT) UPDATE add an entry to every index? (recall 0001)
A lab to run later. It shows autovacuum's counters and HOT firing:
CREATE TABLE t (id int PRIMARY KEY, note text, val int);
CREATE INDEX ON t (note); -- an index on note, but NOT on val
INSERT INTO t SELECT g, 'x', 0 FROM generate_series(1,1000) g;
UPDATE t SET val = val + 1; -- changes val only (not indexed) → HOT-eligible
UPDATE t SET note = note || 'y'; -- changes note (indexed) → NOT HOT
SELECT n_dead_tup, n_tup_upd, n_tup_hot_upd
FROM pg_stat_user_tables WHERE relname='t'; -- compare hot vs total updates
n_tup_hot_upd should rise after the first UPDATE but not the
second. Try re-creating the table with WITH (fillfactor=70) and watch the
HOT ratio improve. Bring the numbers to your teacher.
PostgreSQL Docs — 24.1 Routine Vacuuming for the VACUUM/autovacuum model and the threshold formula, then the short 66.7 Heap-Only Tuples (HOT) page. For the mechanism drawn out tuple-by-tuple, Suzuki's "Internals of PostgreSQL," ch. 6 (VACUUM) and ch. 7 (HOT & index-only scans).