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

從當機中存活:日誌與寫入時複製

一次單純的檔案寫入,可能同時碰到索引節點、一張空閒空間點陣圖,以及一個資料區塊——可是電源,能在這幾筆寫入之間的任何一處斷掉。這篇導覽談的就是那道令人膽寒的縫隙:檔案系統如何可能落得只做到一半,以及那三大策略——日誌、寫入時複製,與日誌結構式設計——如何讓它在當機之後醒來,卻不至於變成一堆垃圾。

一次檔案寫入,其實偷偷是三次寫入

到現在,你已經把整台機器都蓋起來了。你知道一個 索引節點握有檔案的屬性以及它資料區塊的位址;你知道一張空閒空間點陣圖追蹤著哪些區塊被佔用了;你知道寫入並不會直接送到碟片上,而是先暖暖地待在緩衝快取裡。現在我們要問那個一直潛伏在這一切底下的問題:要是電源在中途斷掉,會怎麼樣?這就是當機一致性要談的主題,也是檔案系統設計裡最難、最讓人謙卑的問題。

這就是它為什麼這麼危險。想像把一個資料區塊附加到一個檔案後面。這個看似無辜的單一動作,其實會改動磁碟上三個不同的地方:那個新的資料區塊本身(你寫進去的位元組)、點陣圖(把那個區塊標記為現在已被使用),以及索引節點(記下新區塊的位址、以及檔案變大後的新大小)。三筆寫入——而磁碟一次只能真正地提交一個區塊。當機可以在這三筆當中任意兩筆之間的縫隙裡突襲,把你的檔案系統凍結在做到一半的狀態。

而且,並非所有「做到一半」都一樣糟。假設索引節點和點陣圖都寫成了,但當機在資料區塊落地之前就打中:你的檔案現在宣稱擁有一個區塊,裡頭卻裝著「之前住在那裡的東西」留下的陳舊垃圾。或者更糟,假設索引節點被更新去指向新區塊,可點陣圖卻從沒被標記:那個區塊既被你的檔案搆得到、卻仍被標成空閒,於是下一個要配置空間的檔案會抓走同一個區塊,兩個檔案就會悄無聲息地把彼此弄壞。一個寫到一半的檔案系統,不只是不完整——它可能是主動地、危險地不一致。

老辦法:把全部掃過一遍,然後祈禱

第一代檔案系統用一種蠻力的辦法來對付「做到一半」這個問題:別去防它,事後清理就好。每次開機時——或至少在一次不乾淨的關機之後——一個修復工具(經典的 Unix 版本叫 fsck,「檔案系統檢查」)會走遍整顆磁碟,找出不一致之處。它順著每個索引節點的指標,從頭重建空閒空間點陣圖,交叉檢查每個搆得到的區塊都被標成已用、每個已用的區塊都搆得到,數一數指向每個索引節點的連結數,再把任何兜不攏的地方補起來。

這管用,但它有一個致命缺陷,而且一年比一年嚴重:時間。fsck 必須檢查整個檔案系統,所以它的代價隨磁碟大小而成長,而不是隨「當機打中時實際正在寫入多少」而成長。一次只碰到一個小檔案的當機,仍然會逼出一場對好幾 TB 磁碟區的掃描——每次重開機都是好幾分鐘、有時好幾小時,一台伺服器就這麼黑著、不可用地枯坐著。隨著磁碟從 MB 長到 TB,「全部掃過一遍然後祈禱」就從可接受變成了無法忍受。我們需要一種復原方式,它的代價只跟「當機那一刻正在進行中的工作量」一樣大。

日誌:先把你的打算寫下來

現代的主流答案是日誌式檔案系統,它的核心想法,直接借自一個謹慎的人怎麼記一本銀行帳簿:在你真正改動任何東西之前,先把你即將要做的事一字不差地寫下來,寫進一塊特別保留的區域,叫做日誌(或預寫式日誌,write-ahead log)。唯有等整份計畫都被安全地記錄下來之後,你才去執行真正的更新。這就是預寫式日誌:日誌條目永遠在它所描述的那個改動之前先落地。

