Journaling

Hands typing on the Raspberry Pi 500+'s built-in mechanical keyboard
Niko 的新桌機:Raspberry Pi 500+,附贈機械式鍵盤喔! (圖片來源:Jeff Geerling, Testing the Raspberry Pi 500+'s mechanical keyboard)

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 裡長這樣:

Journal layout: TxB, inode v2, bitmap v2, data block, TxE blocks laid out sequentially in the journal area
一筆 transaction 在 journal 上的 layout:TxB(begin marker)→ block 新版本(I[v2]、B[v2]、Db)→ TxE(commit block,即 commit record) (圖片來源:OSTEP, File System Journaling)

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 journalingLogical journaling
記錄單位完整 block image (4 KB)操作描述 (幾十 bytes)
Journal 寫入量高低
Recovery 複雜度低(重寫 block)高(重演操作)
實務代表ext3/4 (JBD2)、NTFSXFS

那麼,如果 logical 的效能比較好,是不是大家都用 logical 才對呢?但 ext4 用的是 physical journaling,靠一個叫 JBD2 的子系統實作。原因是 JBD2 已經運行了幾十年,程式碼穩定。XFS 雖然是純 logical journal,但那是他們從一開始就這樣設計的。

Journal byte overhead vs fsync frequency: JBD2 and CJFS (both physical) overhead grows steeply as fsync gets more frequent, while XFS (logical) stays low
Physical (JBD2) vs Logical (XFS) journal byte overhead 實測(CJFS 是另一個 physical journal 系統,曲線基本與 JBD2 重合):fsync 越頻繁,physical journal 的 overhead 越大 (圖片來源:FastCommit, USENIX ATC '24, Figure 2)

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 會怎麼做?

  1. 把整個 inode 所在的 4 KB block 寫進 journal
  2. 外加一個 4 KB 的 descriptor block(說明這筆 transaction 包含哪些 block)
  3. 還要再寫一個 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%。

Mail-server write-bandwidth breakdown: top plot shows Ext4+JBD2 with journal occupying 76% of bandwidth; bottom plot shows Ext4+FastCommit with journal reduced to a small fraction
Mail-server workload 的寫入頻寬分佈(FastCommit 論文 Figure 1):上圖是 Ext4+JBD2,journal 吃掉 76%;下圖是 Ext4+FastCommit,journal 大幅縮水 (圖片來源:Shirwadkar, Kadekodi, Ts'o (Google), FastCommit, USENIX ATC '24, Figure 1)

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。 我們做個小比較:

JournalingCopy-on-Write
Atomicity boundaryCommit record 的單一 sector 寫入Root pointer 的 8-byte swap
Write amplification 形狀寫兩遍:一次進 journal,一次進正式位置Path 上所有 block 重寫:leaf → root
Crash recoveryReplay journal 裡 committed 的 transaction不需要 recovery,crash 時硬碟上就是某個 valid state
FragmentationIn-place update,不會造成 fragmentationOut-of-place,長期使用會 fragment
Snapshot困難,需要另外做天然支援,保留舊 root pointer 就是 snapshot
例子ext4、NTFSBtrfs、ZFS

自問自答

試著用自己的話回答以下問題。如果卡住了,回去重讀對應的段落。

  1. Commit record 為什麼必須是單一個 block?

  2. 在 writeback mode 下,inode 已經被 journal 保護了,為什麼還是可能出現資安漏洞?

  3. 一個 ext4 系統跑一個 workload:每秒 1000 次 append 4 KB 到不同檔案、每次 append 後都呼叫 fsync。估算 JBD2 每秒寫入 journal 的 byte 量,以及 FastCommit 每秒寫入 journal 的 byte 量。比例大約是多少?

  4. 在 FastCommit 出現之前,database 社群發明了一個 workaround 來解決 fsync 的效能問題:直接用 O_DIRECT 繞過 page cache,自己管理 WAL。這個 workaround 和 FastCommit 的 hybrid journaling 哲學有什麼相似之處?