Chris Collins

Lab · Exploration

How a database survives a crash

Pull the plug on a database halfway through a write and it comes back with every committed transaction and none of the half-finished ones. How? Follow a write through the log, the buffer pool and the disk, compare B+trees with LSM-trees, watch compaction, and then see the other half of the promise — what concurrent transactions are allowed to see — with real PostgreSQL and SQLite running between the paragraphs.

Part 3 of 7 in How things work

· 14 min read

UsesPostgres playgroundStorage engines: LSM-tree vs B-tree, simulatedSQLite internalsIsolation lab

Data and event platformsSoftware engineering

Here is a promise every serious database makes, and almost nobody stops to think about: once it says COMMIT succeeded, your data is safe. Not "safe unless the power goes", not "safe unless the kernel panics in the next forty milliseconds" — safe. And the transaction that was halfway through when the lights went out? Gone, cleanly, as if it had never started.

That is a strange thing to be able to promise. Disks write in blocks of 4 KB or so; a table and its indexes are spread over thousands of those blocks; a single UPDATE might touch a dozen of them. The power can fail between any two. So how does the database come back up with every block consistent?

The answer is one idea, applied relentlessly — write down what you are going to do before you do it — plus a lot of engineering to make that idea cheap. This exploration walks through it with the site’s database tools running live in the page. Nothing connects to a server: the PostgreSQL and SQLite you will poke at are real engines compiled to WebAssembly, and the storage and isolation simulators are models built to show the mechanisms.

The problem: pages in memory, pages on disk

A database keeps its data in fixed-size pages — 8 KB in PostgreSQL, 16 KB in InnoDB, 4 KB by default in SQLite. To change a row it reads the page into memory, changes it there, and at some point writes the page back.

Reading pages is slow, so databases keep a cache of them: the buffer pool. A page that has been changed in memory but not yet written back is dirty. The buffer pool would love to keep dirty pages around for as long as possible — if the same page is updated a hundred times, it only needs writing once — but every dirty page is a change the disk does not know about yet.

So we have a tension. For speed, we want to delay writing pages. For safety, we want every committed change on disk before we say "committed". Writing every touched page at every commit would be correct and ruinously slow: random writes all over the disk, and a torn mess if the power fails halfway through a page.

The write-ahead log

The way out is the write-ahead log (WAL). Before a page is allowed to change on disk, a record describing the change is appended to a log — a single file that only ever grows at the end. At commit, the database makes sure the log is on disk up to and including that transaction’s commit record (an fsync), and then it says yes. The pages themselves can be written whenever it is convenient.

Why is that better? Because appending to one file is sequential I/O, which even spinning disks do quickly, and because a log record is small: "on page 4117, slot 3, set column 2 to 150" is tens of bytes, not 8 KB. And if the machine dies, the log is the truth: replay it from the last known-good point and every committed change is redone; a transaction with no commit record is undone or simply ignored.

You can watch PostgreSQL’s log grow. The console below is real PostgreSQL 18, running in your browser through PGlite. pg_current_wal_lsn() returns the log sequence number — the byte position at the end of the WAL — so the difference between two of them is how many bytes of log a piece of work produced.

What to try: scroll through the results and compare the three numbers, then change the 10000 and press Run.Open full tool →

The script measures three things. First, inserting 10,000 rows produces about 1.6 MB of log — more than the table and its primary-key index occupy. That is the price of writing everything twice: once to the log now, and once more to the table’s pages later.

The second and third numbers are the interesting ones. After a CHECKPOINT, the first change to a page writes far more log than the second: when I ran it, about 19,000 bytes against about 300. Both updates change one short text value. Why the difference?

Try this:

  • Press Reset (so the tables are fresh), delete the CHECKPOINT; line and press Run. The first update no longer pays the big price, because no checkpoint has happened since its page was last logged.

  • Reset again, put the checkpoint back and change WHERE id = 2 to WHERE id = 9000: a row on a different page. Now the "second" update is also a first change to its page, and it costs about 16,000 bytes too.

