Journaling

Niko 上次那台 Mac mini 蘋果 logo 撞裂之後報了公帳,公司一看原廠維修估價單,乾脆不修了,給 Niko 換一台 Raspberry Pi 500+ 桌機,比 Mac Mini 更省空間。反正 Niko 最近都在 vibe coding,Claude Code 跑在雲端,本機只負責顯示 terminal,買這台 C/P 值最高,還附贈機械鍵盤耶!!根本是碼農的耕田機!!連 Windows 授權都省了,直接灌 Linux、檔案系統用 ext4。
Capoo 是忠實 Btrfs 信徒,把自己的 workstation 灌成 Btrfs,還到處宣揚 Butter-Filesystem 多美好。
公司大樓昨晚遭雷擊,突波穿過 UPS 癱瘓了整層樓的電腦。早上 Niko 進辦公室開機,畫面卡了幾秒,螢幕閃過一行 Recovering journal,然後也上線了,昨晚的 code 還在,好哩家在。回頭一看 Capoo 的 Btrfs 機器開機更快,連 recovery 畫面都沒出現就直接進桌面。
兩個人用的是完全不同的 crash consistency 機制,但都活下來了。CoW 的 atomic root swap 讓硬碟上永遠處於某個 valid state,crash 根本不需要 recovery,而 ext4 的 Recovering journal,是我們現在要學習的東西。
Write-Ahead Log(快速複習)
Journaling 從資料庫借來一個古老的想法:write-ahead logging(WAL)。我們在硬碟上劃出一塊叫 journal 的區域,每次要修改檔案系統,就先把「我打算把哪些 block 改成什麼樣子」寫進 journal,等 journal 那邊寫完了,才去動真正的檔案系統本體。一筆 transaction 在 journal 裡長這樣:

WAL 的完整原理上學期 OS 課已經有影片詳細介紹過,建議不熟悉的同學先去看過:【OS 課 WAL 影片】。這邊我們快速把三個關鍵名詞複習完就繼續往下。
Commit record 是整個 WAL 流程唯一的 atomicity boundary。寫入順序硬性要求:前面那一大塊 transaction body 必須全部寫入硬碟之後,才能寫 commit record;而 commit record 本身只佔單一個 block,硬體保證一個 sector 的寫入是 atomic 的。於是整條 log 只有兩種狀態:commit record 存在(整筆 transaction 算數)、commit record 不存在(整筆當沒發生),沒有中間地帶。
Checkpoint 是把 journal 裡 committed 的內容真的搬回 on-disk 正式位置的動作,完成後 journal 的寫入指標就能 reset。Recovery 則是 crash 後 mount 時的掃描:有 commit record 的 transaction replay、沒有的丟掉。比起 fsck 要掃整顆硬碟,journal replay 只讀 journal 那一小塊區域,recovery 時間幾乎和硬碟大小無關。這就是 Niko 開機時 Recovering journal 一閃而過的原因。
Physical vs Logical Journaling
journal 裡面該記錄什麼東西?
Physical journaling 記錄完整的 block image。假設 Niko 把某個 inode 的 size 從 1 MB 改成 11 MB,這個修改只動到 inode 裡的幾個 byte,但 physical journal 會把整個 inode 所在的 4 KB block 完整複製一份到 journal。replay 時只要把這份 block 寫回原位就好,邏輯極其單純。
Logical journaling 反過來,只記錄高階操作的描述,例如「檔案 /foo 的 size 從 1 MB 變成 11 MB」、「在目錄 /dir 下新增一個叫 bar 的 entry 指向 inode 42」。這種記錄方式很省空間、效能好,幾十個 byte 就能描述一個原本要寫 4 KB 的修改。但代價是 recovery 非常複雜:replay 時必須重新執行那些高階操作,而高階操作的執行結果依賴當下檔案系統的狀態,一個操作做錯,後面的操作可能也會一同失敗。
| Physical journaling | Logical journaling | |
|---|---|---|
| 記錄單位 | 完整 block image (4 KB) | 操作描述 (幾十 bytes) |
| Journal 寫入量 | 高 | 低 |
| Recovery 複雜度 | 低(重寫 block) | 高(重演操作) |
| 實務代表 | ext3/4 (JBD2)、NTFS | XFS |
那麼,如果 logical 的效能比較好,是不是大家都用 logical 才對呢?但 ext4 用的是 physical journaling,靠一個叫 JBD2 的子系統實作。原因是 JBD2 已經運行了幾十年,程式碼穩定。XFS 雖然是純 logical journal,但那是他們從一開始就這樣設計的。

