跳轉到

5.2 日誌式檔案系統與崩潰一致性(含 journaling 理論)#

崩潰一致性:最陰險的問題#

磁碟寫入不是原子的:一次邏輯寫入可能拆成多個塊 I/O,而電源在任意兩個塊 I/O 之間斷掉是常態(不是例外——資料中心每年都會發生)。重開機後,磁碟上的資料結構可能處於半更新狀態。

考慮在 ext2 式檔案系統中新增一個檔案的寫入序列:

  1. 更新 inode 位圖(標記新 inode 已用)
  2. 更新資料塊位圖(標記資料塊已用)
  3. 寫 inode 本體
  4. 寫資料塊
  5. 寫目錄項

若在步驟 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 的事務)
        把日誌中的修改重放到最終位置;  // 重放是冪等的
    截斷日誌;
}

兩個關鍵:

  1. commit block(提交記錄):它是最後寫入的哨兵。若它不完整(校驗和錯),整個事務視為未發生——原子性由「哨兵最後落盤」保證。
  2. 冪等重放:重放兩次結果相同(重寫同樣的塊內容),所以崩潰發生在恢復中間也安全。

三種日誌模式(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)。

特性:恢復零成本(斷電時 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 的形式理論#

把崩潰一致性形式化,有兩條理論線:

  1. 可線性化與持久原子性(persistent atomicity, 2010s):斷電後的操作序列應等價於某個串行順序。日誌+commit block 實現了「全有或全無」的事務語意,即持久化的線性化。
  2. Crash Hoare Logic(FRCS/UCSD 的 Cheops、FSCQ,2015):Kolosov 等人與 MIT 的 Tej Chajed 等人(FSCQ,SOSP 2015,Frans Kaashoek 團隊)以定理證明器(Coq)形式化日誌協議,證明「使用 FSCQ 的檔案系統在任何崩潰序列下都不會遺失已 fsync 的資料」——把 journaling 的正確性從工程經驗升級為數學定理。這是 OS 領域形式驗證的里程碑。
  3. 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)是其網路化延伸。