讓其他一切成為可能的那道分割
第一篇從上方給了你VFS的視角:一條路徑進來,核心讓它走過一棵由記憶體中的 dentry 組成的樹、抵達一個記憶體中的索引節點物件,再從那裡讓 read() 與 write() 分派到某個檔案系統自己的函式。那是執行期的圖像,活在 RAM 裡。本篇落到它底下,問一個 VFS 刻意藏起來的問題:當什麼都沒被快取、機器剛開機時,這一切在實際的磁碟上究竟位於何處、又是什麼形狀?你會帶走的最重要的一個概念是一道分割——而一旦你看見它,每一個 Unix 檔案系統一半的設計都會各就各位。
這道分割是:一個檔案的名字與一個檔案的資料儲存在兩個完全不同的地方,而連接它們的是一個編號。 幾乎每個人來的時候都假設「report.txt」這個名字以某種方式黏在檔案的位元組上,像一張貼在箱子上的標籤。並非如此。report.txt 的位元組活在一個結構裡,亦即索引節點,而它根本不持有任何名字。名字活在別的地方、活在目錄裡,與那個索引節點的編號配成一對。拉動這個事實,你就已經能預測:為什麼一個檔案能有兩個名字、為什麼重新命名從不複製資料,以及為什麼刪除一個名字不一定會刪除一個檔案。
索引節點:關於一個檔案的一切,除了它的名字
一個索引節點是一筆小而固定大小的紀錄——對 ext4 來說 128 或 256 位元組是典型的——而它就是一個檔案真正的身分。把它打開,你會找到這個檔案擁有的每一項屬性,除了它的名字:檔案型別(普通檔案、目錄、符號連結)、權限位元與擁有者與群組、以位元組計的大小、三個時間戳,以及一個我們稍後會回頭談的連結計數。接著是真正幹活的那部分:一張從檔案的邏輯位元組、對映到磁碟上實際持有它們的實體區塊的地圖。索引節點是檔案的脊椎;名字不過是某人掛在它上頭的一個標籤。
這些紀錄住在哪裡?住在一個固定的陣列裡,亦即索引節點表,在檔案系統被建立時寫到磁碟上一塊已知的區域。每個索引節點以它在那個陣列裡的位置來識別——它的索引節點編號,就是你在「 ls -i」或「 stat report.txt」裡看到的、標示為「Inode」的那個整數。這正是第一篇路徑漫步所終結於的那個編號。由於這張表大小固定,索引節點的數量在格式化時也就固定了:當你用數百萬個極小的檔案把磁碟填滿時,它可能在還剩數 GB 空間之際就用光了索引節點。那不是一個臭蟲,而是預先佈置一張固定索引節點表的直接後果。
$ stat report.txt
File: report.txt
Size: 8192 Blocks: 16 IO Block: 4096 regular file
Device: 259,2 Inode: 1973 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 1000/ joey) Gid: ( 1000/ joey)
inode 1973 holds: type=regular mode=0644 uid=1000 size=8192
links=1 block-map -> [ data blocks ... ]
^ no filename anywhere in here目錄:名字對編號的表格,僅此而已
如果名字不在索引節點裡,那在哪裡?在目錄裡——而妙處在於一個目錄本身就只是一個檔案。 它有它自己的索引節點(型別是「目錄」而非「普通」),而它的資料區塊裡裝的不是文件文字;它們裝的是一張由目錄項目組成的表格。每一筆項目幾乎什麼都沒有:一個檔名,以及它所指向的索引節點編號。這就是一個目錄的全部:一串(名字,索引節點編號)的配對。「開啟目錄 /home/joey」的意思是「讀取 /home/joey 解析到的那個索引節點的資料區塊,並把它們剖析為一串這樣的配對」。
現在第一篇裡的路徑解析不再是魔法,而成了機械的步驟。要解析 /home/joey/report.txt,核心從根索引節點(一個已知的固定編號,傳統上是 2)開始、讀它的目錄項目、找到名為「home」的那一筆、取那筆項目的索引節點編號、讀那個索引節點的目錄項目、找到「joey」,然後重複——每一段路徑分量做一次索引節點讀取與一次表格掃描——直到它找到「report.txt」並得知它的索引節點編號。一條路徑裡的每一道斜線都是這樣的一次查找。目錄是唯一知道名字的結構;索引節點從來都不知道。
這個名字/索引節點的分割正是硬連結背後的秘密。因為一個名字不過是一筆指向某個索引節點編號的項目,沒有什麼能阻止兩筆目錄項目——即使在不同的目錄裡——指向同一個索引節點編號。於是兩個名字都指涉同一個一模一樣的檔案;沒有正本、也沒有副本。這正是索引節點裡那個連結計數所數的東西:有多少筆目錄項目指向它。「$ rm report.txt」並不抹除資料——它移除一筆目錄項目並把那個計數減一。只有當計數歸 0(且沒有任何行程仍把該檔案開著)時,檔案系統才真正釋放那個索引節點與它的區塊。刪除一個名字並不是刪除一個檔案。
從索引節點到位元組:延伸區段與間接的把戲
現在談索引節點真正的工作:把邏輯的檔案位移對映到實體的磁碟區塊。天真的作法是一份單純的清單——檔案的第 0 塊在磁碟區塊 X、第 1 塊在磁碟區塊 Y,依此類推。它行得通,但很浪費,因為真實的檔案通常是連續儲存的:數千個相連的檔案區塊坐在數千個相連的磁碟區塊上。逐一列出每一個,就像在你大可說「第 1 到第 1000 號座位」時,卻寫下「座位 1、座位 2、座位 3、……座位 1000」。那份壓縮正是一個延伸區段的概念。
一個延伸區段是對一段連續區塊的單一描述子:三個數字,說「檔案的邏輯區塊 L 對映到磁碟上的實體區塊 P,長度為 N 個區塊」。一個 12 位元組的延伸區段就能涵蓋一塊 4 MiB 的區域,而舊的逐區塊清單會需要一千筆項目才行。像 ext4 這樣的現代檔案系統,會把少數幾個延伸區段直接存在索引節點本身裡,於是對常見情形——一個塞得進幾段連續區段的檔案——整張區塊地圖會在讀取索引節點的同一次磁碟存取裡就被讀進來。這就是為什麼 ext4 讀取大型、配置良好的檔案如此有效率:地圖很小,而且已經在手上了。
但如果一個檔案巨大、或破碎得很厲害,需要的延伸區段多到塞不進索引節點那個小欄位呢?此時較舊卻仍至關重要的間接區塊把戲就登場了,有時還被包進一棵樹裡。索引節點不把地圖內嵌儲存,而是指向一個另外的磁碟區塊、由它持有更多的地圖項目;若連那個也滿出來,一個雙重間接區塊就指向一塊裝著指向地圖區塊之指標的區塊,依此類推。ext4 把這個一般化為一棵延伸區段樹:索引節點持有樹根,而磁碟上的內部節點向外展開到裝滿延伸區段的葉子。代價是誠實的——一個破碎得厲害的檔案,光是在讀取任何資料之前就得多做幾次磁碟讀取來拼湊出它的地圖——而這正是破碎傷害效能的真正意涵。
把這一切佈置到磁碟上
退一步,把一個全新的 ext 家族檔案系統在格式化時寫下的那些區域組裝起來。首先,在最前頭附近,是那個超級區塊——一筆描述整個檔案系統的小紀錄:區塊大小、總區塊數、存在多少個索引節點、其他各區域從何處開始。它是主索引,而它如此重要,以至於檔案系統會把它的備份副本散佈在磁碟各處。在沒有備份的情況下弄丟超級區塊,磁碟就會變成一片無法讀取的區塊汪洋,即使你資料的每一個位元組仍實體地待在那裡。
接著是記帳用的點陣圖。一個區塊點陣圖是每個資料區塊一個位元:1 代表已用、0 代表空閒。要為一個正在長大的檔案配置空間,檔案系統就掃描這張點陣圖找 0;要釋放一個區塊,就清掉那個位元。一個索引節點點陣圖對索引節點表做同樣的事。然後是索引節點表本身,再來是磁碟絕大部分的篇幅:那些同時持有檔案內容與目錄項目的資料區塊。ext4 其實把磁碟切成許多區塊群組,每一個都是這份配置的微縮副本,於是一個檔案的索引節點、它的資料、它的目錄往往會落在彼此附近——純粹是對硬體機械特性的體貼,讓相關的讀取在實體上靠得近。
把整幅圖像拉在一起,它小得令人滿意。一個檔案就是它的索引節點,一個索引節點持有後設資料外加一張延伸區段地圖(在需要時溢位到間接或樹狀區塊),而名字則分隔地活在目錄裡、以指向索引節點編號的項目存在——這道分割白白給了你硬連結與廉價的重新命名。超級區塊、點陣圖、索引節點表與資料區塊,不過就是這一切被寫下的地方。接下來,第三篇直面這個提示框剛剛揭露的危險:一個檔案系統如何在那些寫入之間撐過一次當機而不損毀自己。