卷 IV · 万物CH 17深度 17/24

名字与数据是两回事

「文件名」这个词误导了整整几代人。在 Unix 文件系统里,文件根本没有名字——名字是目录里的一条记录,指向真正的文件本体。理解这一点,一大堆奇怪现象会同时变得显然。

inode目录项硬链接nlink

目录不是文件夹

图形界面用「文件夹」这个比喻教了我们几十年:文件装在文件夹里。

这个比喻是错的。目录就是一个文件,它的内容是一张两列的表:

目录 /home/me 的内容:
┌──────────────┬────────────┐
│ 名字          │ inode 号    │
├──────────────┼────────────┤
│ .            │  1024      │  ← 自己
│ ..           │   512      │  ← 上级目录
│ notes.txt    │  3391      │
│ photo.jpg    │  3392      │
└──────────────┴────────────┘

就这些。目录里没有文件内容,没有文件大小,甚至没有文件的修改时间——那些全在 inode 里。

inode(index node)才是文件的本体:

inode 里有inode 里没有
文件大小文件名
权限、属主、属组它在哪个目录里
三个时间戳(atime / mtime / ctime)
数据块的位置
硬链接计数 nlink
◆ 于是几件事同时成立
  • 一个文件可以有多个名字——多条目录项指向同一个 inode。它们地位完全平等,没有哪个是「原件」。
  • 文件可以一个名字都没有——还活着,还占着磁盘,只是没人叫得出它。(这是本章的重点)
  • 改名不动数据——只改目录里那一行的第一列。
  • 权限属于文件,不属于名字——所以你没法给同一个文件的两个硬链接设不同权限。
▶ 动手 · inode 文件系统

下面这台是一台真的文件系统:目录项和 inode 分开存,nlink 和打开计数分别记账,释放条件是两者同时归零。

默认打开的就是本章最重要的那个场景。四个场景都跑一遍。

rm 的系统调用叫 unlink

这个命名不是随意的,它精确地描述了发生的事:

◆ rm 干的三件事(以及不干的一件)
  1. 把目录里那条记录删掉
  2. 把 inode 的 nlink 减 1。
  3. 如果 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 和名字需要提前想到

最后一条才是正解,也是 logrotatecopytruncate 模式的原理。而更好的做法是让日志程序支持 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 就是一个内容寻址的 inode 系统

如果你觉得「名字和数据分开」这个设计眼熟,那是因为 Git 把它推到了极致。

Git 里,文件内容存成 blob 对象,用内容的 SHA 哈希做「inode 号」;tree 对象就是目录——一张「名字 → 对象哈希」的表。完全同构。

区别在于 Git 用内容哈希而不是分配的编号,于是白拿了两个好处:内容相同的文件自动去重(同一个 blob),以及任何修改都会改变哈希、从而改变所有上层 tree 的哈希——这就是 Git 能廉价地判断「有没有变」的原因。

「把标识与内容分开」这个想法,四十年前解决了文件系统的问题,四十年后又解决了版本控制的问题。好的抽象会反复被重新发现。

⚠ inode 会用完(而磁盘还很空)

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 那一毫秒到底买了什么。