文件系统实战:从 inode 到 Page Cache——档案库的管理艺术
本文是《趣谈 Linux 操作系统》风格的实战续篇,与上篇《内存管理实战》配套。所有命令与输出均来自一台真实云服务器(华为云 FlexusX,Ubuntu 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-generic |
| CPU/内存 | 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.imgbs=1Mcount=100# 100MB 虚拟磁盘mkfs.ext4-F/root/testfs.img# 格式化为 ext4mount-oloop /root/testfs.img /mnt/testfs# 挂载echo'hello ext4'>/mnt/testfs/hello.txt# 试试能写真实输出:
# dd 创建虚拟磁盘 100+0 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 抽象的价值。
接着看这张"仓库地基图纸"——超级块(superblock):
dumpe2fs-h/root/testfs.img2>/dev/null|grep-E'Inode count|Block count|Block size|Inode size|Inodes per group|Free inodes|Free blocks|Mount count'Inode 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 KB(x86 上几乎总是它),一共 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&&(cdmany&&foriin$(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 lost+found # 建 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 号,lost+found(inode 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/testfsecho'origin content'>orig.txtlnorig.txt hard.txt# 硬链接ln-sorig.txt soft.txt# 软链接ls-li# 看 inode 号与链接数stat-c'name=%n inode=%i links=%h'orig.txt hard.txt soft.txtrmorig.txt# 删掉"源文件"echo'--- 硬链接仍可访问 ---';cathard.txtecho'--- 软链接已悬空 ---';catsoft.txt2>&1||true真实输出:
# 建链接后 ls -li:hard.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 lost+found 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 且 links=2;软链接独立 inode 14 name=orig.txt inode=13 links=2 name=hard.txt inode=13 links=2 name=soft.txt inode=14 links=1 # 删除 orig.txt 后 --- 硬链接仍可访问 --- origin content --- 软链接已悬空 --- cat: soft.txt: No such file or directory解读
- 硬链接:
hard.txt与orig.txt的 inode 都是13,链接数links=2。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-c'name=%n inode=%i links=%h'dirA# 空目录 links=?mkdir-pdirA/subBstat-c'dirA links=%h'dirA# 多一个子目录后ls-laidirA# 看 . 和 .. 的 inodemkdir-pdirA/subCstat-c'dirA links=%h'dirA# 再一个子目录真实输出:
name=dirA inode=13 links=2 # 空目录:自己 + 自己的 "." = 2 dirA links=3 # 多 subB:subB 的 ".." 也指向 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 links=4 # 再添 subC:又多一个 ".." 指向 dirA解读
- 空目录
dirA链接数是2:一份是它自己在父目录里的标签,一份是它内部.(当前目录)对自己 inode 的引用。 - 每多一个子目录
subB,subB里的..(上级目录)就成了一张指向dirA的硬链接,于是dirA的链接数 +1(2→3→4)。 - 注意:普通文件不增加父目录的链接数(文件的
..不指向父目录,而是文件自己的链接数)。
由此得到一个实用小技巧:用
find /some/dir -links 2 -type d可以找出"没有任何子目录的目录"(叶子目录),因为它们的链接数恰好是 2。
实验五:前台统一窗口 VFS(虚拟文件系统)
Linux 同时挂着几十种文件系统:根目录是 ext4,内存里塞着 proc、sysfs、tmpfs,网络盘可能是 nfs……但你在应用层用的全是同一套open/read/write/stat接口。这层统一抽象就是VFS(Virtual 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/testfs(ext4)、/run(tmpfs)、/proc(proc)和平共处。VFS 在中间做翻译:你调用read(),VFS 根据文件所在的"文件系统类型"分派给 ext4 或 tmpfs 或 proc 各自的实现。应用毫不知情,这正是"面向接口编程"在内核里的典范。
实验六:阅览室 Page Cache(冷缓存 vs 热缓存)
前面反复提到"Page Cache(页缓存)"。它把读过/写过的数据页留在内存,下次直接命中内存,省去跑仓库(磁盘)的开销。关键实验:清空缓存 → 冷读(慢)→ 热读(快),并用free -h看buff/cache增长。
cd/mnt/testfsddif=/dev/zeroof=bigfilebs=1Mcount=802>&1|tail-1# 造 80MB 文件syncecho3>/proc/sys/vm/drop_caches# 清空 Page Cache(需 root)free-h# 记 buff/cache 基线ddif=bigfileof=/dev/nullbs=1M2>&1|tail-1# 冷读free-h# buff/cache 应涨ddif=bigfileof=/dev/nullbs=1M2>&1|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_caches?sync把脏页落盘,避免 drop 时把还没写完的数据弄丢;echo 3同时清 page cache 与 dentry/inode cache。
实验七:文件 I/O 的三条路径(write / fsync / O_DIRECT)
写文件至少有三条路,持久性与速度天差地别:
write不 fsync:数据进 Page Cache 就返回,快,但断电可能丢(靠内核后台pdflush异步刷盘)。write+fsync:强制把数据刷到磁盘才返回,持久但慢。O_DIRECT:绕过 Page Cache,应用自己管理缓冲、直接 DMA 到磁盘,最低延迟抖动但吞吐通常最低,数据库常用。
C 程序实测三种模式各写 64 MB:
/* 节选:三种模式打开同一文件 */intflags=O_WRONLY|O_CREAT|O_TRUNC;if(mode=='d')flags|=O_DIRECT;// 绕过页缓存/* mode=w: 仅 write; mode=f: write 后 fsync(fd); mode=d: O_DIRECT 按 4K 对齐写 */./io_demo w# 仅写缓存./io_demo f# 写 + fsync./io_demo d# O_DIRECT真实输出:
mode=w 写 64 MB 用时 0.060 s 吞吐 1118.5 MB/s # 仅写缓存 mode=f 写 64 MB 用时 0.122 s 吞吐 551.5 MB/s # 写 + fsync(落盘) mode=d 写 64 MB 用时 0.570 s 吞吐 117.7 MB/s # O_DIRECT(绕过缓存)解读
- 仅
write最快(1118 MB/s):只是把数据拷进内核页缓存,CPU 不碰磁盘。代价是不保证持久——进程以为写完了,实则还在内存里,机器掉电就丢。 write+fsync慢一倍(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-i'filesystem 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 MB(1024 个块) Total journal blocks: 1024 Journal sequence: 0x00000002 Journal checksum type: crc32c # 用 crc32c 给日志做校验解读
- 特性位里明确有
has_journal,说明本文件系统启用了日志(ext4 默认开启,模式ordered:元数据必落日志,数据按序落盘)。 - 日志本身 resides 在inode 8(ext 系列约定俗成的"日志专用 inode"),大小4 MB(1024 个 4 KB 块)——对一个 100 MB 的小盘来说绰绰有余。
journal_checksum_v3+crc32c:每块日志都有校验和,防止日志本身被静默损坏导致错误重放。- 回想实验一
mkfs时那行Creating journal (1024 blocks): done——就是在这里挖好了这块"保险账簿"。
关于日志模式,三个档位常被问到:
writeback(数据可后落,最快最不安全)、ordered(默认,数据先于元数据提交,较平衡)、data=journal(数据与元数据都进日志,最慢最稳)。生产上多是ordered。
原理图
图1:一次read("/mnt/testfs/hello.txt")的旅程
图2:目录、inode、数据块的关系(名字不在 inode 上!)
目录 /mnt/testfs (一张映射表) hello.txt ──► inode 12 ──► [数据块][数据块][数据块] dirA ──► inode 13 ──► (目录数据: 子项名→inode) lost+found ──► inode 11 硬链接hard.txt ──┐ ├──► 同一个 inode 13 (Links=2, 删一个名字仍在) 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/s |
| ext4 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,如果改成data=journal,对"崩溃恢复"和"写入性能"分别有什么影响? - 你
rm了一个正在被某进程以O_DIRECT持续写入的大文件,磁盘空间会立刻释放吗?为什么?(提示:文件描述符 / inode 引用)
实验环境:华为云 FlexusX ecs-44ec-0002,Ubuntu 24.04 LTS,内核 6.8.0-106-generic,8 核 16 GiB。所有输出均来自该机器的真实执行;实验结束后已
umount /mnt/testfs并删除测试镜像,未对宿主机造成残留。