What you are seeing is full-page writes, and it is about torn pages.

Torn pages and checkpoints

A PostgreSQL page is 8 KB; the disk underneath may only guarantee that 512-byte or 4 KB sectors are written atomically. If the power fails while a page is being written back, the page on disk can be half old and half new — torn. A log record that says "set slot 3 to 150" is no use against a torn page: it describes a change to a page that no longer exists in any consistent form.

PostgreSQL’s answer is to put a complete copy of the page into the WAL the first time the page changes after each checkpoint. Recovery starts from that checkpoint, so for every page it touches it first finds a whole, known-good image in the log, restores it, and then replays the small records on top. The image costs about one page of log (your ~19,000 bytes were two or three page images: the table page, the index page and the mark table’s page); every later change to the same page, until the next checkpoint, is just the small record.

A checkpoint, then, is the database saying: "every dirty page that existed when I started has now been written to disk, so recovery never needs to look further back than here." It bounds how much log has to be kept and how long recovery takes, and it resets the full-page-write clock. Checkpoints too often and you write page images constantly; too rarely and recovery after a crash takes a long time. That is a real tuning knob (checkpoint_timeout, max_wal_size).

The storage-engines simulator draws all of this: the WAL, the buffer pool with its dirty pages, and the disk, with a counter of every byte written. This view runs its update-heavy scenario on the B+tree alone with full-page writes switched on.

What to try: press Start, then Next step twice. Watch the WAL and disk boxes and the write amplification counter.Open full tool →

Try this:

  • After step 2 (Skewed updates) look at write amp. — with no buffer pool, every update of a 116-byte entry writes a whole page, and full-page writes add page images to the log on top. It lands in the high thirties.

  • Step 3 turns on a 128-page buffer pool. Hot pages now absorb many updates in memory and are written once, at eviction or checkpoint. Watch the write-amplification chart bend downwards.

  • Tap the WAL box or the buffer pool to inspect them; Checkpoint is one of the actions. Dots on pages show which are cached, orange ones are dirty.

This is the whole bargain of a classical database engine: the buffer pool makes writes cheap by delaying them, the WAL makes the delay safe, and checkpoints stop the WAL growing for ever.

Two ways to organise the pages: B+tree and LSM-tree

So far the pages have been the leaves and branches of a B+tree: one big sorted tree, updated in place. PostgreSQL’s indexes, InnoDB’s tables and SQLite’s files all work this way. Updating in place is what makes the WAL and the torn-page dance necessary.

There is another family that takes the log idea to its logical conclusion. A log-structured merge-tree (LSM-tree) — RocksDB, LevelDB, Cassandra, ScyllaDB, the storage under many modern systems — never updates a file on disk at all. A write goes to the WAL (for durability, exactly as before) and into a sorted structure in memory, the memtable. When the memtable is full it is frozen and written out in one sequential pass as an immutable sorted file, an SSTable. Its part of the WAL can then be thrown away.

What to try: press Start and step through all four steps; tap the memtable and an SSTable to inspect them.Open full tool →

Try this:

  • In step 1, notice that a write touches only two things: the WAL and the memtable. Nothing on disk is modified in place, so there is no page to tear.

  • In step 2 the memtable fills (six entries here — millions in real life), becomes the immutable memtable, and is flushed to level 0. The WAL’s live size drops: those records are no longer needed for recovery, because the data is now in a file.

  • In step 4, follow a lookup: memtable first, then the L0 files newest to oldest. A ✕ means a Bloom filter said "definitely not here" and the file was skipped without a disk read.

Crash recovery in an LSM-tree is pleasingly simple. The SSTables are immutable and were fsynced before they were recorded in the manifest, so they are either fully there or not there at all. The only thing lost in a crash is the memtable — and the WAL holds exactly the writes that were in it. Replay the WAL into a new memtable and you are back.

