Crash Recovery
On restart the engine has a log and a set of page images that lag behind it by an unknown amount. Recovery finds the last checkpoint, scans the log forward to learn which transactions committed, replays every change whose page does not yet have it (redo, made idempotent by page LSNs), rolls back the transactions that never committed (undo — or, in PostgreSQL, nothing), and opens for business.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
The machine is back. On disk: a log ending wherever the last fsync reached, and data pages that reflect some prefix of it — different prefixes for different pages. Which changes are missing from which pages, and which of them should be there at all?
↓ - Naive solution
Replay the whole log from the beginning onto the data files. Every change is reapplied, so every page ends up current.
↓ - Why it breaks
The log is gigabytes; replaying from the start takes hours. Reapplying "balance − 100" to a page that already has it gives the wrong answer. And the log contains changes from transactions that never committed — replaying them puts uncommitted data into the committed state.
↓ - Better idea
Start from the last checkpoint, not the beginning. Let each page say what it already contains, so replay can skip. Decide per transaction, from the log, whether it committed — then keep those and reverse the rest.
↓ - Internal mechanism
ARIES: analysis (scan forward from the checkpoint; build the table of transactions and their status and the set of possibly-dirty pages), redo (repeat history from the earliest dirty-page LSN, applying each record only if page LSN < record LSN), undo (walk each loser transaction's records backward, restoring before-images and logging compensation records so undo itself is restartable).
↓ - Trade-offs
Recovery time is bounded by the checkpoint interval; frequent checkpoints buy fast restarts with more page writes. Undo needs before-images in the log or an undo log; MVCC engines that keep old versions in place can skip undo entirely, at the cost of vacuuming later.
↓ - Real database
PostgreSQL: redo from the last checkpoint's redo pointer, no undo phase — a loser's tuples are simply never marked committed in
pg_xact. InnoDB: redo from the checkpoint LSN, then undo of active transactions from the undo tablespaces, rolling back in the background after the server opens.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
After a crash the database files are inconsistent: some changes made it to disk, some did not, and some that made it belong to transactions that never finished. The log knows the truth. Recovery reads it, brings every page up to date with the changes that committed, and removes the effects of transactions that did not.
It starts from the last checkpoint — a marker saying "everything before this is already on disk" — so it never has to read the whole history. And it is careful not to apply a change twice, because "subtract 100" applied twice is wrong.
Restart: what the engine finds
The process starts with an empty buffer pool. On disk: the log, intact up to the last fsync (a CRC on each record identifies the point where a crash truncated a partially written one), and the data files, in which each page carries the LSN of the last change written into it. Nothing else — no memory of which transactions were running, which pages were dirty, or which frames held what. The data files may be arbitrarily behind the log, and unevenly: page 7 was flushed by the background writer just before the crash and is current, page 3 was last written at the previous checkpoint and lacks two committed changes, page 12 was flushed mid-transaction and contains a change that never committed.
Recovery's job is to produce, from those two inputs, exactly the state of every committed transaction and nothing of any uncommitted one — and to do it in bounded time, and to survive crashing again halfway through.
atlas.wal (durable through LSN 9)
LSN 1 T1 BEGIN
LSN 2 T1 UPDATE page 3 120 → 220
LSN 3 T1 COMMIT
LSN 4 CHECKPOINT active = {} ← recovery starts here
LSN 5 T2 BEGIN
LSN 6 T2 UPDATE page 7 500 → 400
LSN 7 T2 COMMIT
LSN 8 T3 BEGIN
LSN 9 T3 UPDATE page 12 900 → 870 (T3 never committed)
~~~~~~ crash ~~~~~~
atlas.db pages: page 3 = 220 (LSN 2) ✓ current (written by the checkpoint)
page 7 = 500 (LSN 0) ✗ missing T2's committed change
page 12 = 870 (LSN 9) ✗ contains T3's uncommitted changeFind the recovery point: the last checkpoint
The control file (PostgreSQL pg_control, InnoDB's log header) records the LSN of the last completed checkpoint. Everything dirtied before that checkpoint was written to disk as part of it, so no record before it can be missing from any page; recovery reads the log from there. In the example that means LSN 4: T1's records at LSNs 1–3 are never read, and page 3 is trusted as current because the checkpoint guarantees it.
With fuzzy checkpoints the start point can be earlier than the checkpoint record: the record lists the pages that were still dirty when it was taken and the LSN at which each was first dirtied, and redo begins at the smallest of those. Either way the log before the start point is dead weight and has been, or can now be, recycled. The checkpoint interval is therefore the knob that bounds recovery time: checkpoint_timeout five minutes means at most five minutes of log to replay — plus whatever was written during a slow checkpoint.
Analysis: which transactions committed?
The analysis pass reads forward from the start point and builds the transaction table: every transaction id it sees, with status committed (a COMMIT record was found), aborted (an ABORT record), or active (neither — the transaction was in flight at the crash and is a loser). It also builds the dirty page table: every page mentioned by a record after the checkpoint, with the first LSN that touched it. Transactions listed as active in the checkpoint record are seeded into the table so that a transaction which started before the checkpoint and never committed is still recognised as a loser.
In the example: T2 has a COMMIT at LSN 7 — winner. T3 has BEGIN and UPDATE but no COMMIT — loser. T1 is not in the scanned range at all, which is correct: everything about it is already on disk. The committed set is what redo must guarantee; the loser set is what undo must remove.
transaction table dirty page table T2 committed (LSN 7) page 7 first LSN 6 T3 active → loser page 12 first LSN 9 redo range: LSN 6 … 9 undo list: T3 (records at LSN 9, walked backward)
Redo: repeat history, idempotently
Redo walks the range forward and, for every record that changes a page, fetches the page and compares LSNs. Page 7 carries LSN 0 and the record is LSN 6: apply the after-image (balance 400), stamp the page with LSN 6. Page 12 carries LSN 9 and the record is LSN 9: the page already contains the change — skip. That comparison is what makes redo idempotent: run recovery twice, or crash during it and run it again, and every record is applied exactly once per page, because a page that has it says so. Without the page LSN, "balance − 100" replayed twice would take 200 and nobody would know.
Notice that redo applies T3's change if the page lacks it, even though T3 will be undone. This is ARIES's "repeat history": after redo the pages are exactly what they were in memory at the crash, and undo then works from a known state using the same code path it uses for a normal ROLLBACK. Redo is also the code path of a streaming replica — it receives the same records and applies them with the same LSN comparison — which is why Replication Internals: WAL Shipping, LSNs, Lag and Failover is this lesson with a network in the middle.
1for record in log[start_lsn ..]:2 if record.kind not in (UPDATE, CLR): continue3 page = read(record.page) # from storage into the pool4 if page.lsn >= record.lsn: # already there — idempotence5 continue6 apply(page, record.after_image) # deterministic given page + record7 page.lsn = record.lsn8 page.dirty = true # flushed at the end of recoveryUndo: losers, before-images and compensation records
Undo walks each loser's records backward — newest first, following each record's back-pointer to the previous record of the same transaction — and reverses them: restore the before-image (page 12: 870 → 900). Each reversal is itself logged as a compensation log record (CLR) carrying the undo as a redo-able change plus a pointer to the next record still to undo. If the machine crashes during undo, the next recovery's redo pass replays the CLRs (they are ordinary records to it), and its undo pass resumes from the pointer, never undoing the same record twice and never undoing a CLR. When a loser's records are exhausted, an ABORT record closes it. This is the whole of ARIES in one paragraph: analysis to learn the state, redo to repeat history, undo to remove losers, with every action expressed as a log record so recovery is itself recoverable.
Whether undo is needed at all depends on how the engine updates rows. An engine that overwrites in place (InnoDB) must physically restore before-images, and keeps them in the undo log for the purpose. An engine that writes new versions and leaves the old ones (PostgreSQL) has nothing to restore: a loser's new tuple versions carry a transaction id that will never be marked committed, so they are invisible to everyone, and VACUUM removes them later. Both are correct; one pays at recovery, the other pays in dead tuples — see MVCC Internals: Version Chains and Snapshots and UPDATE, DELETE and Dead Tuples.
| Phase | ARIES / InnoDB | PostgreSQL |
|---|---|---|
| Start point | Checkpoint LSN in the redo log header; min recovery LSN of dirty pages | Redo pointer of the last checkpoint in pg_control |
| Torn pages | Doublewrite buffer restores the page first | Full-page image at first touch after the checkpoint is replayed first |
| Redo | Physiological records applied if page LSN < record LSN | Resource-manager redo per record, same LSN test |
| Undo | Roll back active transactions from the undo log; runs in the background after opening | None: uncommitted xids stay unmarked in pg_xact, their tuples are invisible |
| Left behind | Undo log pages to purge | Dead tuple versions for VACUUM |
| Open for business | After redo (undo continues in background) | After redo |
Open for business, and what it cost
The dirty pages produced by redo and undo are flushed (or left for the checkpointer — they are protected by the log either way), an end-of-recovery checkpoint is written so the next restart starts here, and the engine accepts connections. In the example the final state is page 3 = 220, page 7 = 400, page 12 = 900: exactly the committed transactions T1 and T2, nothing of T3 — and the client that waited for T3's COMMIT never received it, so nothing was promised and nothing is lost.
The cost of recovery is the cost of reading the log since the checkpoint and touching every page it mentions — usually seconds to a few minutes. The engineering decision that controls it is the checkpoint interval; the engineering mistakes that inflate it are a huge max_wal_size with no time-based checkpoint, a crash during a long-running bulk load (minutes of undo in InnoDB, a large dead-tuple cleanup in PostgreSQL), and storage whose fsync lied, which produces a log that says a page is current when it is not — the one failure recovery cannot repair.
Key points
- Recovery has two inputs: the durable log and the page images, each page tagged with the LSN of its last applied change.
- Start at the last checkpoint: everything before it is already on disk, so the checkpoint interval bounds recovery time.
- Analysis builds the transaction table (winners, losers) and the dirty page table from one forward scan.
- Redo repeats history for every record, skipping pages whose LSN is already ≥ the record LSN — that comparison is what makes it idempotent and restartable.
- Undo reverses losers newest-first using before-images and logs CLRs so a crash during recovery is safe; PostgreSQL skips undo because old versions stay in place and losers are simply never marked committed.
Crash recovery: restart and replay
T1 committed — the client saw OK — but neither data page was written. The only copy of the transfer is in the log.
- →
- →
- →
- →
- →
When to use — and when not
- The ARIES shape fits any engine with a write-back buffer pool and a write-ahead log — including ones that update in place (InnoDB, SQL Server, Oracle) and ones that version (PostgreSQL, minus undo).
- Understanding it fits when reasoning about restart time, replica lag (the same redo), or what a "crash-consistent" snapshot actually restores to.
- Full ARIES is more than an engine needs when it never overwrites data (an append-only LSM with immutable SSTables replays its memtable log and discards partial files; no undo, no page LSNs).
- It does not apply to caches and derived stores that are rebuilt from a source of truth rather than recovered.
Failure modes
- Recovery taking twenty minutes because max_wal_size was raised and checkpoint_timeout never lowered; the outage is the restart, not the crash.
- Restoring data files from a snapshot without the WAL between the checkpoint and the snapshot: pages from different LSNs with no log to reconcile them.
- Storage that acknowledged writes it had not made: the log says a page is current, the page is old, and redo skips it — silent corruption recovery cannot detect.
- Assuming a crash mid-transaction "lost" data: it lost nothing that was promised, and the client that never got COMMIT must retry.
- InnoDB reopening while a huge rollback runs in the background, then being killed again, restarting the rollback from the start each time.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- Operating SystemsJournal replay on mount (fsck-free recovery) → WAL redo on restart
- DSAIdempotent operations and monotonic sequence numbers → Page LSN ≥ record LSN → skipA monotonic position makes "already applied" a single comparison — the same trick as a high-water mark over a prefix.
- Distributed SystemsLog replay in Raft followers → Redo on a streaming replica