Journaling File Systems

The crash-consistency problem left us with two bad options: overwrite structures in place and risk a corrupt file system, or run a full \texttt{fsck} that scans the whole disk at every boot. Journaling borrows the idea that saved databases decades earlier — write-ahead logging — and gives file systems atomic multi-block updates with recovery that touches only a tiny log instead of the entire disk.

The principle is almost embarrassingly simple, and it is one of the most important ideas in systems: before you touch the real structures, write down what you are about to do in a dedicated on-disk log (the journal). Only once that intention is safely recorded do you go and modify the actual inodes, bitmaps, and data in place. If you crash midway, recovery reads the log and either replays the intention (if it was fully recorded) or discards it (if it wasn't). Either way you end up in a state that could have happened — never a torn one. ext3, ext4, XFS, JFS, and NTFS all live by this.

Write-ahead logging: the three phases

A journaled update is a transaction that moves through three phases. The magic is entirely in the ordering barrier between phase 2 and phase 3.

Recovery is then trivial and fast: after a crash, scan the journal. For every transaction that has a valid commit record, replay it (re-do the checkpoint) — this is safe even if it already partly happened, because re-writing the same blocks is idempotent. Any transaction without a commit record is thrown away as if it never began. Crucially, recovery time is proportional to the journal size (a few megabytes), not the disk size — that is the whole win over \texttt{fsck}.

See the timeline

Watch the barrier. The commit record must not hit the disk until the journal-write blocks are all durable — otherwise recovery might trust a commit for a transaction whose data never arrived. This single ordering constraint (enforced with the write barriers of a later lesson) is what makes the whole scheme correct.

Replay a journal after a crash

The model below is a miniature journaling file system. Several transactions sit in the journal; some have a commit record, some were interrupted by the crash and do not. Recovery replays only the committed ones onto the "disk." Notice the uncommitted transaction is silently dropped — exactly the behaviour that keeps the file system consistent.

// A tiny journal. Each transaction lists (block -> value) writes and whether its commit record landed. type Tx = { id: number; writes: [string, string][]; committed: boolean }; const journal: Tx[] = [ { id: 1, writes: [["inode/7", "size=8KB"], ["bitmap", "blk42=used"]], committed: true }, { id: 2, writes: [["data/blk42", "hello world"]], committed: true }, // Crash struck mid-way: transaction 3's blocks reached the log but the commit record never did. { id: 3, writes: [["inode/9", "size=1MB"], ["bitmap", "blk99=used"]], committed: false }, ]; const disk: Record<string, string> = {}; console.log("Recovery: scanning the journal…"); for (const tx of journal) { if (tx.committed) { for (const [blk, val] of tx.writes) disk[blk] = val; console.log(` tx#${tx.id}: COMMITTED -> replayed ${tx.writes.length} write(s)`); } else { console.log(` tx#${tx.id}: no commit -> DISCARDED (as if it never happened)`); } } console.log("Final disk state:"); for (const k of Object.keys(disk)) console.log(` ${k} = ${disk[k]}`);

The double-write cost — and the modes that dodge it

Journaling has an obvious price: every journaled block is written twice — once to the log, once to its real home. For a file system that logged all data, that halves write bandwidth. So real systems offer three modes trading safety against that cost. ext3/ext4 name them exactly:

ModeWhat is journaledTrade-off
data (journal)metadata and datasafest; every data byte written twice
ordered (default)metadata only; data forced to disk before the metadata commitsfast + safe: no stale-data exposure
writebackmetadata only; data written wheneverfastest; a crash can expose stale bytes in a newly-grown file

Almost everyone runs ordered mode. It journals only metadata (so the double-write hits just the small structures, not your gigabytes of data), yet by forcing the data blocks to their final home before the metadata that points at them is committed, it guarantees a committed inode never points at a block full of someone else's old bytes. Metadata-only journaling is the default of the whole industry for exactly this reason.

Batching: amortising the log

Committing a transaction per \texttt{write()} would drown the journal in commit records. Instead, file systems batch: they buffer many logical operations over a short window (ext3 uses ~5 seconds) into one compound transaction with a single commit. If the same block is touched ten times in that window, it is journaled once with its final value — a huge saving, and the reason journaling's real-world overhead is far below the naive "everything twice." The cost is a small durability window: work in the current batch that hasn't committed yet is lost on a crash (unless you forced it with \texttt{fsync}).

Because the commit record is the one place the scheme leans directly on the hardware's single-block atomicity. The whole protocol reduces "did this multi-block transaction happen?" to "did this one block — the commit record — make it to disk?" A single block is exactly the thing the disk promises to write all-or-nothing, so the answer is never ambiguous: either the commit block is there (replay) or it isn't (discard). If the commit needed two blocks you would be back to the original problem — a torn commit — one level up. This is a recurring trick in systems: build a big atomic action by funnelling its "did it happen?" decision through a single atomic write. (Real implementations also add a checksum so a commit record that is present but corrupt is treated as absent.)

A journaled file system will never boot into a corrupt state — but that does not mean your last \texttt{write()} survived. If your write is still sitting in an uncommitted batch when the power fails, journaling correctly discards it, and the file system is perfectly consistent without it. Consistency and durability are still different promises (as they were with fsck). Journaling makes recovery fast and keeps the structures coherent; it does not, by itself, promise that a particular write is on disk. For that you still need \texttt{fsync} — which forces the current transaction to commit now. Don't let "my file system is journaled" lull you into skipping it.