JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

日誌與當機一致性

一次檔案改名,可能要動到磁碟上三個不同的地方。如果電源在第一個寫入與第三個寫入之間斷掉,隔天早上你的檔案系統會長成什麼樣子?這篇從頭建起一個觀念:日誌如何把一堆危險的小寫入,變成一個全有或全無的承諾。

為什麼一個邏輯上的改動是好幾次實體寫入

從上一篇你帶著一幅關於磁碟佈局的精確圖像:一個檔案的資料住在區塊裡、它的中介資料住在一個inode裡、目錄透過目錄項把名稱對應到 inode 編號、而一個區塊配置點陣圖記錄哪些區塊是空的。牢牢記住這些,因為我們接下來要遇到的麻煩正是從這裡來的。一個對程式來說感覺起來不可分割的改動——「在這個檔案後面附加 100 個位元組」——對磁碟而言不是一次寫入。它是寫到好幾個不同、而且往往相隔很遠的位置的好幾次寫入。

把那次附加走一遍。首先檔案系統在點陣圖裡找到一個空區塊、把它的位元從 0 翻成 1。接著它把你的 100 個位元組寫進那個區塊。然後它更新 inode:一個新的區塊指標、一個變大的大小欄位、一個更新過的修改時間。三個分開的磁碟位置、三次分開的寫入。磁碟韌體與區塊 I/O 層可以用任何順序提交它們,而且關鍵在於:write() 一回傳,寫入並不就此持久——它可能先在記憶體裡的分頁快取待上一陣子。硬體只承諾一件事:一次單一磁碟磁區的寫入(通常 512 位元組,現代磁碟是 4 KiB)要嘛完整落地、要嘛沒落地。對於一寫入,它完全不做任何承諾。

中途的一次當機究竟會弄壞什麼

讓我們用「在最糟的時刻當機」把危險變具體。假設點陣圖的寫入與資料的寫入都落地了,但 inode 的寫入沒有。現在有一個區塊被標記為已用、裝著你的位元組——但沒有任何 inode 指向它。它是一個遺失的區塊:永遠被佔用、誰也搆不著、是磁碟空間的緩慢流失。惱人,但不是大災難。現在把它翻過來:假設 inode 的更新落地了(它宣稱有一個新區塊)但點陣圖的寫入沒有(那個區塊仍讀作空的)。這是危險得多的情況。檔案系統現在相信那個區塊既是空的、又是你檔案的一部分。下一個被建立的檔案可能拿到同一個區塊,於是兩個檔案無聲地共用儲存空間——寫到其中一個,就毀掉另一個。

在日誌存在之前,對這個問題的答案是 fsck——「filesystem check,檔案系統檢查」——一個修復工具,在當機之後掃描整個磁碟,找的正是這些不一致:被標記為已用卻沒人參照的區塊、指向被標記為空的區塊的 inode、與目錄項對不上的連結計數。它會把能補的補起來。這做法有兩個誠實的問題。第一,fsck 很慢:它必須讀過整個磁碟區,所以在好幾 TB 的磁碟上,一次當機可能意味著機器連開機之前都要先停機一小時。第二,fsck 只能恢復結構上的健全,不能恢復你的資料——它可能用「直接丟掉其中一個檔案的宣稱」來解決一個被重複配置的區塊。檔案系統再次變得一致了;你的一個檔案剛剛消失了。

日誌:先把你的意圖寫下來

解決這件事的點子幾乎原封不動地借自資料庫,而且一旦你看懂了,它美得很簡單。在去碰磁碟上那些真實、散落的位置之前,先把整個改動的一份精簡描述寫到一個專用、連續的區域——就是日誌。「我即將:設定點陣圖位元 4711、把這些位元組放進區塊 4711、像這樣更新 inode 92。」只有在那整份描述安全地落到磁碟上之後,你才去它們散落的家裡執行真正的更新。這套紀律——在你動手之前先把意圖持久地記下來——就是預寫日誌,也是檔案系統日誌的引擎。

