Internals · WALAtlasDB V7 · Write-Ahead LogWALwrite-ahead logLSNfsyncredo

Write-Ahead Logging

COMMIT returns, the machine dies, the dirty page was never written. The naive fix — flush every dirty page at commit — costs random 8 KB writes per changed byte and still leaves torn pages. The write-ahead log appends a description of each change to a sequential file, fsyncs it at COMMIT, and lets data pages be written whenever convenient. LSNs order everything; checkpoints let the log be truncated.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    The buffer pool holds a page with balance 400; storage holds 500; the client was told COMMIT succeeded. Power fails. Which number is true, and how would the engine know on restart?

  2. Naive solution

    Make COMMIT write every dirty page the transaction touched to storage before returning OK. Then storage is always the committed truth.

  3. Why it breaks

    A transfer touches two heap pages and two index pages: four random 8 KB writes — 32 KB and four seeks — to persist 16 bytes of change. Under load the disk saturates on random writes. And a page write is not atomic: a crash halfway leaves a torn page that is neither the old nor the new version, so even "flush at commit" is not safe.

  4. Better idea

    Write down what you intend to change, in a small record, to a file you only ever append to, and make *that* durable at COMMIT. The page can be written later, at leisure; if the machine dies first, the record is enough to redo the change.

  5. Internal mechanism

    A write-ahead log: records appended in memory, each with an LSN (its byte position); every modified page stamped with the LSN of the last record applied to it; the rule that a page may not be written before the log is durable through its LSN; COMMIT = append a commit record and fsync; checkpoints that write dirty pages so old log can be recycled.

  6. Trade-offs

    Every change is written twice — once to the log, later to the page — and COMMIT waits on an fsync. In exchange, the log write is sequential and can be shared by many committing transactions, and page writes are batched and deferred. The log also has to be managed: it grows without bound unless checkpoints let it be truncated.

  7. Real database

    PostgreSQL pg_wal: 16 MB segments, 8 KB pages, full-page writes after each checkpoint to survive torn pages, synchronous_commit and commit_delay for group commit. InnoDB: a fixed-size ring of redo log files, a doublewrite buffer instead of full-page images, innodb_flush_log_at_trx_commit.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

Write the note before you make the change

A database keeps changed pages in memory and writes them to disk later, because writing them immediately would be slow. That leaves a window in which a committed change exists only in RAM. The write-ahead log closes the window: before a page is changed, a short note describing the change is appended to a log file, and COMMIT means the note is safely on disk.

If the machine dies, the notes are replayed on restart and the pages are brought up to date. The disk work per commit becomes one small sequential append instead of several scattered page writes.

The question: what survives after COMMIT?

By Follow a Write Through the Engine the picture is: COMMIT has returned, heap page 2093 in the buffer pool says 400, page 2093 on storage says 500, and the frame will be written in a few minutes. Power fails now. RAM is gone; storage says 500; the client has a receipt for a transaction that says 400. Durability — the D in ACID — is the promise that the client's receipt is correct. The engine must therefore have made *something* durable before returning OK, and on restart it must be able to find it and act on it.

The constraints make this hard. Writing an 8 KB page is a random write to wherever that page lives — for a transfer touching two heap pages and two index leaves, four writes to four places. Random writes are the slow operation on every storage medium; a disk does perhaps 200 per second, an SSD tens of thousands, and each is 8 KB for a change of 16 bytes. And a page write is not atomic: 8 KB spans several physical sectors, and a crash mid-write leaves a page half old, half new — a torn page — that is worse than either version.

Naive: flush every dirty page at commit

The obvious design: COMMIT writes every page the transaction dirtied, fsyncs them, and only then returns OK. Storage is always the committed state; a crash loses only uncommitted work; restart needs no special handling. Three things break. Write amplification: 16 bytes changed, 32 KB written, four seeks; a workload of 5,000 small commits per second is 20,000 random page writes per second, which exceeds what most storage can sustain. No batching: a hot page updated by a thousand transactions in a second is written a thousand times instead of once. Torn pages: a crash during one of the four writes leaves a page that is neither version, and there is no other copy to repair it from.