ext4 的三種 mode
journal 要保護的是「data 和 metadata 要一致」。但其實我們可以選擇「data 要不要也進 journal」:寫 data 兩遍比較安全但很貴,寫 metadata 兩遍便宜但 data 自己沒保護。ext4 把這個取捨開放給使用者,提供三種 mode:
- data mode:data 和 metadata 都寫進 journal。每個 byte 寫兩次,WAF = 2。最安全。
- ordered mode(default):只有 metadata 進 journal,但強制 data 必須在該 transaction 的 commit record 寫入 journal 之前,先被 flush 到正式位置。
- writeback mode:只有 metadata 進 journal,data 和 metadata 的寫入順序沒有保證。
為什麼 default 是 ordered?想像你在 writeback mode 下 crash:metadata journal 已經 replay 完成,inode 指向一批新 block,但那些 block 裡的 data 其實還沒寫進去,留下前一任使用者刪檔後的無效資料。使用者 read() 會合法地拿到別人的舊資料。這就是先前提到的資安漏洞。
ordered mode 透過強制先 data 後 metadata 的順序來避免這個問題,同時又不須把 data 寫兩遍,在「安全」和「便宜」之間找到一個平衡點。
fsync() 的效能問題
journaling 最貴的地方不是平常的 append,而是 fsync()。平常寫檔案,kernel 會把修改累積在記憶體裡,每 5 秒才做一次大的 journal commit。但當應用程式呼叫 fsync(),它要求的是「現在、立刻、把這個檔案的修改寫到硬碟」,kernel 必須馬上觸發一次 journal commit,不能等。
問題在於 JBD2 的 commit granularity 通常遠大於 fsync 實際要保護的修改大小。假設你的 application 只改了一個 inode 的 size 欄位(8 bytes),然後 fsync。JBD2 會怎麼做?
- 把整個 inode 所在的 4 KB block 寫進 journal
- 外加一個 4 KB 的 descriptor block(說明這筆 transaction 包含哪些 block)
- 還要再寫一個 4 KB 的 commit record
這叫做 granularity mismatch:明明只改了 8 bytes,journal 卻寫了至少 12 KB,平均下來每個 JBD2 commit 會寫 6 個 block,約 24 KB。而且為了確保資料真的寫入硬碟(不只是停在 SSD 的 volatile cache 裡),JBD2 還必須在寫 commit record 之前送一個 cache flush 指令,強制硬碟把 cache 裡的東西全部刷到 non-volatile memory。cache flush 是一個非常昂貴的操作,它不只 flush 你在意的東西,連旁邊正要跟其他寫入合併的 cache 資料也一起被強制寫出去。
對 fsync-heavy 的 workload 而言,這種行為會大大拖慢效能。ext4 的主要開發者曹子德(Theodore Ts'o)和他在 Google 的同事在 2024 ATC 發表的論文裡實測,一個 multi-threaded mail-server workload 裡,JBD2 自己就吃掉了整顆硬碟 76% 的寫入頻寬,留給真正使用者資料的只剩 24%。

FastCommit
曹子德他們提出 FastCommit,他們的觀察是:recovery 只在極少數情況發生,而 fsync 每天發生無數次。我們能不能在 fsync path 上改用 logical journaling,又保留 JBD2 做定期的 physical commit 呢?
他們的方法是這樣的:
- 每 5 秒跑一次 JBD2 的傳統 physical commit(也叫 slow commit),把這段時間累積的修改完整寫入硬碟
- 每個
fsync則不觸發 JBD2,而是寫一條小小的 logical log(FastCommit 叫它 FCLog)到 journal 裡另外切出來的 FastCommit 區(佔整個 journal 空間的 1.5%) - crash recovery 時:先 replay 最後一次 JBD2 slow commit,再依序 replay 之後累積的 FCLog
FCLog 的設計極度精簡。FastCommit 只定義了 8 種 tag:HEAD、ADD_RANGE、DEL_RANGE、CREAT、LINK、UNLINK、INODE、TAIL,剛好足以描述最常見的檔案操作。一個 append 4 KB 的 fsync 用 JBD2 要寫 6 個 block(24 KB),用 FastCommit 只要 168 bytes。rename 一個檔案,JBD2 要寫 28 KB,FastCommit 要 192 bytes。超過 80% 的 FCLog 都能塞容納在一個 4 KB block 裡。
實驗結果顯示,應用程式 throughput 提升最高 1.2 倍,journal 消耗的頻寬降到 44% 以下。FastCommit 不用改 on-disk format、API介面,讓舊 ext4 檔案系統在 kernel 升級後可以無縫接軌。這種「無痛升級」是最理想的系統優化了。
Journaling v.s CoW
Journaling 和 CoW 都在解決同一個問題:檔案系統一次修改要動好幾個 on-disk structure,而硬碟一次只能 atomic write 一個 sector。 我們做個小比較:
| Journaling | Copy-on-Write | |
|---|---|---|
| Atomicity boundary | Commit record 的單一 sector 寫入 | Root pointer 的 8-byte swap |
| Write amplification 形狀 | 寫兩遍:一次進 journal,一次進正式位置 | Path 上所有 block 重寫:leaf → root |
| Crash recovery | Replay journal 裡 committed 的 transaction | 不需要 recovery,crash 時硬碟上就是某個 valid state |
| Fragmentation | In-place update,不會造成 fragmentation | Out-of-place,長期使用會 fragment |
| Snapshot | 困難,需要另外做 | 天然支援,保留舊 root pointer 就是 snapshot |
| 例子 | ext4、NTFS | Btrfs、ZFS |
自問自答
試著用自己的話回答以下問題。如果卡住了,回去重讀對應的段落。
-
Commit record 為什麼必須是單一個 block?
-
在 writeback mode 下,inode 已經被 journal 保護了,為什麼還是可能出現資安漏洞?
-
一個 ext4 系統跑一個 workload:每秒 1000 次 append 4 KB 到不同檔案、每次 append 後都呼叫 fsync。估算 JBD2 每秒寫入 journal 的 byte 量,以及 FastCommit 每秒寫入 journal 的 byte 量。比例大約是多少?
-
在 FastCommit 出現之前,database 社群發明了一個 workaround 來解決 fsync 的效能問題:直接用
O_DIRECT繞過 page cache,自己管理 WAL。這個 workaround 和 FastCommit 的 hybrid journaling 哲學有什麼相似之處?