為什麼這能救我們?因為它把三筆散落的、各自可被打斷的寫入,變成一筆全有或全無的交易(transaction)。整則日誌條目以一個小小的提交紀錄(commit record)結尾——那是一個標記,意思是「這筆交易完整且最終定案了」。提交紀錄是單獨一個區塊,所以它要嘛被寫成、要嘛沒被寫成;不存在「一半」。那一個原子性的區塊,正是整套方案賴以轉動的樞紐。以下是我們那筆「三次寫入」附加的完整流程。

  1. 把資料區塊、點陣圖與索引節點「打算寫成的新版本」寫進日誌區——但「先別」碰它們真正的家。如果當機在此刻打中,真正的檔案系統毫髮無傷、完好如初;那則寫到一半的日誌,直接被忽略就好。
  2. 等那些日誌區塊全都安全地落到磁碟上,才寫下那單獨一個提交紀錄。這一個原子性的區塊,把整筆交易從「暫訂」翻轉為「定案」。
  3. 現在(而且唯有現在)才執行真正的更新:把資料、點陣圖與索引節點,從日誌複製到它們真正的位置上。這個步驟叫做設立檢查點(checkpointing)。
  4. 等真正的更新落地之後,把那筆日誌交易標記為空閒,好讓它的空間能被下一筆交易再利用。日誌是一塊小小的環形緩衝區,不是一份不斷長大的紀錄。

當機後的復原,現在便宜得漂亮。重開機時,檔案系統只讀日誌——一塊極小的、固定大小的區域,而非整顆磁碟。對每一筆交易,它只問一個問題:有沒有一個有效的提交紀錄?如果有,它就單純地把真正的更新重做一遍(重複做是安全的,因為把同樣的區塊複製兩次什麼也不會改變——這個性質叫冪等性,idempotence)。如果沒有提交紀錄,那筆交易就從沒完成過,於是被丟掉,彷彿它從沒發生過。無論哪種情況,檔案系統都在幾毫秒內落到一個一致的狀態,不管磁碟有多大。

寫入時複製:永不就地覆寫,永遠改放新處

日誌付出「寫兩次」這筆稅,來讓更新變安全。而寫入時複製檔案系統(例如 ZFS 或 Btrfs)立下一個截然不同的誓言:絕不就地覆寫一個還活著的區塊——永遠不。當你要改一個區塊,就把新版本寫到一個全新的、空閒的位置,讓舊區塊完全保持不動。你在記憶體那一段早就見過這個一模一樣的把戲,也就是用在 fork 出的行程分頁上的寫入時複製;在這裡,它成了整個磁碟上檔案系統的組織原則。

