名字与数据是两回事
「文件名」这个词误导了整整几代人。在 Unix 文件系统里,文件根本没有名字——名字是目录里的一条记录,指向真正的文件本体。理解这一点,一大堆奇怪现象会同时变得显然。
目录不是文件夹
图形界面用「文件夹」这个比喻教了我们几十年:文件装在文件夹里。
这个比喻是错的。目录就是一个文件,它的内容是一张两列的表:
目录 /home/me 的内容: ┌──────────────┬────────────┐ │ 名字 │ inode 号 │ ├──────────────┼────────────┤ │ . │ 1024 │ ← 自己 │ .. │ 512 │ ← 上级目录 │ notes.txt │ 3391 │ │ photo.jpg │ 3392 │ └──────────────┴────────────┘
就这些。目录里没有文件内容,没有文件大小,甚至没有文件的修改时间——那些全在 inode 里。
而 inode(index node)才是文件的本体:
| inode 里有 | inode 里没有 |
|---|---|
| 文件大小 | ★ 文件名 |
| 权限、属主、属组 | 它在哪个目录里 |
| 三个时间戳(atime / mtime / ctime) | |
| 数据块的位置 | |
| ★ 硬链接计数 nlink |
- 一个文件可以有多个名字——多条目录项指向同一个 inode。它们地位完全平等,没有哪个是「原件」。
- 文件可以一个名字都没有——还活着,还占着磁盘,只是没人叫得出它。(这是本章的重点)
- 改名不动数据——只改目录里那一行的第一列。
- 权限属于文件,不属于名字——所以你没法给同一个文件的两个硬链接设不同权限。
下面这台是一台真的文件系统:目录项和 inode 分开存,nlink 和打开计数分别记账,释放条件是两者同时归零。
默认打开的就是本章最重要的那个场景。四个场景都跑一遍。
rm 的系统调用叫 unlink
这个命名不是随意的,它精确地描述了发生的事:
- 把目录里那条记录删掉。
- 把 inode 的
nlink减 1。 - 如果
nlink归零而且没有进程打开着它,才释放数据块。
它不干的事:碰你的数据。「删除文件」这个说法本身就是误导——你删的是名字,数据只是在最后一个名字消失、且最后一个使用者放手之后,被顺便回收了。
那个让运维困惑的经典场景
现在可以解释本章标题下那个问题了:
$ df -h /var Filesystem Size Used Avail Use% Mounted on /dev/sda2 100G 98G 0.5G 99% /var $ du -sh /var/log/app.log 2.0G /var/log/app.log $ rm /var/log/app.log # 删掉它 $ df -h /var /dev/sda2 100G 98G 0.5G 99% /var # ← 空间一点没变!
为什么?因为那个进程还开着这个文件。
此刻的状态是:
nlink = 0——目录里没有它了,ls看不见,du统计不到;- 打开计数 = 1——那个还在跑的进程握着 fd;
- 于是数据块不释放,2 GB 磁盘继续被占着。
而且更糟:那个进程还在往里写。文件在长大,你却看不见它、也删不掉它——因为它已经没有名字了。du 会告诉你磁盘上什么都没有,df 会告诉你满了。这两个命令都是对的。
怎么找到这些「幽灵文件」:
$ lsof +L1 # 列出所有 nlink < 1 但仍被打开的文件
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
java 4242 app 3w REG 253,2 2147483648 0 1573 /var/log/app.log (deleted)
↑
nlink = 0
三种解法,各有代价:
| 做法 | 效果 | 代价 |
|---|---|---|
| 重启那个进程 | 立刻释放 | 服务中断 |
: > /proc/PID/fd/3 | 通过 /proc 里那个 fd 把文件截断到 0 | 进程可能还按旧偏移写,留一堆空洞 |
★ 一开始就别 rm,用 truncate -s 0 | 清空但保留 inode 和名字 | 需要提前想到 |
最后一条才是正解,也是 logrotate 的 copytruncate 模式的原理。而更好的做法是让日志程序支持 SIGHUP 重开文件——这就是 logrotate 默认模式做的事:先改名,再发信号让进程重新 open。
「删了还能用」听起来像 bug,但它是 Unix 一个非常重要的保证:
你手上的 fd 永远有效,不管别人对文件名做了什么。
这让一些事变得可能:
- 自动清理的临时文件:
open之后立刻unlink。文件立刻从目录里消失(谁也看不见、偷不走),但你继续用。进程一退出,fd 关闭,空间自动回收——哪怕进程是被 kill -9 杀的。没有任何清理代码能做到这么可靠。 - 原子替换:写一个新文件,然后
rename()覆盖旧的。已经打开旧文件的进程继续读旧内容,不会读到写了一半的东西。这就是为什么软件更新时正在运行的程序不会崩溃。
第二条尤其重要。「先写临时文件,再 rename」是所有需要原子性写文件的场景的标准做法——因为 rename 在同一文件系统内是原子的,而直接覆写不是。
硬链接与软链接
这两个东西经常被并列讲,但它们完全不是一个层次的东西:
硬链接 ln a b | 软链接 ln -s a b | |
|---|---|---|
| 是什么 | 目录里又一条指向同一 inode 的记录 | 一个新的 inode,内容是一个路径字符串 |
| 占空间 | 几乎不占(一条目录项) | 一个 inode |
| 删掉目标 | 没影响——它就是目标之一 | 悬空,指向不存在的路径 |
| 能跨文件系统吗 | 不能(inode 号只在本文件系统内有意义) | 能(它存的是路径字符串) |
| 能指向目录吗 | 不能(会造成目录环,遍历死循环) | 能 |
| 哪个是「原件」 | ★ 没有原件,完全平等 | 目标是原件,链接是附属 |
关键区别一句话:硬链接是「另一个名字」,软链接是「一张写着路径的纸条」。
所以硬链接不可能悬空——nlink 记着账,只要还有一个硬链接,数据就在。而软链接对目标一无所知,目标没了它也不知道。
如果你觉得「名字和数据分开」这个设计眼熟,那是因为 Git 把它推到了极致。
Git 里,文件内容存成 blob 对象,用内容的 SHA 哈希做「inode 号」;tree 对象就是目录——一张「名字 → 对象哈希」的表。完全同构。
区别在于 Git 用内容哈希而不是分配的编号,于是白拿了两个好处:内容相同的文件自动去重(同一个 blob),以及任何修改都会改变哈希、从而改变所有上层 tree 的哈希——这就是 Git 能廉价地判断「有没有变」的原因。
「把标识与内容分开」这个想法,四十年前解决了文件系统的问题,四十年后又解决了版本控制的问题。好的抽象会反复被重新发现。
ext4 在格式化时就固定了 inode 的数量。如果你有海量小文件,可能出现这种情况:
$ df -h
/dev/sda1 100G 30G 70G 30% / # 空间还有 70%
$ df -i
/dev/sda1 6.5M 6.5M 0 100% / # ★ inode 用光了
$ touch newfile
touch: cannot touch 'newfile': No space left on device
↑ 报的是「没空间」,但空间明明还有
报错信息会把你带偏——它说 No space left,但 df -h 显示空间充足。遇到「明明有空间却写不进去」,第一件事是 df -i。
常见元凶是缓存目录、邮件队列、或者某个程序疯狂生成小文件。XFS 和 Btrfs 用动态 inode 分配,没有这个问题。
「为什么 Android 的原子写文件要这么写?」
直接覆写一个文件是不原子的——写到一半掉电或进程被杀(第 15 章说过,随时可能),你会得到一个损坏的文件,而旧数据已经没了。
正确的做法用到了这一章的两个性质:
// AtomicFile 的原理 val tmp = File(dir, "settings.xml.tmp") tmp.writeText(newContent) // 1. 写到临时文件 // 2. fsync —— 确保数据真的落盘(第 18 章) tmp.renameTo(File(dir, "settings.xml")) // 3. 原子替换
rename 在同一文件系统内是原子的:要么旧的,要么新的,绝不会出现「写了一半」的中间状态。而且此刻正在读旧文件的进程继续读到完整的旧内容(因为它握着 fd,第 16 章)。
Android 的 AtomicFile、SharedPreferences 的持久化、Room 的 WAL、以及几乎所有数据库的写入路径,用的都是这套组合。第 2 步的 fsync 是最容易被漏掉的一步——少了它,rename 可能先于数据落盘,掉电后你会得到一个名字对但内容是空的文件。下一章讲这个。
这一章的一句话
目录只是一张「名字 → inode 号」的表,文件本体(inode)里根本没有名字——所以文件可以有多个名字、也可以一个都没有。rm 的系统调用叫 unlink,它解除的是名字和数据的链接;只有当 nlink 和打开计数同时归零,空间才真的释放。这解释了「删了日志磁盘没变空」,也支撑了「先写临时文件再 rename」这个原子写的基石。
下一章:数据从 read() 到磁盘要经过哪几层?我们会看到同一行代码,命中缓存和不命中差了上千倍——以及 fsync 那一毫秒到底买了什么。