There is a fourth, subtler problem. A page can hold rows from several transactions. Flushing it for T1's commit also writes T2's uncommitted change on the same page; if T2 then aborts, storage holds a change that never committed and nothing records how to undo it. "Flush at commit" is not merely slow; without a log it is not even correct.

Persisting a transfer (2 heap pages + 2 index pages changed, 16 bytes of data) — simulated
DesignBytes written at COMMITWrites at COMMITPatternTorn-page safeHot page updated 1,000×/s
Flush dirty pages at commit32 KB4 + 4 fsyncsrandomno1,000 page writes/s
Write-ahead log~200 B1 append + 1 fsyncsequentialyes (with FPW / doublewrite)1 page write per checkpoint

The log: append, then fsync

Instead of writing the page, write a description of the change: "transaction 7412, page 2093, slot 7, balance 500 → 400". Eighty bytes. Append it to a file that is only ever appended to — sequential writes are the fast operation on every medium — and make *that* durable at COMMIT with one fsync. The page stays dirty in the pool and is written whenever the background writer gets to it; if the machine dies first, the record is enough to reconstruct the change: read the page from storage (500), apply the record (400), done. The log is the durable truth; the data files are a cache of it that lags behind.

Every change goes through the same door. UPDATE, INSERT, DELETE, index inserts, page splits, even the allocation of a new page: each is a record. A transaction's records are chained by a back-pointer, and its last record is COMMIT (or ABORT). The sequence for the transfer is: BEGIN → UPDATE page 7 → UPDATE page 3 → COMMIT, then fsync, then OK to the client, then — later — the two pages.

AtlasDB V7: atlas.wal after a transfer, before any page is flushed
atlas.wal (append-only)                                   durable ─┐
LSN     1  T1 BEGIN                                    24 B          │
LSN    25  T1 UPDATE page 7  account 42  500 → 400     72 B          │
LSN    97  T1 UPDATE page 3  account 17  120 → 220     72 B          │
LSN   169  T1 COMMIT                                   32 B  ← fsync ┘  client sees OK here
LSN   201  (next record goes here)

atlas.db pages on storage:  page 3 = 120 (LSN 0)   page 7 = 500 (LSN 0)   ← stale, fine
buffer pool:                page 3 = 220 (LSN 97, dirty)   page 7 = 400 (LSN 25, dirty)

LSNs, page LSNs, the write-ahead rule — and group commit

The LSN (log sequence number) is a record's byte position in the log stream. It is monotonic, so it is also a clock: "everything before LSN 169" is well defined. Three LSNs matter. The durable LSN (flushed LSN): the log has been fsynced through here. The page LSN, stamped in each page header: the last record applied to this page. And the checkpoint LSN: recovery starts here. The write-ahead rule ties the first two together: a page may be written to storage only if durable LSN ≥ page LSN. The background writer checks it before every page write and, if necessary, flushes the log first. The rule guarantees that anything on storage is described by durable log — which is exactly what redo needs, and it is why a page image on disk never runs ahead of the log.

COMMIT's cost is the fsync, and fsync does not get cheaper with smaller writes: it is a round trip to the device's durable medium, 50–200 µs on a datacenter NVMe with power-loss-protected cache, 1–10 ms on consumer SSDs and spinning disks. Group commit amortises it: while one fsync is in flight, other transactions reaching COMMIT append their records and wait; the next fsync covers all of them. A hundred concurrent committers pay one fsync between them, so throughput scales with concurrency while each commit still waits roughly one fsync of latency. Relaxing the wait (synchronous_commit = off) returns before the fsync and accepts losing the last few hundred milliseconds of commits on a crash — a legitimate choice for data that can be regenerated, and a silent data-loss bug otherwise.

