从一个我当助教时被问住的谜题开始:一台服务器磁盘明明显示还有空间(df -h 里的 Use% 才 70%),新建文件却报 No space left on device。折腾半天换了 df -i 才看见真相:
$ df -h /
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 100G 70G 30G 70% /
$ df -i /
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sda1 6.3M 6.3M 0 100% ← inode 耗尽!
空间还有 30GB,但 inode(文件元数据的编号名额)被海量小文件塞满了,一个文件都建不出来。这个谜题的钥匙,正是本章的主角:文件系统怎么用「名字」和「数据」这两套账本管理磁盘。顺带把目录树、硬链接、日志、页缓存一次讲透,这章的内容几乎每天都在运维和开发中被用到。
文件系统把磁盘抽象成目录树,但底层账本分两本:目录是一本「名字到编号」的映射表,inode 才是文件本体的档案(类型、权限、大小、时间戳、数据块指针)。名字和数据在账本上彻底分家,这一刀切出好几个你天天用却未必想过的行为:
ln a.txt b.txt 不复制数据,只是在目录表里加一行「b.txt 也指向 a.txt 的 inode」,同时 inode 里的链接数加一。两个名字完全平等,删掉一个,数据还在(链接数还剩 1);全删光,数据块才真正释放。mv 跨目录 rename 快如闪电。亲手验证一遍,stat 的输出把两本账全摊开:
$ touch a.txt && ln a.txt b.txt
$ ls -li
123456 -rw-r--r-- 2 user user 0 Oct 24 10:00 a.txt
123456 -rw-r--r-- 2 user user 0 Oct 24 10:00 b.txt
↑ 同一个 inode 号 ↑ 链接数 2
inode 里最重要的是数据块指针。怎么用指针组织一个大文件,三种经典方案,优缺点恰好是第 6 章内存连续分配问题的翻版:
连续分配:文件占连续的磁盘块。顺序读写飞快(磁盘转一圈全读完),随机访问也只需一次寻道。缺点眼熟:文件要长大,后面可能被别人占了,只能整体搬家;来回创建删除,外部碎片满地。
链接分配:每块末尾放下一块的指针,文件散着放也能串起来。无外部碎片、随取随长,但读第 n 块要跳 n 次,随机访问慢到没法用;指针坏了整条链断掉。FAT 文件系统的改良:把所有指针抽出来集中放一张 FAT 表(内存可缓存),链还在但查找变快了,代价是表自身占空间。
索引分配:inode 里放一个索引块,集中记录所有数据块号,随机访问一次到位。现代主流。难点是大文件索引不够用,解法是多级索引(和页表的多级化一模一样!):ext2/3 用「12 个直接指针 + 1 个一级间接 + 1 个二级间接 + 1 个三级间接」,小文件一次查找、大文件逐级扩展。
现代 ext4 把固定大小的索引块换成了 extent(区段):记「从第 X 块起连续 N 块」,一个 128 字节的 extent 能描述 128MB 连续空间,超大文件才需要 extent 树。为什么现代文件系统这么在意连续?因为磁盘(尤其机械盘)顺序访问和随机访问差百倍,「尽量连续地摆文件」是文件系统对物理世界的最大妥协。
文件系统最难的考题:一次写文件涉及多处改动(数据块、inode、位图),写到一半断电,磁盘上的状态是半成品,重启后文件系统要么自相矛盾要么丢数据。这个「崩溃一致性」问题,几十年的答案是日志(journaling):先在日志区把「我打算做这些改动」完整写一遍,落盘后再真正执行;崩溃后重放日志,要么补完要么回滚。
理解日志最直观的素材是 ext4 的三种模式:
journal:数据和元数据都记日志,最安全最慢;ordered(默认):只记元数据,但保证数据先于元数据落盘;writeback:只记元数据,数据自便,最快,崩溃后可能看到旧数据配上新元数据。数据库的事务日志(数据库课第 8 章)、Redis 的 AOF、Raft 的复制日志,全是同一个思想的各自变体:先把意图顺序地写下来,再执行。顺序写快、可重放,这一条在存储领域通用到近乎公理。
Linux 读文件,数据先进内存的页缓存(page cache),同一文件再读直接命中内存;写文件也是先写缓存,由内核异步刷盘(write-back)。于是 free 的输出里 Used 看起来常年爆表,其中一大半是缓存:
$ free -h
total used free available
Mem: 15Gi 14Gi 300Mi 12Gi ← available 才是真实余量
缓存用的内存随时可回收(它是「借花献佛」,不占白不占),所以看余量要看 available 列。这个设计和第 7 章的页替换一脉相承:文件缓存页就是页缓存里的普通页,LRU 近似算法同样在管它们。执行 sync 强制刷盘,echo 3 > /proc/sys/vm/drop_caches 强制丢缓存(仅实验用),两个命令能帮你亲眼确认缓存的存在和效果。
df 不减。运维经典事故:删了 50GB 的日志,空间没释放,最后发现是某个服务还攥着文件句柄。解法:重启服务,或 lsof | grep deleted 找到后truncate。ln 零拷贝,cp 全拷贝;对大文件用错,时间差几个数量级。ln 一个硬链接一个软链接,分别 stat 三者;删掉原文件再看硬链接和软链接的状态,亲手验证 8.1 节的所有结论。dd if=/dev/zero of=big bs=1M count=2000 造一个 2GB 文件,free -h 看缓存涨;sync 后再读它一遍并计时,对比第一次读的速度,量化页缓存的效果。