Compaction: paying the bill later

An LSM-tree buys fast writes by deferring work. Every flush adds another file, files overlap, and an updated or deleted key may now have several versions scattered across them. Reads get slower the more files there are, and old versions waste space. So, in the background, the engine compacts: it merge-sorts files together, keeps the newest version of each key, and writes fewer, bigger, non-overlapping files further down.

What to try: press Start, then Next step. Watch L0 fill to four files and get merged into L1, and L1 spill into L2.Open full tool →

Try this:

  • Watch the level labels: 30/32 means L1 holds 30 entries against a target of 32. When a level goes over its target, one of its files is merged with everything it overlaps in the level below.

  • Notice L0’s files overlap each other (they span most of the key range), while L1 and L2 are tidy rows of non-overlapping files. That is the payoff: below L0, a lookup reads at most one file per level.

  • In step 3, look at the LSM’s write amplification. Each entry is rewritten once per level it passes through — the cost of keeping reads cheap.

Compaction also matters for crashes, in a quiet way: it, too, writes new files and only then records them in the manifest and deletes the old ones. A crash mid-compaction leaves some orphaned half-written output, which is simply ignored and deleted on restart. Immutable files make "atomic" cheap.

If you want to see the two families side by side, the simulator’s Range scans favour the B+tree and Bloom filters scenarios are good next stops; the trade-off is usually summarised as read, write, space — pick two.

SQLite: atomic commit in a single file

SQLite is a useful contrast, because it has two complete answers to the crash problem and lets you switch between them with one PRAGMA.

In the default rollback journal mode, SQLite does the opposite of a WAL: before changing a page in the database file, it copies the page’s original content into a separate -journal file and fsyncs that. Then it writes the new pages into the database. If the process dies, the next program to open the database finds a "hot" journal and copies the old pages back. The database is rolled back to the last commit. The commit itself is the moment the journal is deleted.

In WAL mode, the database file is left alone during a transaction; new versions of pages are appended to a -wal file, readers look there first, and a checkpoint later copies them home. That is the same shape as PostgreSQL’s WAL, with whole pages as the records.

What to try: press Run every step, then read the header table under each step — offsets 18, 19 and 24 are the ones that change.Open full tool →

Try this:

  • Under step 1, find the file change counter at offset 24. Every write transaction in rollback mode bumps it; other processes use it to notice their cached pages are stale.

  • Under Switch to WAL, bytes 18 and 19 change from 1 (legacy) to 2 (WAL). The journal mode is stored in the file itself, so it persists.

  • Open the Pages tab afterwards and look at the map of the file: every page is accounted for, and the header at the start of page 1 describes the whole database.

The lab is honest about a limit: one browser page cannot pull the plug on itself, so what you see is the on-disk format the recovery logic relies on rather than a crash. But the mechanism is all here — a page either has a known-good copy somewhere or it was never touched.

The other half of the promise: isolation

Surviving a crash is the D in ACID — durability — and its sibling A, atomicity. But a database that is merely durable can still hand your application nonsense, if two transactions run at the same time and see each other’s half-finished work. That is the I: isolation.

The machinery is related. Modern databases keep several versions of a row (MVCC — multi-version concurrency control), and each transaction reads from a snapshot: the set of versions committed when it started (or, at READ COMMITTED, when each statement started). Old versions are what the undo log or the heap’s dead tuples are for. The isolation level decides which snapshot you get and what happens when two transactions want the same row.

The isolation lab steps two sessions through an interleaving, statement by statement, against models of PostgreSQL and InnoDB that have been checked against the real servers.

What to try: press Step eight times, then change the isolation level to REPEATABLE READ and run it again.Open full tool →