The three operations that define a WAL (AtlasDB V7)
1append(record): # any backend
2 lsn = log.reserve(record.size) # short critical section
3 log.buffer[lsn] = record
4 return lsn
5
6modify(page, record):
7 page.bytes = apply(record)
8 page.lsn = record.lsn # stamp
9 page.dirty = true
10
11write_page(page): # background writer / checkpoint / eviction
12 if durable_lsn < page.lsn:
13 flush_log(page.lsn) # write-ahead rule: log first
14 storage.write(page)
15 page.dirty = false
16
17commit(tx):
18 lsn = append(COMMIT tx)
19 flush_log(lsn) # fsync — group commit batches waiters here
20 reply OK # never before the fsync

Checkpoints: why the log can be truncated

The log grows with every change and recovery replays it from the beginning — unbounded log, unbounded restart time. A checkpoint bounds both. The checkpointer notes the current LSN, writes every page dirtied before it to storage (honouring the write-ahead rule), then writes a CHECKPOINT record and fsyncs. From that moment every change before the checkpoint LSN is present in the data files, so recovery can start at the checkpoint and the log before it can be recycled. Real engines spread the page writes over the interval between checkpoints (checkpoint_completion_target) rather than writing them in a burst — a burst of thousands of random page writes is the "checkpoint storm" that makes latency spike every five minutes on a badly tuned server.

A sharp checkpoint stops the world until all pages are written; a fuzzy checkpoint (what every engine actually does) lets transactions continue and records, in the checkpoint, the list of active transactions and the earliest LSN of any still-dirty page, so recovery knows how far back to start. The trade-off is the interval: frequent checkpoints mean a short log and fast recovery but more page writes (each hot page is written once per checkpoint, and each first-touch after a checkpoint costs a full-page image); rare checkpoints mean fewer writes and a long restart.

Where durability lives over time
log firstlater (WAL rule)WAL record (LSN)change in pool (dirty)COMMIT: fsyncpage flushedclient OKCHECKPOINT recordold log recycled
UserLLMAgentToolDataDecisionHumanGuardrail

PostgreSQL: pg_wal, full-page writes and synchronous_commit

PostgreSQL implementation

PostgreSQL's WAL lives in pg_wal/ as 16 MB segment files named by timeline and LSN; the LSN is printed as two hex words (0/16B3D48). Records are written by backends into a shared wal_buffers area, flushed by whoever commits or by the WAL writer process. synchronous_commit selects what COMMIT waits for: on (fsynced locally, the default), remote_apply/remote_write/on with synchronous replicas, or off (return immediately; up to ~3× wal_writer_delay of commits can be lost on a crash, never corrupting the database). commit_delay and commit_siblings tune group commit explicitly.

Full-page writes (full_page_writes = on) solve torn pages: the first record that modifies a page after a checkpoint carries the entire 8 KB page image, so recovery can overwrite a possibly-torn page with a known-good copy before replaying later records. The cost is that WAL volume spikes right after each checkpoint — one reason not to checkpoint too often — and the reason wal_compression exists. pg_current_wal_lsn(), pg_stat_wal and pg_stat_bgwriter show how much WAL is generated and how checkpoints are behaving; checkpoint_timeout, max_wal_size and checkpoint_completion_target govern when and how smoothly they run.

Watching the log move (PostgreSQL)
1SELECT pg_current_wal_lsn() AS current_lsn,
2 pg_current_wal_insert_lsn() AS insert_lsn, -- appended, not necessarily fsynced
3 pg_walfile_name(pg_current_wal_lsn()) AS segment;
4
5-- how far the log has moved during one transaction
6SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), '0/16B3D48') AS bytes_since;
7
8-- checkpoint behaviour: are they timed (good) or forced by max_wal_size (tune)?
9SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_backend
10FROM pg_stat_bgwriter;

InnoDB: the redo ring and the doublewrite buffer

MySQL / InnoDB implementation

