第 8 章 · 文件系统

从一个我当助教时被问住的谜题开始:一台服务器磁盘明明显示还有空间(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(文件元数据的编号名额)被海量小文件塞满了,一个文件都建不出来。这个谜题的钥匙,正是本章的主角:文件系统怎么用「名字」和「数据」这两套账本管理磁盘。顺带把目录树、硬链接、日志、页缓存一次讲透,这章的内容几乎每天都在运维和开发中被用到。

8.1 文件、目录与 inode:名字和数据是两本账

文件系统把磁盘抽象成目录树,但底层账本分两本:目录是一本「名字到编号」的映射表,inode 才是文件本体的档案(类型、权限、大小、时间戳、数据块指针)。名字和数据在账本上彻底分家,这一刀切出好几个你天天用却未必想过的行为:

  • 硬链接:ln a.txt b.txt 不复制数据,只是在目录表里加一行「b.txt 也指向 a.txt 的 inode」,同时 inode 里的链接数加一。两个名字完全平等,删掉一个,数据还在(链接数还剩 1);全删光,数据块才真正释放。
  • 重命名的真实含义:只是改目录表里的一行字,数据一个字节都不动,所以 mv 跨目录 rename 快如闪电。
  • 软链接(符号链接)是另一种东西:一个独立的小文件,内容是「另一个路径的字符串」,像快捷方式。原文件删除,软链接就成了悬空的死链(硬链接没有这个问题,因为它直接指着 inode)。

亲手验证一遍,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

8.2 文件的数据块怎么摆:三种分配法

inode 里最重要的是数据块指针。怎么用指针组织一个大文件,三种经典方案,优缺点恰好是第 6 章内存连续分配问题的翻版:

连续分配:文件占连续的磁盘块。顺序读写飞快(磁盘转一圈全读完),随机访问也只需一次寻道。缺点眼熟:文件要长大,后面可能被别人占了,只能整体搬家;来回创建删除,外部碎片满地。

链接分配:每块末尾放下一块的指针,文件散着放也能串起来。无外部碎片、随取随长,但读第 n 块要跳 n 次,随机访问慢到没法用;指针坏了整条链断掉。FAT 文件系统的改良:把所有指针抽出来集中放一张 FAT 表(内存可缓存),链还在但查找变快了,代价是表自身占空间。

索引分配:inode 里放一个索引块,集中记录所有数据块号,随机访问一次到位。现代主流。难点是大文件索引不够用,解法是多级索引(和页表的多级化一模一样!):ext2/3 用「12 个直接指针 + 1 个一级间接 + 1 个二级间接 + 1 个三级间接」,小文件一次查找、大文件逐级扩展。

现代 ext4 把固定大小的索引块换成了 extent(区段):记「从第 X 块起连续 N 块」,一个 128 字节的 extent 能描述 128MB 连续空间,超大文件才需要 extent 树。为什么现代文件系统这么在意连续?因为磁盘(尤其机械盘)顺序访问和随机访问差百倍,「尽量连续地摆文件」是文件系统对物理世界的最大妥协。

8.3 日志:崩溃一致性

文件系统最难的考题:一次写文件涉及多处改动(数据块、inode、位图),写到一半断电,磁盘上的状态是半成品,重启后文件系统要么自相矛盾要么丢数据。这个「崩溃一致性」问题,几十年的答案是日志(journaling):先在日志区把「我打算做这些改动」完整写一遍,落盘后再真正执行;崩溃后重放日志,要么补完要么回滚。

理解日志最直观的素材是 ext4 的三种模式:

  • journal:数据和元数据都记日志,最安全最慢;
  • ordered(默认):只记元数据,但保证数据先于元数据落盘;
  • writeback:只记元数据,数据自便,最快,崩溃后可能看到旧数据配上新元数据。

数据库的事务日志(数据库课第 8 章)、Redis 的 AOF、Raft 的复制日志,全是同一个思想的各自变体:先把意图顺序地写下来,再执行。顺序写快、可重放,这一条在存储领域通用到近乎公理。

8.4 页缓存:free 命令里那块「被占用」的内存

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 强制丢缓存(仅实验用),两个命令能帮你亲眼确认缓存的存在和效果。

常见错误

  • 删了文件磁盘没腾出来。文件被进程打开着,rm 只是从目录账本划掉名字,inode 和数据块还被进程的文件描述符攥着,df 不减。运维经典事故:删了 50GB 的日志,空间没释放,最后发现是某个服务还攥着文件句柄。解法:重启服务,或 lsof | grep deleted 找到后truncate。
  • 混淆硬链接和复制。ln 零拷贝,cp 全拷贝;对大文件用错,时间差几个数量级。
  • 在 U 盘(FAT32)上放 4GB 以上的文件。FAT32 单文件上限 4GB,这是分配方式和元数据格式决定的硬伤,换 exFAT 或 ext4。

练习

  1. 动手:创建文件,ln 一个硬链接一个软链接,分别 stat 三者;删掉原文件再看硬链接和软链接的状态,亲手验证 8.1 节的所有结论。
  2. 手算:ext2 的 inode 指针区,12 直接 + 1 间接 + 2 二级间接 + 1 三级间接,块 4KB、每指针 4 字节,算最大文件大小(答案 16GB 出头),再想想 ext4 换 extent 的动机。
  3. 动手:dd if=/dev/zero of=big bs=1M count=2000 造一个 2GB 文件,free -h 看缓存涨;sync 后再读它一遍并计时,对比第一次读的速度,量化页缓存的效果。
  4. 思考:日志能保证「文件系统结构」不烂,但你的数据库写入还是可能丢最后几条。从「日志写完到数据落盘」之间的窗口想想为什么,这和数据库 WAL 又是什么关系?