Try this:

  • At READ COMMITTED, both sessions read a balance of 100, A writes 150, B writes 125. B waits for A’s row lock, then overwrites. The banner says The anomaly happened: one deposit is lost.

  • Switch to REPEATABLE READ. B’s UPDATE now fails with 40001 could not serialize access due to concurrent update. The application has to retry from BEGIN — and on the retry it reads 150.

  • Switch the engine to MySQL InnoDB at REPEATABLE READ. The lost update happens. Same SQL standard name, different behaviour; this is why the lab exists.

The lost update has well-known fixes — SELECT … FOR UPDATE, an atomic SET balance = balance + 25, a version column — and they are all in the scenario list. The nastier anomaly is the one no row lock can see.

What to try: step to the end at REPEATABLE READ, then switch to SERIALIZABLE and step through again.Open full tool →

Try this:

  • Alice and Bob are both on call for shift 1. Each checks that two doctors are on call, and each takes themselves off. They update different rows, so nothing conflicts and the snapshots are perfectly consistent. At the end, nobody is on call — the Data panel shows both false.

  • At SERIALIZABLE, PostgreSQL’s Serializable Snapshot Isolation tracks what each transaction read as well as what it wrote. It spots the dangerous pattern and fails B’s commit with 40001. Read the explanation under Step 8.

  • Try MySQL InnoDB at SERIALIZABLE: there, plain reads take shared locks, and the two updates deadlock instead. Prevented, but by a different route.

Finally, the whole map at once. Every scenario, both engines, all four levels:

What to try: find the Write skew row and read across it; click any cell to step through that combination.Open full tool →

Try this:

  • Red cells are anomalies that happen; dark green means the database stopped it with an error you must retry; pale green means it was stopped by waiting or by the snapshot.

  • Compare the PostgreSQL RR column with the MySQL RR column. They share a name and very little else.

  • Notice how much green at SERIALIZABLE is an error. Serialisable isolation is not free safety: it is safety in exchange for retry loops in your application.

Putting it together

Follow one UPDATE accounts SET balance = 175 WHERE id = 1; COMMIT; from start to finish:

  1. The transaction takes a snapshot and a row lock; the isolation level decides what it may see and whether a concurrent writer makes it wait or fail.

  2. The page holding the row is found in the buffer pool (or read into it) and changed in memory. It is now dirty.

  3. A WAL record describing the change is appended to the log buffer — with a full-page image if this is the page’s first change since the last checkpoint.

  4. At COMMIT, the log is fsynced up to the commit record. Only then does the client hear "OK".

  5. Some time later — at eviction, or at the next checkpoint — the dirty page is written to its home on disk.

  6. If the power fails anywhere in that sequence, recovery starts at the last checkpoint, restores page images, replays every logged change, and discards the work of transactions that never committed.

An LSM-tree swaps steps 2 and 5 for a memtable and a flush, then spends its spare time compacting; SQLite in rollback mode inverts the log and saves the old pages instead of the new ones. But every one of them rests on the same rule: before you change the only copy of something, write down enough to get back.

Where to go next

  • Storage engines — all ten scenarios, including size-tiered compaction’s space spike, tombstones, and the Cost models tab with the textbook formulas.

  • Postgres playground — real PostgreSQL in the browser, with pattern labs for queues, advisory locks, leases and an animated concurrency model.

  • SQLite internals — the file decoded byte by byte, b-tree diagrams, bytecode, and ten more labs.

  • Isolation lab — every anomaly, the fixes, the Hermitage tests, and a mode that runs the same steps on your own PostgreSQL server.

  • Consistency lab — the same questions once the database is replicated: stale reads, session guarantees and linearisability.

  • Transactions across services — what you can do when one database’s WAL is no longer enough: two-phase commit, sagas and the outbox.

  • AOEE, a TAO-style social graph cache in Rust — a cache in front of a database, and what that does to consistency.

#databases#durability#write-ahead log#wal#buffer pool#checkpoint#full-page writes#b-tree#lsm-tree#compaction#crash recovery#sqlite#postgresql#transactions#isolation levels#mvcc#write skew