魔法在末尾那一小筆紀錄。在描述寫完之後,檔案系統寫下最後一筆提交紀錄(commit record)——一個極小的標記,說「這筆交易完整且有效」。因為那個標記是一次小小的、單一磁區的寫入,硬體那一個保證就適用於它:它要嘛完整落地、要嘛完全沒有。於是提交紀錄成了決定一切的那個單點。當機之後如果它在,整筆交易就算數;如果它不在,整筆交易就被忽略。我們把一個雜亂的多寫入更新,轉換成了一個全有或全無的決定,鉸在一個單一的持久位元上。這正是你原本希望磁碟天生就給你的那種不可分割行為——日誌把它製造了出來。

TIME --->

  [ describe change in journal ]   then   [ COMMIT record ]   then   [ apply to real locations ]
        (the intent)                       (the deciding bit)         (the "checkpoint")

  Crash BEFORE commit lands  =>  on reboot, recovery sees no commit  =>  DISCARD. Disk untouched, clean.
  Crash AFTER  commit lands  =>  on reboot, recovery sees commit     =>  REPLAY journal -> finish real writes.

  Either way the filesystem ends consistent. The commit record is the all-or-nothing hinge.
預寫的順序。唯一脆弱的瞬間是提交寫入本身,而因為它是單一磁區,它繼承了硬體那一個不可分割的保證。

復原,以及它換回來的代價

現在在當機後重新開機,看復原怎麼運作。檔案系統不會像 fsck 那樣掃描整個磁碟。它只讀日誌——一個小的、連續的區域——並重播它找到的每一筆已提交的交易,把真實的寫入重做一遍,確保它們全部完成。沒有提交紀錄的交易就直接被忽略,彷彿從未發生。復原時間取決於日誌的大小,而不是磁碟的大小,所以它花幾秒而不是一小時。那個速度就是日誌勝出的頭號理由:不是它能防止 fsck 終究修不好的損壞,而是它讓復原又快又有上限。

重播必須能安全地執行不只一次,因為機器可能復原途中當機、再次重開進復原。重做一個像「設定點陣圖位元 4711」或「把這些確切的位元組存進區塊 4711」的寫入,不管你做一次還是五次,落到的都是同一個結果——再套用一次也無害。那個性質就是冪等性,它正是當機復原值得信任的原因:日誌記的是要安裝的最終值,而不是要套用的增量,所以一個做到一半的重播可以乾脆從頭再跑一次,不會重複計算。

天下沒有白吃的午餐,而日誌誠實的代價就在這裡:每個改動現在都被寫了兩次——一次到日誌、一次到它真正的家。那是實實在在的頻寬與磨損。大多數正式環境的檔案系統會刻意走一條捷徑:它們只對中介資料記日誌(inode、點陣圖、目錄項——那些一旦損壞就是大災難的結構),而把檔案資料只寫一次、直接寫到它的位置。這就是只記中介資料的日誌(metadata-only journaling),是 ext4 的預設模式。它用一半的寫入成本,讓檔案系統的結構永遠可復原。不過要對它做的取捨誠實:當機之後結構保證健全,但一個檔案的內容可能是舊位元組與新位元組的混合。骨架受到保護;血肉沒有。

日誌對你承諾與不承諾的東西

這裡有一個要在它咬你之前先除掉的誤解:日誌檔案系統並不承諾「當 write() 回傳時,你的資料已經在磁碟上」。日誌保證的是檔案系統在當機後會一致——永遠不自相矛盾、永遠不會有被重複配置的區塊。它對「你檔案的哪一個版本會存活」隻字不提。仍待在分頁快取裡、還沒被推進日誌的近期寫入,在當機時就直接遺失了,而那是完全正確的行為。一致性不是持久性。一個無損地保持一致的檔案系統,依然會樂呵呵地忘掉你最後十秒鐘的工作。

當你真的需要某一筆特定的寫入熬過一次當機時,你必須用 fsync() 明確地要求它:一個告訴核心「在這個檔案的資料與中介資料持久地落到磁碟上之前不要回傳」的系統呼叫。那就是從一致性通往持久性的橋,而 fsync() 確切的承諾——它刷新什麼、它忘掉什麼、以及為什麼連 fsync() 都有銳利的邊角——是這一級最後一篇的整個主題。現在,先把這條界線釘進腦子裡:日誌免費保證一致性;某一特定寫入的持久性,是你必須主動請求的東西。