Databases
Part 1 of 6 · MVCC & isolationMVCC, Snapshot Isolation & Write Skew
xmin/xmax visibility; SI vs SSI; write skew; SERIALIZABLE + 40001 retry; FOR UPDATE; VACUUM/bloat.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
- 1
simple CRUD → fresh statement snapshot
Stay on READ COMMITTED
Cheap. Dirty reads are gone. Non-repeatable reads and write skew are allowed. Fine for catalog browsing, wrong for multi-row money invariants.
- 2
first statement → one snapshot
SI (Postgres REPEATABLE READ)
Same-row lost updates abort. Multi-row invariants still write-skew. This is the hub's default mental model.
- ?
Winner for multi-row rules: SSI, FOR UPDATE, or a constraint
SERIALIZABLE aborts the dangerous rw-cycle (
40001→ retry whole txn).SELECT … FOR UPDATEserializes the read set. A CHECK / EXCLUDE / guard row makes the invariant physical. Pick the cheapest that actually closes the hole — details on the sibling pages.
Overview
Almost every modern OLTP database (PostgreSQL, MySQL InnoDB, Oracle, SQL Server row-versioning, Cockroach, TiDB) uses Multi-Version Concurrency Control. Senior interviews love asking how readers avoid blocking writers, what isolation you actually get, and why REPEATABLE READ still lets write skew through.
If you only memorize "ACID" without MVCC mechanics, you will miss production bugs and interview depth questions.
By the end of this lesson you should be able to:
- Explain how
xmin/xmaxdecide visibility under a snapshot - Contrast READ COMMITTED vs SI vs SSI
- Reproduce the classic write-skew anomaly and fix it
- Reason about VACUUM / GC as the cost of MVCC
- Write application-level retry loops for serialization failures (
40001)
Versions, not locks (for reads)
Under lock-based 2PL, a writer holds an exclusive lock and readers wait. Under MVCC:
- A write creates a new physical version of a logical row (or marks the old one deleted)
- A read picks the newest version that was committed as of the transaction's snapshot
- Result: readers do not block writers; writers do not block readers
- Writers may still block other writers on the same row (first-writer-wins / row locks)
This is why analytics queries on hot OLTP tables often "just work" without stopping the write path — they read older committed versions.
PostgreSQL visibility (xmin / xmax)
Each heap tuple carries system columns:
| Column | Meaning |
|---|---|
xmin | inserting transaction ID (XID) for this row version |
xmax | deleting / updating XID (0 if live) |
ctid | physical location of this version (changes on update / VACUUM FULL) |
Visibility is not "xmin less than my xid". The engine consults the transaction's snapshot: which XIDs were committed, in-progress, or aborted at snapshot time.
- Your own in-progress writes are visible to you
- Uncommitted writes from others are not
UPDATE = insert a new version + set xmax on the old one. DELETE = set xmax (no physical remove yet). VACUUM later reclaims versions no longer visible to any snapshot.
A simplified teaching rule: a version is visible to snapshot S if its inserter is committed as of S (or is you), and its deleter is not committed as of S (and is not you deleting it).
Flow
- 1
xmin=10 xmax=20 on_call=true
- 2
xmin=20 xmax=0 on_call=false
- 3
Snapshot xmax=15
- nextxmin=10 xmax=20 on_call=true
- 4
Snapshot xmax=25
- nextxmin=20 xmax=0 on_call=false
Lesson map
MVCC, Snapshot Isolation & Write Skew
xmin/xmax visibility; SI vs SSI; write skew; SERIALIZABLE + 40001 retry; FOR UPDATE; VACUUM/bloat.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["Txn A SI"] b["Txn B SI"] d["doctors"] a -->|read Alice on,| d b -->|read Alice on,| d a -->|Alice off| d b -->|Bob off| d a -->|COMMIT| d b -->|COMMIT| d
ctid is not a stable row id — it changes on update and rewrite.
Snapshots and isolation levels
READ COMMITTED (Postgres default)
Each statement takes a new snapshot. You never see dirty (uncommitted) data, but two SELECTs in the same txn can disagree (non-repeatable reads). Fine for simple CRUD when that is acceptable.
Snapshot Isolation (SI)
When a transaction starts (or, depending on engine, on first statement), it gets a consistent snapshot:
- Sees only data committed before the snapshot
- Never sees partial results of concurrent writers
- Concurrent updates to the same row: first committer wins; the loser aborts or retries (lost-update prevention on that row)
PostgreSQL's REPEATABLE READ implements SI (stronger than the weak ANSI RR definition). MySQL InnoDB's RR is closer to SI for many workloads but has different gap-lock behavior — do not assume engines match ANSI labels 1:1.
SI prevents dirty reads, non-repeatable reads on the same row, and lost updates on the same row. It does not guarantee a schedule equivalent to some serial order of transactions. The classic hole is write skew.
Serializable Snapshot Isolation (SSI)
PostgreSQL SERIALIZABLE runs SI plus tracking of read-write dependencies (SIREAD / predicate locks). If the dependency graph contains a dangerous structure (potential anomaly), one txn aborts with SQLSTATE 40001 (serialization_failure). You must retry the whole transaction from the start.
Tradeoffs:
- Near-SI performance for many workloads; some false positives
- Coarse predicate locks (page/table) increase abort rates
READ ONLY DEFERRABLEcan wait for a conflict-free snapshot (great for reports)
| Level | Snapshot | Same-row lost update | Multi-row write skew | App duty |
|---|---|---|---|---|
| READ COMMITTED | per statement | possible without extra care | possible | weakest; OK for simple CRUD |
| REPEATABLE READ (PG = SI) | per txn | prevented | allowed | locks / constraints if invariant spans rows |
| SERIALIZABLE (PG = SSI) | per txn + rw checks | prevented | aborted (40001) | retry entire txn |
Write skew: doctors on call
Invariant: at least one of Alice or Bob must remain on_call = true.
- t1: Txn A and Txn B both
BEGINand read both rows (Alice on, Bob on) - t2: Each sees at least one on-call; A sets Alice off; B sets Bob off
- t3: Both
COMMIT
Each txn updated a different row, so no write-write conflict. Final state: both off. No serial order produces that. That is write skew.
Sequence
- 1
Txn A SI
both take the same snapshot
- 2
Txn A SI → doctors
read Alice on, Bob on
- 3
Txn B SI → doctors
read Alice on, Bob on
- 4
Txn A SI → doctors
Alice off
- 5
Txn B SI → doctors
Bob off
- 6
Txn A SI → doctors
COMMIT
- 7
Txn B SI → doctors
COMMIT
- 8
doctors
both off — write skew
Other shapes: two transfers checking "sum greater than or equal to 0" then debiting different accounts; two inventory adjusters each checking "total stock greater than or equal to N".
Fixes (pick one):
SELECT ... FOR UPDATEon the read set (serialize the decision)SERIALIZABLE(SSI) + retry on40001- A constraint that materializes the invariant (guard row /
CHECK)
Architecture
Flow
- 1
Client apps
- nextConnection / pooler
- 2
Connection / pooler
- nextTxn manager: XID plus snapshot
- 3
Txn manager: XID plus snapshot
- nextBuffer pool / heap pages
- nextIndexes: TID plus MVCC visibility
- nextWAL: durability of commits
- 4
Buffer pool / heap pages
- nextVacuum / GC worker
- 5
Indexes: TID plus MVCC visibility
- nextVacuum / GC worker
- 6
WAL: durability of commits
- 7
Vacuum / GC worker
The txn manager assigns an XID and a snapshot (xmin horizon, xmax, in-progress XID list). WAL is orthogonal but paired with MVCC. Vacuum reclaims dead versions and freezes old XIDs.
Failure modes and ops
- Table bloat: heavy
UPDATE/DELETEwithout vacuum means dead versions pile up, seq scans slow, disk grows - Long-running transactions / idle-in-transaction: hold back the vacuum horizon; everyone pays
- XID wraparound: 32-bit XIDs must be frozen; autovacuum can force aggressive vacuum or refuse writes if ignored
- Index bloat: indexes also retain dead entries until vacuumed
- Hot rows: writers still serialize on the same logical row; MVCC does not magically make contended counters scale
Scaling levers: shorter transactions, SELECT ... FOR UPDATE / advisory locks where invariants span rows, SERIALIZABLE + retry for correctness-critical paths, partitioning to shrink vacuum scope, read replicas for stale-ok reads (never money invariants).
Flow
- 1
UPDATE
- nextold version xmax set
- nextnew version xmin set
- 2
old version xmax set
- nextVACUUM
- 3
new version xmin set
- 4
VACUUM
- nextspace reusable
- 5
space reusable
- 6
long-running txn
- relatedVACUUM
Choosing isolation in production
- Simple CRUD, tolerate non-repeatable reads: READ COMMITTED (Postgres default)
- Multi-statement consistency, protect same-row lost updates: REPEATABLE READ (SI)
- Multi-row invariants (balances, quotas, "at least one"): SERIALIZABLE or explicit locks / constraints
- Long read-only report: REPEATABLE READ / snapshot +
READ ONLY; or a replica
Senior answer pattern: prefer the weakest level that preserves the invariant. If the invariant spans multiple rows and contention is low, SERIALIZABLE + retry is often cleaner than hand-rolled locking. If contention is high on a small key set, use row locks or a single "constraint row" / SELECT ... FOR UPDATE.
Full ANSI-vs-engine matrix: Isolation levels deep dive. Dangerous rw-cycles and 40001 retries: SSI vs Snapshot Isolation. Lock wait policies (NOWAIT, SKIP LOCKED): Row locks SELECT FOR UPDATE. Vacuum, freeze, wraparound: VACUUM, bloat & XID wraparound. Phantoms vs write skew as two different holes: Phantoms vs write skew.
Application patterns
A. Explicit lock to prevent write skew
# Sketch — psycopg. Lock both doctors before deciding.
def go_off_call(conn, doctor_id, peer_id):
with conn.transaction():
rows = conn.execute(
"""
SELECT id, on_call FROM doctors
WHERE id IN (%s, %s)
FOR UPDATE
""",
(doctor_id, peer_id),
).fetchall()
by_id = {r["id"]: r["on_call"] for r in rows}
others_on = any(on for i, on in by_id.items() if i != doctor_id)
if not others_on:
raise RuntimeError("cannot leave: peer not on call")
conn.execute(
"UPDATE doctors SET on_call = false WHERE id = %s",
(doctor_id,),
)// Sketch — node-postgres. BEGIN, FOR UPDATE, then UPDATE, then COMMIT.
await client.query("BEGIN");
const { rows } = await client.query(
`SELECT id, on_call FROM doctors
WHERE id = ANY($1::int[])
FOR UPDATE`,
[[doctorId, peerId]]
);
const peerOn = rows.some((r) => r.id === peerId && r.on_call);
if (!peerOn) throw new Error("cannot leave: peer not on call");
await client.query(`UPDATE doctors SET on_call = false WHERE id = $1`, [doctorId]);
await client.query("COMMIT");B. SERIALIZABLE + retry (preferred when many multi-row invariants)
Retry must re-run all reads and writes of the logical unit of work. Never commit partial work and retry only the failing statement. Add jitter in production.
# Sketch — catch SerializationFailure, backoff, retry the whole txn.
def with_serializable_retry(conn, fn, *, max_attempts=8):
delay = 0.01
for attempt in range(1, max_attempts + 1):
try:
with conn.transaction():
conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
return fn(conn)
except SerializationFailure:
if attempt == max_attempts:
raise
time.sleep(delay)
delay = min(delay * 2, 0.5)// Sketch — SQLSTATE 40001: ROLLBACK, backoff, new BEGIN ISOLATION LEVEL SERIALIZABLE.
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await client.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK").catch(() => undefined);
if (err?.code !== "40001" || attempt === maxAttempts) throw err;
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(delayMs * 2, 500);
}
}C. Constraint-enforced invariant (often best)
-- Materialize count on call and reject zero with a CHECK,
-- or use a singleton guard row.
CREATE TABLE on_call_guard (
id int PRIMARY KEY CHECK (id = 1),
on_call_count int NOT NULL CHECK (on_call_count >= 1)
);
-- Each go-off-call decrements under row lock; CHECK fails if it would hit 0.Inventory "never sell below zero": prefer a single-row atomic predicate, not check-then-act:
UPDATE stock SET qty = qty - $1 WHERE id = $2 AND qty >= $1 RETURNING qty;For multi-SKU carts: one transaction with locks on ordered SKU ids, or SERIALIZABLE + retry.
In-memory snapshots (run this)
A tiny heap + snapshot visibility function. SI lets both doctors commit; locking / running B after A preserves the invariant; SSI detects a dangerous rw-cycle.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Deep dive · MVCC vs app-level OCC
Database MVCC is storage-level versioning + snapshots. App optimistic concurrency is often a version column / ETag: UPDATE ... WHERE id = ? AND version = ?. Both detect write-write conflicts; neither alone fixes multi-row write skew without extra locking, SSI, or constraints.
Interview Q&A
How does PostgreSQL let readers avoid blocking writers?
Answer
MVCC keeps multiple row versions. A reader uses its snapshot to pick a committed version with xmin visible and xmax not yet committed (from its point of view). Writers append new versions / set xmax instead of overwriting in place under a global read lock. Readers still wait for writers on the same row if they need a version that is not yet committed (SELECT … FOR UPDATE / tuple lock), but typical SELECT does not block UPDATE.
What do xmin and xmax mean on a heap tuple?
Answer
xmin is the inserting transaction's XID. xmax is 0 (still live) or the XID that deleted/updated this version. Visibility: a snapshot sees a version if xmin committed before the snapshot and xmax is not a committed-before-snapshot deleter. This is the whole “readers don’t block writers” trick — old versions stay until no snapshot can see them.
Does REPEATABLE READ prevent write skew in Postgres?
Answer
No. Postgres REPEATABLE READ is Snapshot Isolation, which is weaker than true serializability. Two transactions can each read a disjoint set, both decide an invariant still holds, and both commit — write skew. Use SERIALIZABLE (SSI), SELECT … FOR UPDATE on the read set, or a constraint/guard row that materializes the invariant.
Walk through write skew with the on-call doctors.
Answer
Invariant: at least one of Alice or Bob is on call. Both transactions take the same snapshot, both see two-on, each updates a different row to off, both commit. SI is happy (no same-row lost update); the invariant is dead. Same shape as two transfers checking “sum ≥ 0” then debiting different accounts. Say the anomaly name, then the fix.
What is SQLSTATE 40001 and what should the app do?
Answer
Serialization failure under SSI (or deadlock in some engines). Roll back and retry the entire transaction from the first statement, with backoff and a retry budget. Treat it as expected under contention, not a rare crash. Do not retry a single statement inside a half-committed txn.
When do you pick READ COMMITTED vs REPEATABLE READ vs SERIALIZABLE?
Answer
READ COMMITTED (Postgres default): simple CRUD that can tolerate non-repeatable reads. REPEATABLE READ (SI): multi-statement consistency and same-row lost-update protection. SERIALIZABLE (or explicit locks / constraints): multi-row invariants (balances, “at least one”, quotas). Prefer the weakest level that preserves the invariant; if contention is high on a small key set, row locks often beat SSI abort storms.
Why does VACUUM matter for MVCC?
Answer
Dead versions stay until no snapshot can see them. Vacuum reclaims heap/index space and freezes old XIDs to prevent wraparound. Autovacuum lag or long txns cause bloat, slower seq scans, and eventually wraparound protection that refuses writes.
How do long-running / idle-in-transaction sessions hurt everyone else?
Answer
They hold back the vacuum horizon: dead tuples that this snapshot might still see cannot be removed, so every other session pays in bloat and cache pressure. idle_in_transaction_session_timeout, short transactions, and not holding a txn open while waiting on a user are operational musts — not just style.
MVCC vs optimistic concurrency (OCC) in app code?
Answer
DB MVCC is storage-level versioning + snapshots. App OCC is often a version column / ETag checked at write time. Both detect write-write conflicts on the same row; neither alone fixes multi-row write skew without extra locking, SSI, or constraints. You can (and often should) use both: MVCC in the engine, version columns for HTTP / lost-update across requests.
How would you design inventory never-sell-below-zero under concurrency?
Answer
Prefer a single-row atomic predicate: UPDATE stock SET qty = qty - $1 WHERE id = $2 AND qty >= $1 RETURNING qty. Zero rows means sold out — no check-then-act. For multi-SKU carts, one transaction with locks on ordered SKU ids or SERIALIZABLE + retry. Never “SELECT qty; if qty > 0 UPDATE” under SI without locks.
Pitfalls
Create doctors(id, name, on_call boolean) with two rows both true. Open two psql sessions at REPEATABLE READ and reproduce write skew (both go off call). Repeat under SERIALIZABLE and observe one abort with 40001. Fix with FOR UPDATE under RR and confirm the invariant holds. Run SELECT xmin, xmax, * FROM doctors before/after updates and note version churn. Optional: watch n_dead_tup in pg_stat_user_tables.
Go Deeper
Official docs
- PostgreSQL — Transaction Isolation
- PostgreSQL — System Columns (
xmin/xmax) - PostgreSQL — Routine Vacuuming
- PostgreSQL Wiki — SSI
- InterDB — Concurrency Control (SSI, rw-conflicts)
Papers and lectures
- Ports and Grittner, Serializable Snapshot Isolation in PostgreSQL (VLDB 2012)
- Berenson et al., A Critique of ANSI SQL Isolation Levels (SIGMOD 1995) — why write skew sits outside the ANSI phenomena list
- CMU 15-445 — Multi-Version Concurrency Control (Andy Pavlo); course hub: 15445.courses.cs.cmu.edu
One-line takeaway: MVCC buys non-blocking reads via versions; Snapshot Isolation still allows write skew on multi-row invariants — close that gap with SSI retries, precise locks, or constraints, and keep transactions short so vacuum can keep up.