InnoDB's redo log is a fixed-size ring (innodb_redo_log_capacity, formerly innodb_log_file_size × innodb_log_files_in_group). The LSN is a byte count since the beginning of time; the ring position is LSN modulo capacity. Because the ring wraps, the checkpointer must have flushed pages up to the LSN about to be overwritten — if it cannot keep up, writers stall until it does, which is the classic "the redo log is too small" symptom under write bursts. innodb_flush_log_at_trx_commit chooses durability: 1 (fsync at every commit, the default and the only fully durable setting), 2 (write to the OS at commit, fsync once per second — survives a MySQL crash but not a power loss), 0 (write and fsync once per second).

Torn pages are handled by the doublewrite buffer: before a data page is written to its place in the tablespace, it is written to a contiguous doublewrite area and fsynced; then it is written to its real location. A crash during the second write is repaired from the doublewrite copy on restart. It doubles page-write bytes but keeps them sequential, and it keeps redo records small — InnoDB never has to log full page images. Redo records themselves are physiological (page number + a logical operation within the page), and the undo log is a separate structure in the system or undo tablespaces, itself protected by redo.

Key points

  • Durability is a promise about COMMIT, and COMMIT must make something durable before returning. That something is the log, not the page.
  • Flushing pages at commit is random, amplified, unbatched and not torn-page safe; appending a record and fsyncing is sequential, tiny and shareable.
  • Log before page: a page may not be written until the log is durable through the page's LSN. Commit before OK: fsync, then reply.
  • LSNs order the world: durable LSN, page LSN and checkpoint LSN are the three numbers recovery reasons with.
  • Group commit amortises the fsync across concurrent committers; synchronous_commit = off trades the last few hundred ms for latency.
  • Checkpoints write dirty pages so old log can be recycled and recovery starts late; full-page writes or a doublewrite buffer repair torn pages.

Write-ahead log with a crash button

Write-ahead logging, with a crash button
Drive transactions step by step. Every change goes to the log first; COMMIT fsyncs the log; data pages are flushed later. Crash whenever you like and see exactly what the disk holds.
Durability
Transaction to drive
T1: transfer 100 from account 42 to 17
→ BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 42; UPDATE accounts SET balance = balance + 100 WHERE id = 17; COMMIT;
WAL · append-only · sequential writesdurable through LSN 0
(empty)
Buffer pool0 dirty
page 3 · account 17
page 7 · account 42
page 12 · account 88
Storage · page imagessurvives a crash
page 3 · account 17
120
page LSN 0
page 7 · account 42
500
page LSN 0
page 12 · account 88
900
page LSN 0
no transactions yet
Engine up. Empty log, empty pool, three account pages on storage.
WAL bytes
0
fsyncs
0
Sequential log writes
0
Random page writes
0
Dirty pages
0
Active txns
0

Try: drive T1 to COMMIT, crash, reveal. Then drive T1 to its second UPDATE, flush page 7, crash. Then checkpoint between two transactions and compare how much of the log recovery needs.

Educational simulation — page sizes, costs and counters are modelled, not measured from a real engine.

When to use — and when not

Use it when
  • A write-ahead log fits every engine that both caches pages in memory and promises durable commits — B+ tree engines and LSM engines alike (an LSM's memtable is protected by exactly this log).
  • Relaxed settings (asynchronous commit, flush once per second) fit workloads whose last second of writes can be regenerated: metrics, logs, caches.
Avoid it when
  • A WAL is unnecessary when the data is already durable elsewhere and the store is a rebuildable cache or derived view — Redis without persistence, a materialised aggregate.
  • Synchronous fsync per commit does not fit when commit latency is the product and the storage is slow (consumer SSD, network disk): batch commits or accept asynchronous commit knowingly.

Failure modes

  • fsync that lies: consumer SSDs and some virtualised disks acknowledge before the write is durable, and the database cannot tell.
  • The log on the same disk as the data, so log appends and page flushes compete for the same seeks or the same device queue.
  • Checkpoints too rare (huge pg_wal, minutes of recovery) or too frequent (full-page-write storms, constant page rewrites).
  • synchronous_commit = off or innodb_flush_log_at_trx_commit = 2 set "for performance" on a system that promised durability to its users.
  • Backups of data files without the WAL needed to make them consistent; the restore is a state that never existed.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.

Cross-domain bridges