Learning Objectives
- 說明 inode、資料區塊、目錄各存什麼,以及檔名為什麼不在 inode 裡
- 算出直接與間接指標能表示的最大檔案,判斷讀某個位置要讀幾個區塊
- 預測硬連結、符號連結、刪除開著的檔案會發生什麼
- 說明寫入為什麼會造成不一致,比較 fsck 與日誌的做法,以及 ext4 三種模式的差別
- 分辨 write 與 fsync 給的保證
刪了檔案,空間卻沒有回來
伺服器磁碟滿了,你刪掉一個幾 GB 的紀錄檔,df 顯示的可用空間卻完全沒變。重開服務之後空間才回來。
這不是 bug。下面這支程式打開檔案、刪掉它、再用原本的 fd 讀,在真正的 Linux 上跑的結果:
before unlink: links=1 size=11
open by name: not found
after unlink: links=0 size=11
read through fd: still here
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
用名稱已經找不到這個檔案,連結數是 0,內容卻還讀得到。要理解這個結果,得先知道「名稱」和「檔案」在檔案系統裡是兩件事。這一課從這裡開始,接著處理另一個問題:寫到一半斷電,磁碟上會留下什麼。
inode、資料區塊、目錄
磁碟被切成固定大小的區塊(block,常見 4 KB)。檔案系統主要有四種東西:
| 結構 | 存什麼 |
|---|---|
| 資料區塊 | 檔案的內容 |
| inode | 一個檔案的 metadata:類型、權限、擁有者、大小、時間、連結數、內容在哪些區塊。每個 inode 有一個編號 |
| 目錄 | 也是一個檔案,內容是一張「名稱 → inode 編號」的對照表 |
| 點陣圖(bitmap) | 哪些 inode、哪些資料區塊已經被使用 |
inode 裡沒有檔名。 檔名只存在目錄裡,所以一個檔案可以有很多名稱,也可以沒有名稱。
開啟 /home/a/b.txt 時,核心這樣走:讀根目錄的 inode → 讀根目錄內容,找到 home 的 inode 編號 → 讀 home 的 inode → 讀 home 的內容,找到 a……一路走到 b.txt。路徑越深,要讀的區塊越多,所以核心會把這些查找結果留在記憶體裡(dentry cache)。
open 回傳的 fd 直接指向 inode(第 4 課的開啟檔案表),之後的讀寫不再經過名稱。這就是剛才那個結果的原因:名稱刪了,inode 還被 fd 指著。
用 Python 算位置走哪一層
區塊 4 KB、指標 4 位元組,一個間接區塊放得下 1024 個指標。程式先算出這個位置在第幾塊,再一層一層扣掉前面能表示的範圍。
實作:算出要讀幾個區塊
補完 reads:給檔案裡的位置,回傳讀取這個位置要讀幾個區塊(間接區塊加上資料區塊,inode 已經在記憶體裡)。超過最大檔案時回傳 None。區塊 4 KB、指標 4 位元組。
ext4 的 extent
一個 1 GB 的檔案用區塊對應(區塊 4 KB),要記 26 萬多個區塊編號。但如果這些區塊本來就是連續的,只要記「從第幾塊開始,連續幾塊」就夠了。這就是 extent。
依 kernel.org 的 ext4 文件,inode 內的 60 位元組放得下 4 個 extent,一個 extent 最多涵蓋 32768 個區塊(4 KB 區塊時是 128 MiB);放不下時長成一棵樹。代價是檔案越破碎,extent 越多,所以檔案系統配置時會盡量找連續的空間。
這和第 12 課的外部碎片、第 16 課硬碟怕移動是同一件事的不同面向。
硬連結與符號連結
因為名稱和 inode 是分開的,同一個 inode 可以有多個名稱:
硬連結(ln a b) |
符號連結(ln -s a b) |
|
|---|---|---|
| 本質 | 目錄多一筆,指向同一個 inode | 一個新的 inode,內容是一段路徑 |
| 連結數 | 目標的連結數 +1 | 目標不變 |
| 目標被刪除 | 不受影響,還有其他名稱 | 變成懸置連結,打不開 |
| 跨檔案系統 | 不行(inode 編號只在同一個檔案系統有意義) | 可以 |
| 指向目錄 | 一般不允許(會造成迴圈) | 可以 |
檔案什麼時候才真的消失?連結數歸零,而且沒有任何 fd 開著。 rm 呼叫的系統呼叫叫 unlink,名字就說明了:它刪的是名稱。
在真正的 Linux 上執行:
a.txt and b.txt same inode: yes
a.txt and s.txt same inode: no
a.txt links=2, s.txt links=1
after unlink a.txt, b.txt says: hello
open s.txt: failed (dangling)
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
寫一個區塊要改三個地方
在檔案後面加一個區塊,磁碟上至少要改三個地方:資料區塊本身、inode(大小、多一個指標)、點陣圖(這個區塊被使用了)。這三個位置不相鄰,不可能一次寫完。電源隨時可能斷,而且磁碟可能不照程式下的順序寫。
只寫完一部分會怎樣?
| 寫了什麼 | 後果 |
|---|---|
| 只有資料 | 沒有人指到它,等於沒寫。不一致沒有發生,只是新資料不見了 |
| 只有點陣圖 | 區塊被標成使用中,卻沒有人指到它:空間遺失 |
| 只有 inode | inode 指到一個標成空閒的區塊,之後可能分配給別的檔案,兩個檔案共用同一塊:嚴重不一致 |
| inode 與點陣圖 | metadata 全部一致,但區塊裡是之前別人留下的舊資料:檔案出現別人的內容 |
最後一種最麻煩,因為看起來完全正常。
fsck(file system check)是傳統的解法:開機時掃描所有 inode 和點陣圖,比對有沒有矛盾,再修正。它有兩個問題:磁碟越大掃得越久,而且它只看得出 metadata 之間的矛盾,看不出區塊裡的內容是不是這個檔案的。
日誌(journal)借用資料庫的想法:要改之前,先把「要改什麼」寫到磁碟上一塊專門的區域,寫完加一個 commit 記號,才開始改真正的位置。斷電後只要看日誌:
- 有 commit:照日誌重做一次(重播)。就算已經做過一部分,重做結果也一樣。
- 沒有 commit:這筆交易不算數,直接丟棄。真正的位置還沒被碰過。
不用掃描整個磁碟,也不會停在一半。
ext4 的三種模式
ext4 的掛載選項 data= 決定資料要受多少保護(kernel.org ext4 文件):
| 模式 | 什麼進日誌 | 資料的順序 | 斷電後可能出現 |
|---|---|---|---|
data=journal |
資料與 metadata | 先進日誌,再到最終位置 | metadata 與資料都一致 |
data=ordered(預設) |
只有 metadata | 相關的資料先寫完,metadata 才 commit | metadata 一致,不會指到還沒寫的區塊 |
data=writeback |
只有 metadata | 不保證 | metadata 一致,但最近寫入的檔案可能出現錯誤的內容 |
kernel.org 文件也寫明:data=journal 已被標示為棄用,預計 2028 年 1 月移除;它同時會關閉 delayed allocation 與 O_DIRECT。
日誌不是每次寫入都馬上 commit,預設每 5 秒一次(掛載選項 commit=)。所以沒有呼叫 fsync 的程式,斷電時最多可能損失好幾秒的資料,檔案系統仍然是一致的。日誌保護的是檔案系統自己的結構,不是你的資料有沒有寫完。
模擬器簡化了:一個交易只放一個動作,也沒有模擬 delayed allocation。實際上 ext4 會延後配置區塊,把很多動作併成一個交易。
write 與 fsync 的差別
| 呼叫 | 回傳時保證什麼 |
|---|---|
write |
資料進了頁快取,其他行程讀得到。不保證已經在磁碟上 |
fsync(fd) |
這個檔案的資料與 metadata 已送到裝置,裝置回報完成才回傳 |
fdatasync(fd) |
同上,但不是讀取資料必需的 metadata(例如修改時間)可以不寫 |
close |
不保證寫到磁碟 |
fsync 很慢,因為真的要等裝置。需要資料不遺失的程式(資料庫、郵件伺服器)才在關鍵時刻呼叫,不是每次寫入都呼叫。
還有一個常被忽略的細節:man 2 fsync 寫明,對檔案 fsync 不一定讓目錄裡的那筆名稱也寫到磁碟,要對目錄也 fsync。
所以要安全地更新一個設定檔,標準的作法是:
fd = open("config.tmp", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, data, len);
fsync(fd); // 新內容確實寫到磁碟
close(fd);
rename("config.tmp", "config"); // rename 不會讓別人看到寫一半的內容
// 再對所在的目錄 fsync,確保名稱的改動也寫到磁碟
斷電時你看到的要嘛是完整的舊檔,要嘛是完整的新檔。這個技巧把「改很多地方」變成「改一個名稱」,和日誌的 commit 是同一種思路:讓最後一步成為唯一的決定點。
確認一下
ln a.txt b.txt 之後,執行 rm a.txt,再執行 cat b.txt。會怎樣?
區塊 1 KB、指標 4 位元組、12 個直接指標。只用直接與一層間接,最大檔案是多少?
在沒有日誌的檔案系統上,先寫 inode、再寫點陣圖、最後寫資料。在寫完點陣圖之後斷電,fsck 會怎麼回報?
一支程式呼叫 write 成功之後沒有呼叫 fsync,10 毫秒後斷電。檔案系統是 ext4 預設模式。最可能的結果是?
為什麼日誌的 commit 區塊要在其他日誌區塊都寫完之後才寫?
程式要更新設定檔,避免斷電時留下寫一半的內容。下面哪個作法最安全?
重點整理
- inode 存 metadata 與內容的位置,沒有檔名;目錄是名稱到 inode 的對照表。檔案要連結數歸零而且沒有 fd 開著才會消失。
- 直接與間接指標讓小檔案快、大檔案也放得下;ext4 用 extent 記連續的範圍。
- 硬連結指向 inode,符號連結指向名稱。
- 一次寫入要改好幾個地方,斷電會留下不一致。fsck 掃描全部而且看不出內容對錯;日誌先寫下意圖,靠 commit 決定重播或丟棄。
- ext4 預設 ordered:只有 metadata 進日誌,但資料先寫完。日誌保護結構,要保護你的資料得自己呼叫 fsync。
下一課是最後一課:一台機器怎麼假裝成很多台。