5.2 日誌式檔案系統與崩潰一致性(含 journaling 理論)#
崩潰一致性:最陰險的問題#
磁碟寫入不是原子的:一次邏輯寫入可能拆成多個塊 I/O,而電源在任意兩個塊 I/O 之間斷掉是常態(不是例外——資料中心每年都會發生)。重開機後,磁碟上的資料結構可能處於半更新狀態。
考慮在 ext2 式檔案系統中新增一個檔案的寫入序列:
- 更新 inode 位圖(標記新 inode 已用)
- 更新資料塊位圖(標記資料塊已用)
- 寫 inode 本體
- 寫資料塊
- 寫目錄項
若在步驟 1 之後崩潰:inode 被標記為已用,但沒有任何目錄項指向它——洩漏的 inode(空間永遠浪費)。若步驟 5 寫了一半:目錄項指向一個內容錯亂的 inode——懸空引用,讀它可能導致核心崩潰。若位圖說「已用」但資料塊沒寫完——內容是垃圾。fsck(file system checker)開機掃描全盤修復,在 1990 年代的大磁碟上要數小時——伺服器斷電後幾小時不能服務,不可接受。
崩潰一致性(crash consistency)的正式目標:任何時刻斷電,重啟後磁碟狀態要嘛是某個合法瞬間的完整快照,要嘛是某次已宣告完成操作的完整結果——絕不是兩者的碎片混合。這在理論上等價於可線性化(linearizable)的持久性,與 3.2 節的原子指令、資料庫的 ACID 中之 A(原子性)與 D(持久性)是同一問題在不同層的顯現。
解法一:日誌(Journaling / Write-Ahead Logging)#
來源:資料庫的 WAL#
日誌的思想借自資料庫的預寫日誌(Write-Ahead Logging, WAL),理論奠基於 1970 年代 System R 與 Jim Gray 的事務處理理論:修改資料之前,先把「打算做什麼」完整記錄到順序寫入的日誌區。因為日誌是純順序寫,即使頻繁小寫入也能高效(磁頭不需尋道)。FS 的日誌式設計由 EZ-JFS/實務上以 1990 年代的廠商系統(如 Veritas VxFS、ReiserFS 1997、SGI XFS 1994、IBM JFS)先驅,學術總結則歸功於 UCSC 的 Remzi Arpaci-Dusseau 團隊 2000 年代的論文與教材(《Operating Systems: Three Easy Pieces》第 jf 章),Linux 的 ext3(Stephen Tweedie,1999 併入 kernel)使其普及。
三步協議#
以「追加一個檔案」為例,ext3 式(ordered mode)流程:
// 1. 日誌提交:把元資料修改打包寫入日誌區
journal_begin(txn);
log_write(inode_bitmap); // 預期修改
log_write(data_bitmap);
log_write(inode_block);
log_write(dir_block);
journal_commit(txn); // 寫入 commit block(含校驗和)
// 2. 檢查點(checkpoint):日誌確實落盤後,
// 把這些修改正式寫到磁碟上的最終位置
checkpoint();
// 3. 釋放:日誌中該事務可被回收重用
journal_free(txn);
崩潰恢復(重啟時):
void recover(void) {
replay: // 掃日誌區
while (事務不完整) break; // 沒有 commit block → 丟棄
for (每個已 commit 未 checkpoint 的事務)
把日誌中的修改重放到最終位置; // 重放是冪等的
截斷日誌;
}
兩個關鍵:
- commit block(提交記錄):它是最後寫入的哨兵。若它不完整(校驗和錯),整個事務視為未發生——原子性由「哨兵最後落盤」保證。
- 冪等重放:重放兩次結果相同(重寫同樣的塊內容),所以崩潰發生在恢復中間也安全。
三種日誌模式(ext3 的取捨光譜)#
| 模式 | 日誌內容 | 一致性 | 效能 |
|---|---|---|---|
| journal | 元資料+資料全記 | 最強 | 最慢(資料寫兩次) |
| ordered | 只記元資料;資料先於元資料落盤 | 元資料一致、內容不垃圾 | 中 |
| writeback | 只記元資料,順序不保證 | 元資料一致,內容可能含舊塊垃圾 | 最快 |
ordered 模式成為 ext3 預設:它以「強制資料塊先寫」換取「永不洩漏垃圾內容」,是效能量與正確性的甜點。注意:這裡的核心難點是寫入順序——「資料先落盤、元資料後落盤」需要 OS 強制裝置遵守順序(barrier/flush 命令)。硬碟與 SSD 的 write cache 若不誠實遵守 FLUSH CACHE,日誌就形同虛設——這是 2000 年代大量「斷電資料損毀」事故的根源,也是後來 FUA、flush barrier 語意進入 block layer 的原因。
解法二:Copy-on-Write 檔案系統#
日誌式把「舊值→新值」的更新原地寫回(in-place update),因此需要日誌記錄。COW 檔案系統(ZFS,Sun/Jeff Bonwick 團隊,2005;btrfs,Oracle,2009)採取根本不同的策略:永遠不原地覆寫——任何修改都把受影響的塊(及其上游的所有父塊,一路到超級塊)複製到新位置寫入,最後用一次原子操作(CAS 式的單塊寫入)切換根指標(uberblock 的 txg)。
graph TB
subgraph 舊樹(斷電前)
U1[uberblock v5] --> M1[metadata v5] --> D1[data v5]
end
subgraph 新樹(事務 t6)
U2[uberblock v6 ←最後原子寫] --> M2[metadata v6] --> D2[data v6(新塊)]
end
D1 -.舊塊成為快照.-> SNAP[(可保留的快照)]特性:恢復零成本(斷電時 uberblock 要嘛指舊樹要嘛指新樹,兩者都一致)、快照免費(舊塊天然保留)、寫時全域 CRC(每塊校驗和內建,靜默腐壞 silent corruption 可被偵測甚至以冗餘修復)。代價:寫入放大與碎片化(COW 讓順序寫的資料散開)、需背景壓縮/整理。ZFS 的設計文件(Bonwick, 2001「The Zettabyte File System」)明言其理論來源是 LFS(Log-structured File System,Rosenblum & Ousterhout,1992)。
LFS:1992 年的先知#
LFS(Rosenblum & Ousterhout,SOSP 1992)主張:磁碟的瓶頸是尋道,那為什麼不把所有寫入(包括元資料)都變成純順序追加?讀取靠一張「inode map」索引到散布的新位置。LFS 是 COW 思想的先驅,其代價是垃圾回收:舊位置不斷累積,需背景清理(segment cleaning)。三十年後,SSD 的 FTL(快閃轉譯層)、log-structured 合併樹(LSM-Tree,LevelDB/RocksDB/LSM 資料庫)全部是 LFS 思想的直接繼承——理論先行三十年,硬體追上後爆發。
journaling 的形式理論#
把崩潰一致性形式化,有兩條理論線:
- 可線性化與持久原子性(persistent atomicity, 2010s):斷電後的操作序列應等價於某個串行順序。日誌+commit block 實現了「全有或全無」的事務語意,即持久化的線性化。
- Crash Hoare Logic(FRCS/UCSD 的 Cheops、FSCQ,2015):Kolosov 等人與 MIT 的 Tej Chajed 等人(FSCQ,SOSP 2015,Frans Kaashoek 團隊)以定理證明器(Coq)形式化日誌協議,證明「使用 FSCQ 的檔案系統在任何崩潰序列下都不會遺失已 fsync 的資料」——把 journaling 的正確性從工程經驗升級為數學定理。這是 OS 領域形式驗證的里程碑。
- CrashMonkey/ACE(2017+):模型檢查路線——窮舉崩潰點,自動找檔案系統的崩潰不一致 bug,已實測發現 ext4/xfs/btrfs 的真實缺陷。
應用層的一致性:fsync 的地獄#
檔案系統提供機制,應用要自己負責語意。一個「安全保存檔案」的正確流程(PostgreSQL/SQLite 都這樣做):
int fd = open("data.bin", O_WRONLY);
write(fd, buf, len);
fsync(fd); // 1. 資料落盤
close(fd);
int dfd = open(".", O_RDONLY);
fsync(dfd); // 2. 目錄項也要 fsync!
close(dfd); // 否則崩潰後檔案可能不存在
fsync(".") 這一步是無數資料遺失 bug 的來源(2018 年 PostgreSQL 10 的 fsync 錯誤處理爭議、SQLite 的 rollback journal 設計文件都詳述此坑)。更上層的封裝:資料庫的 WAL+檢查點、Kafka 的 append-only log+副本同步、Kubernetes etcd 的 Raft 日誌——分散式一致性協議(Raft/Paxos)的持久層,本質上就是「網路版的 journaling」:先寫日誌、後應用狀態、多數確認才算提交。5.2 的主題與分散式系統在此合流。
與編譯器/虛擬機的關聯#
- 編譯器的寫入排序:
fsync前的資料可被編譯器/CPU 重排嗎?fsync是系統呼叫(隱含屏障),但應用程式碼中的記憶體操作與裝置映射(mmap)寫入的排序,需依賴編譯器不重排跨系統呼叫的存取——C 的 as-if 規則在此受限於「可觀察行為」的定義,mmap 的語意(MS_SYNC)正是為此設計。 - JVM 的持久化:Java 的
FileChannel.force()即 fsync;JDK 早期「double commit」陷阱(先寫資料、再 rename、中間崩潰)與 OS 層 journaling 的故事同構。現代 JVM 的 log-structured GC、ZGC 的染色指標,都是「先寫元資料、原子切換」思想的堆內版本。 - 快速重建 vs. 形式驗證:FSCQ 證明了一件不可能手驗的事——編譯器/形式方法社群的工具(Coq、模型檢查)正在成為 OS 工程的新工具鏈,這是兩個領域在 2010 年代最深刻的融合。
重點整理#
- 崩潰一致性要求「任何斷電點重啟後狀態合法」;原地更新+多塊寫入天然違反,fsck 是昂貴的補救。
- Journaling(WAL)以「先寫日誌、commit 哨兵、冪等重放」實現原子性;ordered 模式以寫入順序換正確性,依賴裝置誠實遵守 flush。
- COW(ZFS/btrfs)以「永不原地寫+根指標原子切換」實現免恢復、免費快照與內建校驗;LFS(1992)是其思想先驅。
- 理論上,journaling 已被形式驗證(FSCQ);實務上,fsync 的正確使用仍是應用層的雷區,而分散式一致性(Raft)是其網路化延伸。