文件系统实战从 inode 到 Page Cache——档案库的管理艺术本文是《趣谈 Linux 操作系统》风格的实战续篇与上篇《内存管理实战》配套。所有命令与输出均来自一台真实云服务器华为云 FlexusXUbuntu 24.04内核 6.8.0无一字虚构。建议边读边在自己的机器上复现。引子把文件系统想成一家公司档案库公司里有一座档案库每个进程员工都来这里存、取文件一份份档案。这座档案库是怎么运转的文件 一份档案比如一份合同。inode索引节点 这张档案的卡片卡片上有唯一编号inode 号、权限、大小、以及这份档案放哪几个货架的指针。注意卡片上并没有写文件名目录 一个档案柜柜门上贴的是名字 → 卡片编号的标签。所以目录本质上是张映射表不是档案本身。数据块block 仓库里一格格的货架档案内容就放在这。块设备 / 磁盘 整个仓库大楼。文件系统ext4/xfs… 仓库的管理规则卡片怎么排、货架怎么分、丢了怎么办。VFS虚拟文件系统 前台统一受理窗口。不管你存的是 ext4、tmpfs 还是 nfs前台都给你同一套存/取/列目录的界面。Page Cache页缓存 阅览室。热门档案被复印一份放阅览室下次直接看不用再去仓库爬货架。Journal日志 改动流水账。先记一笔我要改 A再真改崩溃了也能照账恢复不会留下半拉子档案。硬链接 同一份档案贴了两张不同的标签两个名字指向同一张卡片。软链接 一张转介条写着真正的档案在隔壁 B 柜。下面我们用一台真机把这座档案库从地基到屋顶拆开看。实验环境项目配置云服务器华为云 FlexusX ecs-44ec-0002系统Ubuntu 24.04 LTS内核6.8.0-106-genericCPU/内存8 核 / 16 GiB工具gcc 13.3.0、e2fsprogs(dumpe2fs/tune2fs)、sysstat为安全、可复现我们不碰真实磁盘而是用普通文件造一块虚拟磁盘回环设备loop device在它上面建 ext4随便折腾、随时删。实验一平地起一座档案库回环设备 ext4先造一个 100 MB 的普通文件当虚拟磁盘格式化成 ext4再挂载成目录用。mkdir-p/mnt/testfsddif/dev/zeroof/root/testfs.imgbs1Mcount100# 100MB 虚拟磁盘mkfs.ext4-F/root/testfs.img# 格式化为 ext4mount-oloop /root/testfs.img /mnt/testfs# 挂载echohello ext4/mnt/testfs/hello.txt# 试试能写真实输出# dd 创建虚拟磁盘 1000 records out 104857600 bytes (105 MB, 100 MiB) copied, 0.0632131 s, 1.7 GB/s # mkfs.ext4 格式化 mke2fs 1.47.0 (5-Feb-2023) Discarding device blocks: done Creating filesystem with 25600 4k blocks and 25600 inodes Allocating group tables: done Writing inode tables: done Creating journal (1024 blocks): done Writing superblocks and filesystem accounting information: done # mount 挂载 /root/testfs.img on /mnt/testfs type ext4 (rw,relatime) # 挂载后可写可读 hello ext4 # df 看到的是 /dev/loop0回环设备 Filesystem Size Used Avail Use% Mounted on /dev/loop0 90M 28K 83M 1% /mnt/testfs解读dd造出的testfs.img就是块设备的内容mount -o loop让内核把它当一块盘来用于是它有了设备名/dev/loop0。注意df显示大小是90M 而非 100M那 10 MB 被 ext4 留作管理开销inode 表、组描述符、日志等这也提醒我们——标称容量和可用容量永远有差距。文件系统挂载后对用户就是个普通目录/mnt/testfs你完全感知不到底下其实是块文件。这就是VFS 抽象的价值。接着看这张仓库地基图纸——超级块superblockdumpe2fs-h/root/testfs.img2/dev/null|grep-EInode count|Block count|Block size|Inode size|Inodes per group|Free inodes|Free blocks|Mount countInode count: 25600 Block count: 25600 Free blocks: 22954 Free inodes: 25589 Block size: 4096 Inode size: 256 Inodes per group: 25600 Mount count: 1一眼能读出块大小 4 KBx86 上几乎总是它一共 25600 个块、25600 个 inode还剩约 22954 个块、25589 个 inode 空闲。后面实验会反复回到这些数字。实验二档案卡片 inode 与卡片号耗尽上一节说 inode 是档案卡片文件名并不在 inode 里而在目录的名字→inode号映射中。我们用stat、ls -i、df -i三件套验证并演示一个经典故障小文件太多会把 inode 耗光哪怕磁盘还有空间。cd/mnt/testfsstathello.txt# 看 inode 号、链接数ls-i# 第一列就是 inode 号df-i/mnt/testfs# 看 inode 总量与已用# 批量造 1000 个小文件观察 inode 消耗mkdir-pmany(cdmanyforiin$(seq11000);dotouchf$i;done)df-i/mnt/testfsrm-rfmany# 清理真实输出# stat hello.txt File: hello.txt Size: 11 Blocks: 8 IO Block: 4096 regular file Device: 7,0 Inode: 12 Links: 1 Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root) # ls -i第一列 inode 号 12 hello.txt 11 lostfound # 建 1000 个文件前 Filesystem Inodes IUsed IFree IUse% Mounted on /dev/loop0 25600 12 25588 1% /mnt/testfs # 建 1000 个文件后many 目录本身占 1 个 inode Filesystem Inodes IUsed IFree IUse% Mounted on /dev/loop0 25600 1013 24587 4% /mnt/testfs # 清理后又回到 12 Filesystem Inodes IUsed IFree IUse% Mounted on /dev/loop0 25600 12 25588 1% /mnt/testfs解读stat告诉我们hello.txt的Inode: 12、Links: 1、占8 个 512 字节扇区即 4 KB一块。注意Size 11是文件逻辑大小Blocks 8才是占的物理块——小文件也有最小占用一块。ls -i第一列就是 inode 号lostfoundinode 11是每个 ext 文件系统格式化就自带的失物招领处。inode 耗尽可能1000 个空文件让IUsed从 12 涨到 1013每文件 1 个 inode 目录本身 1 个。如果这是个存海量小图片/日志的盘25600 个 inode 很快见底。届时df -h显示磁盘还空着但touch会报No space left on device——新手最容易踩的坑就是只盯磁盘空间忘了 inode。所以选型时要按文件数量 vs 大小权衡ext4 可在mkfs时用-i调 inode 密度。实验三两份标签 vs 一张转介条硬链接与软链接硬链接hard link给同一张 inode 卡片再贴一张名字标签。删掉任一名字只要还有标签在档案就还在。软链接symbolic link写一张转介条内容是被链接者的路径。原档案没了转介条就成了死胡同。cd/mnt/testfsechoorigin contentorig.txtlnorig.txt hard.txt# 硬链接ln-sorig.txt soft.txt# 软链接ls-li# 看 inode 号与链接数stat-cname%n inode%i links%horig.txt hard.txt soft.txtrmorig.txt# 删掉源文件echo--- 硬链接仍可访问 ---;cathard.txtecho--- 软链接已悬空 ---;catsoft.txt21||true真实输出# 建链接后 ls -lihard.txt 与 orig.txt 同 inode(13)soft.txt 是另一个 inode(14) 13 -rw-r--r-- 2 root root 15 Jul 29 13:10 hard.txt 12 -rw-r--r-- 1 root root 11 Jul 29 13:10 hello.txt 11 drwx------ 2 root root 16384 Jul 29 13:10 lostfound 13 -rw-r--r-- 2 root root 15 Jul 29 13:10 orig.txt 14 lrwxrwxrwx 1 root root 8 Jul 29 13:10 soft.txt - orig.txt # stat 三者硬链接共享 inode 13 且 links2软链接独立 inode 14 nameorig.txt inode13 links2 namehard.txt inode13 links2 namesoft.txt inode14 links1 # 删除 orig.txt 后 --- 硬链接仍可访问 --- origin content --- 软链接已悬空 --- cat: soft.txt: No such file or directory解读硬链接hard.txt与orig.txt的 inode 都是13链接数links2。rm orig.txt只是把一张标签撕了链接数降到 1档案inode 13还在所以hard.txt照常读到origin content。只有当链接数归零档案才真被删。软链接soft.txt是inode 14的一个独立小文件类型l内容就 8 字节路径orig.txt。源一删它指向的路径不存在cat报No such file or directory——这就是悬空链接。一个硬约束硬链接不能跨文件系统、不能链接目录防止形成环软链接都可以。日常我们用软链接更多因为它语义直观、不增加引用计数负担。实验四目录到底是什么链接数玄机很多人以为目录包含文件其实目录只是一张名字→inode的表。它本身的链接数有个反直觉的规律目录的链接数 2 它直接包含的子目录个数。cd/mnt/testfsmkdir-pdirAstat-cname%n inode%i links%hdirA# 空目录 linksmkdir-pdirA/subBstat-cdirA links%hdirA# 多一个子目录后ls-laidirA# 看 . 和 .. 的 inodemkdir-pdirA/subCstat-cdirA links%hdirA# 再一个子目录真实输出namedirA inode13 links2 # 空目录自己 自己的 . 2 dirA links3 # 多 subBsubB 的 .. 也指向 dirA # ls -lai dirA 13 drwxr-xr-x 3 root root 4096 . # dirA 自身 inode 13 2 drwxr-xr-x 4 root root 4096 .. # 父目录(. /mnt/testfs) inode 2 14 drwxr-xr-x 2 root root 4096 subB # subB 是独立 inode 14 dirA links4 # 再添 subC又多一个 .. 指向 dirA解读空目录dirA链接数是2一份是它自己在父目录里的标签一份是它内部.当前目录对自己 inode 的引用。每多一个子目录subBsubB里的..上级目录就成了一张指向dirA的硬链接于是dirA的链接数 12→3→4。注意普通文件不增加父目录的链接数文件的..不指向父目录而是文件自己的链接数。由此得到一个实用小技巧用find /some/dir -links 2 -type d可以找出没有任何子目录的目录叶子目录因为它们的链接数恰好是 2。实验五前台统一窗口 VFS虚拟文件系统Linux 同时挂着几十种文件系统根目录是 ext4内存里塞着 proc、sysfs、tmpfs网络盘可能是 nfs……但你在应用层用的全是同一套open/read/write/stat接口。这层统一抽象就是VFSVirtual File System。cat/proc/filesystems# 内核支持哪些文件系统类型mount|column-t|head# 当前实际挂载了哪些已列对齐真实输出节选# /proc/filesystems带 nodev 的是不对应真实设备的伪文件系统 nodev sysfs nodev tmpfs nodev proc nodev cgroup2 ext3 ext2 ext4 squashfs vfat btrfs nodev fuse # mount列对齐后 sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime) proc on /proc type proc (rw,nosuid,nodev,noexec,relatime) udev on /dev type devtmpfs (rw,nosuid,relatime,...) /dev/vda1 on / type ext4 (rw,relatime) tmpfs on /run type tmpfs (rw,nosuid,nodev,...) /dev/loop0 on /mnt/testfs type ext4 (rw,relatime) ← 我们的回环盘解读nodev前缀表示没有真实块设备如proc、sysfs、tmpfs——它们是内核用内存现造的虚拟档案库用来暴露内核信息/proc/cpuinfo或当临时盘/dev/shm。同一个mount列表里/ext4、/mnt/testfsext4、/runtmpfs、/procproc和平共处。VFS 在中间做翻译你调用read()VFS 根据文件所在的文件系统类型分派给 ext4 或 tmpfs 或 proc 各自的实现。应用毫不知情这正是面向接口编程在内核里的典范。实验六阅览室 Page Cache冷缓存 vs 热缓存前面反复提到Page Cache页缓存。它把读过/写过的数据页留在内存下次直接命中内存省去跑仓库磁盘的开销。关键实验清空缓存 → 冷读慢→ 热读快并用free -h看buff/cache增长。cd/mnt/testfsddif/dev/zeroofbigfilebs1Mcount8021|tail-1# 造 80MB 文件syncecho3/proc/sys/vm/drop_caches# 清空 Page Cache需 rootfree-h# 记 buff/cache 基线ddifbigfileof/dev/nullbs1M21|tail-1# 冷读free-h# buff/cache 应涨ddifbigfileof/dev/nullbs1M21|tail-1# 热读命中缓存rm-fbigfile真实输出83886080 bytes (84 MB, 80 MiB) copied, 0.084 s, 994 MB/s # 造文件 # 清空缓存后 total used free shared buff/cache available Mem: 14Gi 534Mi 14Gi 2.6Mi 121Mi 14Gi # 冷读第一次要从磁盘取 83886080 bytes (84 MB, 80 MiB) copied, 0.270915 s, 310 MB/s # 冷读后buff/cache 从 121Mi 涨到 282Mi文件内容被缓存 total used free shared buff/cache available Mem: 14Gi 580Mi 14Gi 2.6Mi 282Mi 14Gi # 热读第二次直接命中内存缓存 83886080 bytes (84 MB, 80 MiB) copied, 0.00837692 s, 10.0 GB/s解读冷读 310 MB/s热读 10.0 GB/s快了约 40 倍这就是 Page Cache 的威力第一次读必须把数据从仓库磁盘经回环设备再落回宿主磁盘搬进内存第二次数据已在内存的阅览室CPU 直接拿。free -h的buff/cache从 121 MiB 涨到 282 MiB差额 ≈ 文件大小 一些元数据正是 bigfile 被缓存的证据。工程启示数据库、KV 存储拼命优化缓存命中率本质就是在抢这块内存阅览室。也正因为缓存占用buff/cache所以看内存够不够看available而非free——cached 内存随时能被回收给应用。顺带一题为什么先sync再drop_cachessync把脏页落盘避免 drop 时把还没写完的数据弄丢echo 3同时清 page cache 与 dentry/inode cache。实验七文件 I/O 的三条路径write / fsync / O_DIRECT写文件至少有三条路持久性与速度天差地别write不 fsync数据进 Page Cache 就返回快但断电可能丢靠内核后台pdflush异步刷盘。writefsync强制把数据刷到磁盘才返回持久但慢。O_DIRECT绕过 Page Cache应用自己管理缓冲、直接 DMA 到磁盘最低延迟抖动但吞吐通常最低数据库常用。C 程序实测三种模式各写 64 MB/* 节选三种模式打开同一文件 */intflagsO_WRONLY|O_CREAT|O_TRUNC;if(moded)flags|O_DIRECT;// 绕过页缓存/* modew: 仅 write; modef: write 后 fsync(fd); moded: O_DIRECT 按 4K 对齐写 */./io_demo w# 仅写缓存./io_demo f# 写 fsync./io_demo d# O_DIRECT真实输出modew 写 64 MB 用时 0.060 s 吞吐 1118.5 MB/s # 仅写缓存 modef 写 64 MB 用时 0.122 s 吞吐 551.5 MB/s # 写 fsync落盘 moded 写 64 MB 用时 0.570 s 吞吐 117.7 MB/s # O_DIRECT绕过缓存解读仅write最快1118 MB/s只是把数据拷进内核页缓存CPU 不碰磁盘。代价是不保证持久——进程以为写完了实则还在内存里机器掉电就丢。writefsync慢一倍551 MB/s多花的时间就是等磁盘把数据真正落盘。代价换来了崩溃一致性fsync 返回后数据已在存储介质上。O_DIRECT最慢117 MB/s每次 4 KB 都要跳过缓存直写磁盘且要求内存/长度对齐吞吐天然低。但它绕开了缓存的拷贝与回收开销延迟更可控、不污染 Page Cache所以 MySQL、PostgreSQL 的写盘线程常开O_DIRECT自己管理缓冲。一句话要速度用缓存要安全用 fsync要可控用 O_DIRECT。数据库双写缓冲WAL 预写日志的本质就是在丢失几字节和每次都刷盘之间找平衡。实验八断电也不慌的账簿——ext4 日志journal如果写文件到一半机器断电磁盘可能留下半新半旧的元数据比如文件大小改了、数据块却没分配下次挂载就乱套。ext4 的解法是journal日志/预写式日志先把我打算改 X记进一块专用区域改完再记改完了崩溃恢复时要么重放、要么丢弃绝不留下半截状态。dumpe2fs-h/root/testfs.img2/dev/null|grep-ijournal tune2fs-l/root/testfs.img2/dev/null|grep-ifilesystem features真实输出Filesystem features: has_journal ext_attr resize_inode dir_index filetype needs_recovery extent 64bit flex_bg ... Journal inode: 8 Journal features: journal_64bit journal_checksum_v3 Total journal size: 4096k # 日志区 4 MB1024 个块 Total journal blocks: 1024 Journal sequence: 0x00000002 Journal checksum type: crc32c # 用 crc32c 给日志做校验解读特性位里明确有has_journal说明本文件系统启用了日志ext4 默认开启模式ordered元数据必落日志数据按序落盘。日志本身 resides 在inode 8ext 系列约定俗成的日志专用 inode大小4 MB1024 个 4 KB 块——对一个 100 MB 的小盘来说绰绰有余。journal_checksum_v3crc32c每块日志都有校验和防止日志本身被静默损坏导致错误重放。回想实验一mkfs时那行Creating journal (1024 blocks): done——就是在这里挖好了这块保险账簿。关于日志模式三个档位常被问到writeback数据可后落最快最不安全、ordered默认数据先于元数据提交较平衡、datajournal数据与元数据都进日志最慢最稳。生产上多是ordered。原理图图1一次read(/mnt/testfs/hello.txt)的旅程命中未命中进程调用 readVFS 统一接口文件在 Page Cache?直接从内存返回, 极快ext4 读 inode/地址映射定位磁盘块块设备层读磁盘数据填入 Page Cache图2目录、inode、数据块的关系名字不在 inode 上目录 /mnt/testfs (一张映射表) hello.txt ──► inode 12 ──► [数据块][数据块][数据块] dirA ──► inode 13 ──► (目录数据: 子项名→inode) lostfound ──► inode 11 硬链接hard.txt ──┐ ├──► 同一个 inode 13 (Links2, 删一个名字仍在) orig.txt(已删) ──┘ soft.txt(inode14) ──转介条──► 路径 orig.txt(已不存在→悬空)图3写文件的三种归宿write() ─────────────► Page Cache ──(pdflush 异步)──► 磁盘 [快, 可能丢] write()fsync() ─────► Page Cache ──(fsync 同步刷)──► 磁盘 [慢, 持久] O_DIRECT write() ─────► (绕过缓存) 直写 ──────────────► 磁盘 [最慢, 可控]总结机制档案库类比本篇实证要点inode档案卡片编号/权限/位置stat看 inode 号文件名在目录不在 inode目录名字→inode 的柜门标签目录链接数 2 子目录数硬/软链接两张标签 / 一张转介条硬链接同 inode、删源仍在软链接独立 inode、删源悬空VFS统一受理前台/proc/filesystems列种类mount列实例Page Cache阅览室冷读 310MB/s vs 热读 10GB/s约 40×buff/cache 随之涨I/O 路径入库三通道write 1118 / fsync 551 / O_DIRECT 117 MB/sext4 journal改动保险账簿inode 8、4MB、crc32c 校验、默认 ordered 模式把文件 inode 数据块名字在目录里这个核心模型记牢再叠加 VFS 抽象、Page Cache 加速、journal 保命三件套你就能解释日常 90% 的文件系统现象为什么删了大文件df不释放可能有硬链接/被进程占用、为什么小文件多会No space、为什么数据库要自己管缓存……思考题实验二里df -h还有空间却touch报 “No space left on device”你第一反应会排查什么命令硬链接不能跨文件系统本质是为什么提示inode 号只在同一个文件系统内唯一Page Cache 让热读比冷读快约 40 倍。那为什么云数据库仍常开O_DIRECT自己管缓存而不白嫖内核缓存ext4 默认日志模式ordered如果改成datajournal对崩溃恢复和写入性能分别有什么影响你rm了一个正在被某进程以O_DIRECT持续写入的大文件磁盘空间会立刻释放吗为什么提示文件描述符 / inode 引用实验环境华为云 FlexusX ecs-44ec-0002Ubuntu 24.04 LTS内核 6.8.0-106-generic8 核 16 GiB。所有输出均来自该机器的真实执行实验结束后已umount /mnt/testfs并删除测试镜像未对宿主机造成残留。