可是,如果資料區塊搬到了新地點,那個指向它的索引節點現在就指錯了——所以索引節點也必須被更新。而在寫入時複製裡,你連索引節點也不能覆寫!於是你在一個全新的位置寫下一份索引節點的新副本。這又讓指向那索引節點的目錄指錯了,所以它也得到一份新副本。改動就這樣一個區塊接一個區塊地往上漣漪,一路漣漪到樹的最頂端——檔案系統的(root)。底下沒有任何東西被覆寫過;你是沿著那棵舊的、仍然有效的樹,長出了一整條由全新區塊構成的新路徑。

  OLD tree (still 100% valid)        NEW blocks written alongside

        [root]  <--- old root           [root']  <--- new root
        /    \                           /     \
   [dir]    [dir B]   (shared,    [dir]      [dir B']
     |       unchanged) ------------^           |
  [inode]                                    [inode']
     |                                          |
  [data]                                     [data']  (the new bytes)

  the single atomic switch:  superblock's root pointer  [root] --> [root']
一次寫入時複製的更新,會在未被觸碰的舊樹旁邊,長出一條由全新區塊(root'、dir B'、inode'、data')構成的新路徑,並共用一切沒有改變的部分。翻動一個指標——根——便原子性地把整個舊檔案系統,一口氣換成整個新的。

這裡有個優雅的回報。整場龐大的更新,靠在頂端翻動單獨一個指標就提交完成了——通常是超級區塊裡一個說「目前的根在這裡」的欄位。那一筆寫入是原子的:它要嘛指向舊根、要嘛指向新根,絕不會停在中間任何狀態。翻動之前當機,留給你的是完好無缺的舊樹;翻動之後,是完好無缺的新樹。沒有「一半」的狀態需要修復,而且原則上,根本不需要 fsck、也不需要一份獨立的日誌——當機一致性,是從這個結構本身自然掉出來的。

把它推到極限:日誌結構式檔案系統

還有第三種設計,把寫入時複製那個「永不覆寫、永遠改放新處」的想法,推到邏輯上的極致。日誌結構式檔案系統把整顆磁碟當成一條巨大無比、只能附加(append-only)的日誌。每一個改動——資料、索引節點、一切——都單純地被附加到日誌的尾端,在一筆大大的循序寫入裡完成;磁頭從不跳回去就地更新某個東西。它最初的動機是速度,而不是安全:在一顆轉動式磁碟上,一筆長長的循序寫入,比許多散落的小寫入快得驚人,而緩衝快取又吸收掉愈來愈多的讀取,所以磁碟上的流量大多是寫入。

當機一致性是順帶而來的快樂副產品,就跟寫入時複製一樣:既然你只會附加,已經在日誌上的資料就永遠不會被擾動。當機之後,你找到日誌裡最後一個一致的點,就大功告成。但只能附加的設計,把一個棘手的問題逼到了檯面上:磁碟會被死掉的區塊塞滿——也就是那些後來在日誌更下游被重寫過、已成舊版本的檔案。一個叫做清潔工(cleaner,或區段清潔工)的背景行程,必須不斷地翻揀這條日誌,找出那些塞滿了「大多已死」區塊的區段,把其中少數仍活著的區塊向前複製,再釋放那個區段。清潔工是純日誌設計要付的代價,而要把它調得好,是出了名地細膩。

這些想法並不是博物館裡的陳列品。樸素的日誌活在日常的 ext4 檔案系統裡、也活在 Windows 的 NTFS 裡。寫入時複製驅動著 ZFS 與 Btrfs。而日誌結構這個想法,曾經是為轉動式磁碟而生的研究奇想,後來卻在快閃儲存內部找到了完美的第二個家:一顆 SSD 的控制器同樣無法就地覆寫一個快閃分頁,所以它內部的快閃轉譯層,本身就是一個喬裝過的日誌結構式系統。你剛剛認識的這些當機一致性策略,正安靜地在你擁有的幾乎每一個裝置底下運轉著。

如何選擇,以及當機一致性「沒有」承諾什麼

退一步看,這三種策略排成一個家族,共享同一個洞見:絕不讓當機把你逮在「兩筆必須一起發生的寫入」之間。日誌靠「先寫下你的打算、再用一筆原子紀錄提交」來做到;寫入時複製靠「在旁邊蓋起一整棵新樹、再用一次原子的指標翻動來提交」來做到;日誌結構式設計靠「只會附加,所以根本沒有就地更新會被逮到做一半」來做到。每一種,都把一筆危險的多重寫入,化成單獨一次全有或全無的切換——它們只是各自選了不同的那個開關。

現在來談誠實的極限,因為這些機制每一個都有它鋒利的一面。第一,回想那個大但書:中介資料日誌讓你檔案系統的結構保持一致,卻仍可能交給你一個內容是「新舊位元組混合」的檔案——所以若你的資料真的必須是全有或全無,應用程式本身就得用上謹慎的手法(先寫進一個暫存檔,再原子性地改名過去)。第二,這一切都假設磁碟對「一筆寫入何時真正持久」說了實話;一顆對「自己的快取是否已清出」說謊的硬碟,能擊敗連最完美的日誌——這正是為什麼真實系統會堅持要有誠實的清出/屏障(flush/barrier)操作。

還有一個值得乾脆除掉的誤解:當機一致性不等於備份。日誌與寫入時複製保護你免於「寫到一半時當機」;但它們對「一顆硬碟實體壞死」「你刪錯了檔案」「勒索軟體」或「一場火災」毫無辦法。一個一致的檔案系統,仍然可以是一個一致地空無一物的檔案系統。你在快取那邊認識的髒位元,只告訴作業系統某個區塊需要寫回;它變不出另一台機器上的第二份副本。真正的耐久性是分層的——當機一致性,加上快照,加上機器外的備份——而本篇導覽裡任何單一機制,都不是故事的全部。