MVCC: row versions and snapshots
Postgres never overwrites a row. Each change writes a new version, and each reader picks the version its snapshot allows. Here are those versions on a real page, and the cleanup that versions need.
- 1An UPDATE writes a new row version and stamps the old one with xmax. Nothing changes in place.
- 2A snapshot decides which version each reader sees. Readers never wait for writers.
- 3VACUUM removes versions that no snapshot can see. The oldest open snapshot holds back cleanup for the whole database.
- 4Transaction IDs are 32 bits. VACUUM must freeze old rows before about 2 billion new IDs pass.
- A report that waits for every writer stalls the writers behind it too.
- Versions cost space and cleanup. That is the price of no read locks.
With one copy of a row, a reader must wait for a writer. With versions, the reader takes the last committed version and nobody waits.
| method | where | status |
|---|---|---|
| One copy, shared read locks | Lock-based | Readers wait |
| Read uncommitted data | Any | Not approved |
| Versions in the table, VACUUM removes old ones | Postgres | Approved |
| Newest row in place, old values in an undo log | MySQL InnoDB | Approved |
| A long open snapshot on a busy table | Postgres | Short only |
Every MVCC design pays for old versions somewhere: dead rows in the table, or a growing undo log.
Postgres keeps every version in the table and cleans up later. InnoDB keeps old values in an undo log. Both let readers skip locks.
- Each version has a 23-byte header before its data.
- A DELETE only sets xmax. The version stays until VACUUM.
- Two writers to one row still queue: the second waits for the first's transaction to end.
Each version carries xmin, the transaction that created it, and xmax, the one that replaced it. ctid points to the next version.
visible(version, snapshot):
IF xmin is my own transaction:
RETURN yes, unless I replaced it
IF xmin aborted1, OR xmin running in the snapshot:
RETURN no // its creator had not committed
IF xmax is empty, OR xmax aborted:
RETURN yes // nobody replaced it2
IF xmax running in the snapshot:
RETURN yes // replaced, but not for this reader3
RETURN no // replaced before the snapshot
running in the snapshot:
id >= snapshot.xmax, OR id in snapshot.in_progress4- 1Committed or aborted comes from the commit log, then is cached on the version as a hint bit.
- 2The common case: the newest version of a row.
- 3The replacer was still running when the snapshot was taken, so this reader keeps the old version.
- 4Transactions that were running when the snapshot was taken stay invisible, even after they commit.
Tested source Go: read every version on the page · Go: the claims each recorded run must meet
// Page reads every line pointer on page 0 of the accounts table with pageinspect. It sees all
// row versions, including ones that no snapshot can see any more.
func Page(ctx context.Context, conn *pgx.Conn) ([]Version, error) {
rows, err := conn.Query(ctx, queries["heap_page"])
if err != nil {
return nil, fmt.Errorf("read heap page: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (Version, error) {
var v Version
var flags, off, mask, mask2 int
var balance []byte
if err := r.Scan(&v.LP, &flags, &off, &v.Xmin, &v.Xmax, &v.Ctid, &mask, &mask2,
&v.XminStatus, &v.XmaxStatus, &balance); err != nil {
return v, fmt.Errorf("scan line pointer: %w", err)
}
v.State = []string{Unused, Normal, Redirect, Dead}[flags]
if v.State == Redirect {
v.To = off // a redirect stores the target line pointer in lp_off
}
if v.Xmax == "0" {
v.Xmax = ""
}
if mask&heapXminFrozen == heapXminFrozen {
v.XminStatus = "frozen"
}
v.HotUpdated = mask2&heapHotUpdated != 0
v.HeapOnly = mask2&heapOnlyTuple != 0
if len(balance) == 4 {
b := int(int32(binary.LittleEndian.Uint32(balance)))
v.Balance = &b
}
return v, nil
})
}
// checkScenario asserts the claims the sheet makes about each run.
func checkScenario(t *testing.T, id string, f []mvcc.Frame) {
t.Helper()
lp := func(fr mvcc.Frame, n int) mvcc.Version {
for _, v := range fr.Page {
if v.LP == n {
return v
}
}
return mvcc.Version{State: "missing"}
}
switch id {
case "hot":
upd := f[3]
if v1, v2 := lp(upd, 1), lp(upd, 2); v1.Xmax == "" || v1.Xmax != v2.Xmin || !v1.HotUpdated || !v2.HeapOnly || v1.Ctid != "(0,2)" {
t.Errorf("after the update: %+v and %+v, want a HOT chain (0,1) -> (0,2) with xmax = xmin", v1, v2)
}
if len(upd.Index) != 1 {
t.Errorf("a HOT update added an index entry: %v", upd.Index)
}
if r := f[4]; r.Views[0].Balance != 100 || r.Views[0].Ctid != "(0,1)" {
t.Errorf("the reader saw %+v during the update, want 100 from (0,1)", r.Views[0])
}
if r := f[6]; r.Views[0].Balance != 100 || r.Views[1].Balance != 150 {
t.Errorf("after the commit: session 1 saw %d and session 2 saw %d, want 100 and 150", r.Views[0].Balance, r.Views[1].Balance)
}
if v := lp(f[7], 1); v.State != mvcc.Normal {
t.Errorf("VACUUM with session 1 open left line pointer 1 %s, want the old version kept", v.State)
}
if v := lp(f[9], 1); v.State != mvcc.Redirect || v.To != 2 {
t.Errorf("VACUUM after session 1 ended left line pointer 1 as %+v, want a redirect to 2", v)
}
if v := lp(f[10], 2); v.XminStatus != "frozen" {
t.Errorf("VACUUM (FREEZE) left %+v, want a frozen xmin", v)
}
case "rollback":
if v := lp(f[3], 2); v.XminStatus != "aborted" {
t.Errorf("after ROLLBACK the new version is %+v, want an aborted xmin", v)
}
if r := f[4]; r.Views[0].Balance != 100 {
t.Errorf("after ROLLBACK session 1 saw %d, want 100", r.Views[0].Balance)
}
// VACUUM frees the slot. A free slot at the end of the line pointer array is cut off.
if v := lp(f[5], 2); v.State != mvcc.Unused && v.State != "missing" {
t.Errorf("VACUUM left the aborted version as %s, want it freed", v.State)
}
case "indexed":
if v := lp(f[0], 1); v.HotUpdated || len(f[0].Index) != 2 {
t.Errorf("an update of an indexed column: %+v with index %v, want no HOT and 2 entries", v, f[0].Index)
}
if len(f[1].Index) != 1 {
t.Errorf("after VACUUM the index has %v, want 1 entry", f[1].Index)
}
}
}
A snapshot is xmin, xmax and the list of running transactions. A version is visible when its creator committed before the snapshot and its replacer did not.
| lp | read by | xmin | xmax | ctid | balance |
|---|---|---|---|---|---|
| 1 | S1S2 | 730committed | · | (0,1) | 100 |
primary key entries → (0,1)
- snapshot
- 731:731:
- finished
- IDs below 731
- reads
- (0,1), balance 100
- snapshot
- 731:731:
- finished
- IDs below 731
- reads
- (0,1), balance 100
Each run starts a fresh, private Postgres, so transaction IDs and snapshots match on every recording. A third connection reads the page after each statement.
The reader keeps reading line pointer 1 while the writer creates line pointer 2. Neither waits. VACUUM removes line pointer 1 only after the reader's snapshot ends.
| tool | capability | what it gives you | also used for |
|---|---|---|---|
| Postgres | Row versions with xmin and xmax | Readers never block writers, and writers never block readers. | Long reports on a busy table |
| Postgres | Snapshots: pg_current_snapshot() | Each statement or transaction sees one consistent point in time. | REPEATABLE READ, consistent backups with pg_dump |
| Postgres | Commit log and hint bits | A version's creator is committed or aborted; the answer is cached on the row. | Instant ROLLBACK |
| Postgres | HOT updates and fillfactor | An update of unindexed columns writes no index entries. | Counters, status columns |
| Postgres | VACUUM and autovacuum | Removes dead versions and records free space for reuse. | Visibility map for index-only scans |
| Postgres | Freezing | Old rows stay visible across transaction ID wraparound. | Faster later scans of old pages |
| Postgres | pageinspect, pgstattuple | Read raw versions, dead rows and free space. | Diagnosing bloat |
| Postgres | idle_in_transaction_session_timeout | Ends a session that holds a snapshot and does nothing. | Releasing locks |
| Postgres | Old versions in the table | Limit Every update writes a full row, and dead rows take space until VACUUM runs. |
Postgres gives me versioned rows, snapshots, HOT updates and VACUUM. I must keep transactions short so VACUUM can do its job.
- Leave room on each page: fillfactor 70 to 90 for tables with many updates.
- Do not index a column that changes often unless queries need it.
- In our lab, 16 clients ran about 39,000 updates a second either way. HOT saves index growth and VACUUM work, not commit time.
If no indexed column changes and the page has room, the update is HOT: no new index entries, and the old slot becomes a redirect.
vacuum(table):
horizon = oldest xmin of every open snapshot1
FOR EACH page that may hold dead versions2:
dead = xmax committed before horizon, OR xmin aborted
remove index entries that point at dead versions
free their slots; a HOT chain keeps a redirect
record the free space // the next writes reuse it3
freeze versions older than vacuum_freeze_min_age4
cut empty pages off the end5 of the file- 1One old snapshot in the database holds back cleanup in every table.
- 2The visibility map marks pages where every version is visible to all. VACUUM skips them.
- 3The table does not shrink. New versions fill the free space first.
- 450,000,000 transactions by default.
- 5Only trailing empty pages go back to the file system. VACUUM FULL rewrites the whole table.
Tested source Go: the claims the bloat run must meet
// The claims on the sheet.
if upd.Dead != 100000 || upd.Bytes < load.Bytes*19/10 {
t.Errorf("one update of every row: %d dead, %d bytes; want 100,000 dead and about twice %d bytes", upd.Dead, upd.Bytes, load.Bytes)
}
if vac.Dead != 0 || vac.Bytes != upd.Bytes {
t.Errorf("VACUUM: %d dead and %d bytes, want 0 dead and the size unchanged at %d", vac.Dead, vac.Bytes, upd.Bytes)
}
if again := at("update-again"); again.Bytes != vac.Bytes {
t.Errorf("the second update grew the table from %d to %d bytes; want it to reuse the freed space", vac.Bytes, again.Bytes)
}
if held := at("vacuum-held"); held.Dead != 100000 {
t.Errorf("VACUUM with an open snapshot left %d dead versions, want all 100,000 kept", held.Dead)
}
if grown := at("update-held-again"); grown.Dead != 200000 || grown.Bytes < at("vacuum-held").Bytes*14/10 {
t.Errorf("a second update with the snapshot open: %d dead, %d bytes; want 200,000 dead and a bigger table", grown.Dead, grown.Bytes)
}
if free := at("vacuum-free"); free.Dead != 0 {
t.Errorf("VACUUM after the snapshot closed left %d dead versions, want 0", free.Dead)
}
if full := at("full"); full.Bytes != load.Bytes {
t.Errorf("VACUUM FULL left %d bytes, want the loaded size %d", full.Bytes, load.Bytes)
}| setting | default |
|---|---|
| autovacuum_vacuum_threshold | 50 |
| autovacuum_vacuum_scale_factor | 0.2 |
| autovacuum_naptime | 60s |
| autovacuum_vacuum_cost_delay | 2ms |
| vacuum_cost_limit | 200 |
| autovacuum_max_workers | 3 |
A 10 million row table waits for 2,000,050 dead rows: 50 + 0.2 × 10,000,000.
VACUUM frees the space of versions that no open snapshot can see. Autovacuum runs it when dead rows pass 50 plus 20% of the table.
- Update every row once: 14.1 MB became 28.3 MB, with 100,000 dead versions.
- VACUUM: 0 dead, same 28.3 MB, 50% free. The next update fit in that space.
- VACUUM FULL: back to 14.1 MB. It rewrites the table and blocks every query while it runs.
One update of every row doubled the table. VACUUM did not shrink it, but the next update reused the space. Only an open snapshot made the table grow again.
- VACUUM
- removed 0 of 100,000 dead versions while session 2 held a snapshot.
- growth
- one more update grew the table from 28.3 MB to 42.4 MB.
- after
- session 2 committed; VACUUM removed all 200,000.
- An idle session in a transaction holds the horizon as well. Set idle_in_transaction_session_timeout.
- A replica with hot_standby_feedback on holds it for its own queries.
- An unused replication slot and an old prepared transaction hold it too.
- Find the holder: the oldest backend_xmin in pg_stat_activity.
An open snapshot keeps every version it might need. VACUUM cannot remove them, so the table grows until the transaction ends.
- A frozen row is visible to every snapshot. The recorded run shows it after VACUUM (FREEZE).
- Watch age(datfrozenxid) per database. Alert well before 200,000,000.
- At 10,000 transactions a second, 2.1 billion IDs last about 2.5 days.
Transaction IDs wrap around after about 4 billion, so each row can be at most 2 billion transactions old. VACUUM freezes rows long before that.
- InnoDB: a long transaction makes the undo history grow, and purge falls behind.
- InnoDB: a rollback must apply undo records, so a large rollback is slow.
- Postgres: a rollback is instant, but the dead versions stay until VACUUM.
Source: the MySQL 8.0 reference manual, InnoDB multi-versioning and undo logs. Not measured in our lab.
InnoDB keeps the newest row in place and old values in an undo log. Postgres keeps all versions in the table and cleans up with VACUUM.
| event | result | what stops it | saved by |
|---|---|---|---|
| A session stays idle inside a transaction for hours | VACUUM removes nothing newer than its snapshot. Tables and indexes grow. | idle_in_transaction_session_timeout ends it. Alert on the oldest backend_xmin. | Idle timeout |
| A long report on the primary | Same: the horizon stops for the whole database. | Run reports on a replica. Watch hot_standby_feedback, which sends the horizon back. | Replica |
| Autovacuum cannot keep up with a hot table | Dead rows pile up; scans slow down. | Lower the table's scale factor, raise its cost limit. Our lab removed 60,000 versions a second at the default throttle. | Per-table settings |
| Every update changes an indexed column | No HOT. Every index grows with every update. | Drop indexes that queries do not use. Leave free space with fillfactor. | HOT |
| Transaction IDs near wraparound | Postgres refuses new transaction IDs. Writes stop. | Autovacuum freezes at age 200,000,000. Do not cancel those runs. | Freeze |
| An unused replication slot or old prepared transaction | It holds the horizon forever. | Drop the slot; commit or roll back the prepared transaction. | Monitoring |
| The table is already bloated | Space is not returned to the file system. | VACUUM FULL takes ACCESS EXCLUSIVE. Use an online rewrite tool, or partition and drop. | Rewrite |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | Autovacuum on, default settings. | Most tables. At the default throttle our lab removed about 60,000 dead versions a second. | Dead rows grow while one session stays open: the horizon is stuck. |
| 2 | Short transactions: idle_in_transaction_session_timeout, statement_timeout, reports on a replica. | The horizon moves, so every VACUUM can remove what is dead. | One hot table's dead rows still pass 20% before autovacuum starts. |
| 3 | Per-table autovacuum settings: lower scale factor, higher cost limit. | Unthrottled, VACUUM removed about 890,000 versions a second, 15 times the default rate. | Indexes grow faster than the table: updates are not HOT. |
| 4 | HOT: fillfactor below 100, no index on columns that change often. | Updates write no index entries. In our lab, 100% of balance updates were HOT at fillfactor 80. | Old rows are deleted in bulk by date, and VACUUM must scan them all. |
| 5 | Partitions by time. Drop or detach a whole partition. | Dropping a partition leaves no dead rows at all. | Top of the ladder. |
Created: 16 clients updating random rows of a 100,000-row table. Removed: VACUUM of 1,000,000 dead versions, unthrottled and with the autovacuum cost settings. When creation passes removal, dead rows pile up.
I keep autovacuum on with defaults and transactions short. I tune per table when dead rows grow, and I partition when deletes by age dominate.
0 of 9 known
An UPDATE changes one column of one row. What does Postgres write to the table?
A long report reads a row while another transaction updates it. Does either wait?
A snapshot is 731:731:. Transaction 731 then updates a row and commits. What does this snapshot see?
Session 1 holds a REPEATABLE READ snapshot. You run VACUUM. Why does the old version stay?
A transaction rolls back after it updated 1 million rows. How long does the ROLLBACK take?
When is an update HOT, and what does it save?
After VACUUM the table is still 28 MB. Is VACUUM broken?
Why must Postgres freeze old rows?
How does MySQL InnoDB keep old versions?
- update
- One update of every row doubled the table: 14.1 MB to 28.3 MB.
- VACUUM
- About 890,000 dead versions a second unthrottled; about 60,000 at the autovacuum throttle.
- trigger
- Autovacuum starts at 50 + 20% of rows dead.
- header
- 23 bytes per row version, plus a 4-byte line pointer.
- freeze
- Forced at age 200,000,000. Wraparound at about 2.1 billion.
- long txn
- Held 200,000 dead versions and grew the table by half.
Postgres 16 on an 8-core laptop. Bloat runs on a private server with autovacuum off; rates on the lab server, 16 clients, 3-second runs. Use these as orders of magnitude.