唯一一條規則:絕不寫在舊資料所在之處
第三篇留給你一個得來不易的真理:一個就地更新資料的寫入是危險的,因為一次當機可能在它做到一半時逮住它。那一篇的解法是日誌——先把你的意圖寫進一份記錄,再回頭去修改真正的區塊,於是一次當機可以靠重播或丟棄那份記錄來收拾。一個寫入時複製檔案系統從相反的方向抵達同樣的安全。它立下一條頑固的規則:當你更改一個區塊時,你絕不覆寫舊的那個。你把一份全新的副本寫到某個空閒之處,並且讓舊區塊原封不動地留著,直到那次更改完全提交為止。
你在磁碟之外早已遇過這個想法。在虛擬記憶體那一段,寫入時複製讓一個剛分岔出來的行程以唯讀方式共用其父行程的分頁,只在有人寫入某個分頁的那一瞬間才複製那個分頁。磁碟版本是同一招、瞄準的是儲存而非 RAM:在你非動不可之前別碰原件,然後在一個全新的位置做你的更改。這個名字是誠實的——複製發生在寫入時,而且只在寫入時。一個沒有人去修改的區塊,根本永遠不會被複製。
檔案系統是一棵樹,而你重新長出一根枝椏
要看清楚「絕不覆寫」為什麼有威力、而不只是浪費,把磁碟上的佈局想成一棵指標樹。葉子是你的資料區塊。在它們之上坐著中介資料區塊——在第二篇那個延伸區段與間接區塊的世界裡,就是那些說「這個檔案的位元組住在這些區塊號碼上」的結構。在那些之上又坐著更多中介資料,而在最頂端坐著單一個根區塊,它最終透過所有層次,指到磁碟區上的每一個位元組。找到根,你就能抵達任何東西;更改根,你就更改了一切之中哪一個版本是現行的。
現在跟著一次小小的寫入走。你更改一個檔案中間的四個位元組。寫入時複製不會就地編輯那片葉子。它在某個空閒之處寫下一個新的葉子區塊,裝著更新後的位元組。但上方那個中介資料區塊原本指著舊葉子,所以那個指標現在錯了——而依同一條規則,你也不能就地編輯它。於是你寫下一個新的中介資料區塊,指向新葉子。那個區塊的父親現在也過時了。你就這樣一路往上爬,沿著這條路徑為每一個節點寫一份新副本,一直爬到一個新的根。這趟攀爬是整個設計的核心,它有一個值得記住的名字:把那根枝椏從葉子到根重新長一遍。
before the write: after a copy-on-write update:
ROOT -----------+ ROOT' (new) ------+
| | | (old ROOT |
meta ... meta' still on ...
| | | disk, |
leaf meta leaf ... leaf' meta leaf ...
(data) ... (new) (shared, untouched)
only the leaf->...->root PATH is rewritten; every
block NOT on that path is shared, not copied.這裡有一份安靜的精彩:直到最後一步之前,舊根都還指著一棵完好無缺的舊樹。你的新區塊存在了,但還沒有任何東西指到它們。提交是一個微小的動作——以不可分割的方式,把磁碟上的那個指標從舊根盪向新根。在那一盪之前,一次當機留下的是完整的舊版本。在那之後,新版本是完整的。不存在任何「樹被改到一半」的中間狀態,這正是第三篇奮力爭取的當機一致性保證,而這裡根本不靠一份獨立的日誌就達成了。
快照是免費掉出來的
一旦你看見了「樹與根」這幅圖,快照就不再是一個你硬裝上去的功能,而變成一個你幾乎無法避免的東西。一個快照是整個檔案系統在某一瞬間的、凍結的唯讀視圖。要做一個,你不複製任何資料。你只要保留目前這個根指標的一份副本,並承諾不去釋放它所抵達的那些區塊。整個操作就是這樣:記住一個根。它只佔幾個位元組、在一瞬間發生,不管那個磁碟區裝著一個檔案還是一千萬個。
為什麼當現行檔案系統不斷變化時,快照仍然保持正確?因為那一條規則。新的寫入絕不覆寫快照的舊根仍指著的那些區塊——它們總是去到全新的位置、建起一個新的根。所以快照那棵樹是自動被保留、原封不動的,僅僅靠著不刪除它的區塊。現行樹與快照樹安靜地共用著自快照以來未曾改變的每一個區塊,只在那些已改變的區塊上分岔。因此一個快照在建立時幾乎不費分文,並且只隨著現行資料逐漸漂離它而增長——你為改變了的東西付出儲存,永不為保持不變的東西付費。
校驗和、自我修復,以及一個誠實的代價
這棵樹還換來一樣較舊的檔案系統難以提供的東西:端到端的完整性。因為每當一個子節點改變時,它的父區塊本來就必須被重寫,所以在父區塊裡頭順手存一個它所指向的子節點的校驗和,是很便宜的。ZFS 與 Btrfs 正是這麼做:每一個指標都帶著它所瞄準那個區塊的校驗和,一路向上直到根。當檔案系統讀一個區塊時,它重新計算校驗和並比對——於是寂靜的磁碟損毀,一個被故障硬碟或宇宙射線翻掉的位元,是被偵測出來,而不是悄悄地當成好資料交給你的程式。這跟一個信任磁碟會交回它所儲存之物的檔案系統,是一個確實不同的保證。
當磁碟區保有冗餘副本——一面鏡像、或 RAID 式的同位——偵測就變成了修復。一次校驗和不符會告訴檔案系統哪一份副本是壞的,於是它讀取一份好的副本、把正確的位元組交回你的程式、並重寫那個壞區塊,全部都是透明地進行。這就是讓這些檔案系統因長期儲存而備受喜愛的自我修復。不過要把界線說精確:一個在單一、無鏡像磁碟上的校驗和,能告訴你一個區塊損毀了,卻變不回失去的資料。完整性檢查與冗餘是兩件不同的工作,而你需要兩者兼備,磁碟才真的會自我修復。
ZFS 與 Btrfs:同一個想法的兩種演繹
ZFS 與 Btrfs 都建在寫入時複製的樹之上,但它們從不同的目標長出來,而這一點看得出來。ZFS 在 2000 年代中期出自 Sun Microsystems,它的大想法是把整個儲存堆疊一口吞下——它是檔案系統與磁碟區管理員融成一體,把許多磁碟匯聚成一池、自己管理冗餘,於是完整性與 RAID 是由那個握著校驗和的層次來決定的。它以保守而穩如磐石著稱,代價是對 RAM 胃口很大,而且因為授權的緣故,從未被併入 Linux 核心的主線。
Btrfs(即「B-tree 檔案系統」)是為 Linux 原生設計的,並住在主線核心裡。它的結構倚靠 B 樹——就是你在第二篇遇過、撐起延伸區段與目錄索引的那種平衡樹形狀——去組織那些寫入時複製的中介資料。它提供同樣的招牌功能:快照、校驗和與匯聚成池,但帶著不同的手感:要重塑一個運轉中的陣列更有彈性,而歷史上在某些邊角較為粗糙,特別是同位 RAID 模式,這正是那種在你把不能失去的資料託付給一個檔案系統之前,值得知道的誠實警語。
值得看看這如何與你已經遇過的硬體押韻。一個 SSD 的快閃轉譯層同樣拒絕就地覆寫——快閃記憶體在重寫之前必須以大單位抹除——所以它也是把新資料寫到全新的頁、重新映射一個指標,稍後再回收那些過時的頁。檔案系統層的寫入時複製與快閃層的重新映射,是同一個直覺在兩個層次上的展現:絕不覆寫現行資料,改為重導一個指標。把這個模式看見兩次,正是攀爬這座階梯的意義所在——每當某樣東西必須在另一樣東西可能正在讀它的同時安全地改變,同一個想法就一再地重現。
這把你帶到哪裡
退一步,整個設計就摺疊成一條規則及其後果。絕不覆寫現行資料:寫新區塊、把通往新根的路徑重新長一遍、再以不可分割的方式盪動一個指標來提交。從這單一條紀律,你得到了不靠日誌的當機一致性、只為分岔之物付費的近乎即時的快照,以及一個讓校驗和自然安身之處——那把寂靜的損毀變成被偵測到、並在有冗餘時被修復的東西。它的代價,誠實地付出,是碎裂與寫入放大——真實的、可量測的,而且對許多工作負載而言這筆交易划得來。
你現在已經看過對同一個當機安全問題的兩種答案:第三篇的日誌寫兩次並就地覆寫,而寫入時複製寫一次、根本從不覆寫。沒有哪一個嚴格更好;它們做的是不同的交易,而同時懂得兩者,能讓你讀懂一個檔案系統的設計、預測它的行為。不過有一條線索仍貫穿著這兩者,而它正是本段的最後一塊:所有這些安全推理都活在一層快取之上。當 write() 回傳時你的寫入並沒有打到磁碟——它們坐在分頁快取裡,直到某樣東西逼它們下去。第五篇追著那最後一哩,並釘死 fsync 對於你的資料能否撐過一次斷電,究竟做了、與沒做哪些承諾。