☰
Linux文件系统底层机制详解:VFS、inode、页缓存与日志
2026/10/7 3:23:22 网站建设 项目流程

写这篇文章完全是接着上一篇的思路来的。上一篇聊了 Linux 文件系统的基础概念,今天这篇想再往里钻一钻,把那些面试经常问、日常排查问题绕不开的底层机制讲清楚。文件系统看起来只是把数据存进磁盘,可一旦深入,就会发现它其实是一整套缓存、索引、日志、层次结构配合出来的结果。理解了这些之后再去看df、du、fsck、mount这些命令,很多莫名其妙的输出马上就说得通了。

这篇文章适合刚接触 Linux 运维或后端开发的读者,也适合准备 Linux 面试的人。我会从 VFS 开始,把 inode、dentry、page cache、挂载机制、日志文件系统这些核心概念讲透,再用实际工作中经常踩的坑来验证这些知识点。

1. 文件系统到底是什么

1.1 从用户的一个小困惑说起

有一次同事发现服务器上的某个目录删不掉,报错说空间不足,但用df -h一看,根分区明明还有几十 GB 可用。于是他怀疑磁盘坏了,直接把整台机器重启了一遍。重启后问题依旧,最后才发现是目录里有一个已经被进程删除但仍占着句柄的日志文件,空间被“占而不放”,而且碰巧另一个分区被 inode 耗尽。这个场景很典型,如果不了解文件系统的工作方式,完全不可能靠试错解决。

文件系统对普通用户来说,就是一串目录和文件。但对内核来说,它是一个非常复杂的软件层。它要负责把块设备的扇区组织成我们看到的目录树,要处理文件名的检索,要记录每个文件占用了哪些磁盘块,还要保证系统崩溃之后不至于整个文件全丢。你发出的每一次read、write、open、stat,最终都会经过文件系统这层转化,变成对设备的具体操作。

1.2 文件系统分层模型

Linux 的文件系统大致分成三层:最上层是系统调用接口,中间是虚拟文件系统层,最下面是具体的文件系统实现和块设备驱动。

系统调用接口不用多说,就是我们在用户态调的open、read、write、close这些。中间这一层才是今天的主角,名字叫 VFS,虚拟文件系统。它存在的意义是让所有文件系统用同一套方式对上提供接口,对上层的应用而言,ext4、XFS、Btrfs、FAT、NFS 都只是一个“文件系统”,不需要关心底层差异。

第三层是各种具体实现,比如 ext4 处理 extents 和日志,XFS 处理自己的 B+ 树元数据,NFS 走网络 RPC。每家的存储布局可以完全不同,但在 VFS 眼里,大家都是同一套操作的实现者。

这个分层设计最大的好处是扩展性和统一性。系统管理员可以随意在 /、/home、/data 上挂不同的文件系统,应用程序不用重新编译。配合 mount 机制,甚至可以直接把网络存储和本地目录无缝融合。

2. VFS 里的四个核心对象

2.1 为什么必须有一个虚拟层

如果你同时用过 Windows 和 Linux,就会发现一个有意思的现象:Windows 的盘符 C 盘、D 盘是固定的,而 Linux 只有一个根目录,U 盘、移动硬盘、光驱挂上来之后都变成根目录下面的某个目录。这个设计不是随意的,它正是 VFS 存在的理由之一。

VFS 提供了一套标准的数据结构和接口,让内核不需要关心设备是谁家的。比如你插入一个 U 盘,内核识别出设备后,会按设备上的文件系统类型调用对应的驱动去读取超级块,然后在根目录的某个挂载点建立起这个文件系统与 VFS 的连接。之后你访问/mnt/usb/a.txt,VFS 会先找到/mnt/usb对应的是哪个具体文件系统,然后把路径解析任务交给那个文件系统。

2.2 inode、dentry、file、super_block

真正理解 VFS,必须把这四个结构分清楚。

第一个是 super_block,超级块。它描述的是整个文件系统的全局信息,比如块大小、总块数、空闲块数、文件系统状态、挂载选项。一个文件系统被挂载时,内核就要读它的超级块。ext4 的超级块还有多个副本,分别放在不同的块组里,主副本损坏时可以从备份恢复。

第二个是 inode,索引节点。它保存一个文件的元数据,包括文件类型、权限、属主、属组、大小、时间戳、数据块位置,以及硬链接数。目录本身也是一个 inode,只不过里面的数据是一张从文件名到 inode 编号的映射表。inode 不保存文件名,文件名是放在上级目录的数据块里的,这个设计导致了一个非常重要的结果:硬链接本质上就是多个目录项指向同一个 inode。

第三个是 dentry,目录项。它主要是缓存路径解析结果的。比如你访问/var/log/messages,内核需要从根目录开始逐级查,先查var的目录项,再查log,最后查messages。如果每次都重新读磁盘,会很慢。dentry 缓存就是用来加速这个过程的内存层。

第四个是 file,文件对象。它不是持久存在的,而是每次打开文件时创建的。它记录了当前读写位置、打开模式、对应的 dentry、以及对 inode 的引用。多个 file 可以指向同一个 inode,比如同一个文件被打开两次,或者被多个进程打开。

这几个对象之间的关系是:进程打开文件时拿到 file 对象,通过 file 找到对应的 dentry,dentry 指向 inode,inode 负责管理真实的磁盘数据。理解了这个链条,你就能解释“为什么删除一个正在被占用的文件,空间不释放”“为什么硬链接的文件修改会影响所有名字”“为什么 stat 和 ls 经常显示完全一样的 inode 信息”。

还有一个不算常用的对象是 address_space,它把文件页缓存和具体文件的 inode 关联起来。每个 inode 都有一个 address_space,里面是页面树。读文件时如果页面在缓存里,直接返回内容,不需要访问磁盘。写文件时先写缓存,后面再异步刷盘。

2.3 页缓存与回写机制

page cache 是 Linux 性能的基石。你读一个文件,内核会按页把内容读进内存;再次读同一页时,直接内存返回,速度提升几个数量级。写也是一样,write系统调用默认先写缓存,标记脏页,然后由内核在合适的时间真正写到磁盘。这个机制在绝大多数场景下是对的,但它也带来一个副作用:断电或系统崩溃时,缓存里的数据可能还没落盘。

这里就涉及/proc/sys/vm/dirty_ratio、dirty_background_ratio这些参数。它们控制脏页的高水位和后台回写时机。dirty_ratio 默认大约是 20,也就是当脏页占内存比例达到 20% 时,用户进程自己的写操作会被阻塞,强制刷盘。后台回写的阈值则低一些,大概 10,到了这个比例内核会启动后台线程慢慢写。生产环境里如果大量小文件频繁写,可能会出现一写就卡一下的情况,这往往就是 dirty_ratio 触发了同步限制。

sync 命令的作用就是强制把所有脏数据刷到磁盘。它不只是刷 page cache,还会把文件系统元数据也刷下去。真正常用的做法是在停运维、拔移动硬盘前执行一个 sync,尽量减少数据丢失窗口。

3. 磁盘上的文件系统怎么选

3.1 ext4 的关键机制

ext4 是目前最通用的默认文件系统之一,它把 ext3 的很多功能做了升级,比较关键的是 extents、多块分配和日志校验。

先看 extents。ext2、ext3 时代,一个文件的数据块位置是记录在 inode 的 block 数组里的,每个块都要记录块号,文件一碎片化就非常慢。ext4 用 extent 树来表示连续的数据块范围,一次记录起始块号和长度,大文件的元数据开销大幅度下降,连续文件的读写性能也更快了。

再看日志。ext4 默认是 journaled 模式,也就是把元数据变更先写入一个日志区域,然后再修改实际位置。这样做是为了保证崩溃恢复时一致性。默认的 data 模式是 ordered,意思是对元数据记日志,在元数据提交前保证相应的数据块已经写到磁盘。这样即使断电,最多丢失刚写入的数据,但不会出现目录项指向尚未写入的 inode 这种“半成品”状态。

用 mkfs.ext4 时,可以通过-O参数关闭或开启某些特性,比如64bit、metadata_csum这些。生产上新格式化磁盘时,我一般会加上-E lazy_itable_init=1来避免格式化阶段全盘扫描 inode,加快初始化速度。

3.2 XFS 与大文件场景

XFS 是老牌的高性能文件系统,特别适合大文件、大分区。它设计之初就考虑了并行 IO,元数据用 B+ 树管理,支持大量并发操作。RHEL 7、8 默认就是 XFS,CentOS 7 之后也是。

XFS 没有传统意义的“块组”,而是把空间切割成多个分配组,每个分配组有独立的 inode 分配和管理结构。这就意味着并发的文件创建和删除可以在不同的分配组并行,全局锁冲突少。对高并发小文件写入,XFS 的表现也很稳定。

XFS 的日志只记录元数据,不支持文件数据日志,但这不意味着不安全。它内部有一套复杂的恢复机制,崩溃后扫描日志和 AGI 结构把文件系统恢复到一致状态。日常运维中如果遇到 XFS 分区只读挂载,dmesg往往已经提醒了 metadata 有问题,需要xfs_repair处理。

一个值得注意的点:XFS 暂时无法收缩文件系统大小,也就是不能用resize2fs那种方式缩小分区。所以给 XFS 分区做 LVM 时,最好预留一些调整空间,否则要缩分区只能迁移数据重建。

3.3 Btrfs 的 COW 与快照

Btrfs 是一个具有很多“现代”特性的文件系统,比如写入时复制、快照、校验和、子卷、在线压缩。它把磁盘组织成树状结构,所有元数据和数据都可以按树来管理,子卷就是独立的树根,因此给一个子卷做快照成本非常低,几乎是零拷贝,因为快照只是复制树的根部记录。

COW 的机制是写文件时不直接覆盖旧数据块,而是新分配一个块写入,然后更新元数据指向新块,旧块在确认没有引用之后才被回收。这意味着任何时候磁盘上都保留了一个历史上某个时刻的一致状态,快照和回滚才有意义。

Btrfs 的缺点一直也很明显,早期稳定性差,修复工具不如 ext4 和 XFS 成熟,但在很多 NAS、个人存储场景下越来越流行。如果要用 Btrfs,我建议至少用内核 4.19 以上的版本,而且要开启 scrub(定期扫描磁盘错误)作为习惯,千万别把生产关键数据库硬塞给 Btrfs 当小白鼠。

选型上简单说:默认无脑用发行版默认就好,CentOS/RHEL 默认 XFS,Ubuntu 默认 ext4;数据安全要求高、需要快照可以用 Btrfs;如果做海量小文件存储,也可以考虑 ext4 或者 XFS,配合高内存和高 IOPS。

4. 实操中的文件系统管理

4.1 查看和确认文件系统类型

新接手一台服务器,第一件事就是知道每个分区是什么文件系统、挂载参数是什么。用findmnt或lsblk -f最直观。

findmnt /data # 或者 lsblk -f /dev/sdb1

但有一点容易忽略:df -T显示的是挂载点所在文件系统的类型,blkid看的是块设备上的标识信息。如果某个目录只是 bind mount 或者覆盖挂载,df -T结果可能和你预想的不一样。排查挂载关系时用findmnt -a会看到完整结构,而且能识别出哪些是子挂载。

/proc/mounts是内核视角的真实挂载状态,有些基于 FUSE 的文件系统只在挂载点出现了进程,不一定在这里看到明显差异。管理时多几个命令交叉验证总没坏处。

4.2 挂载与挂载参数

挂载命令是 mount,但生产环境的挂载参数远比默认值重要。常见优化是打开 noatime 或 relatime。默认情况下,每次读文件都会更新 atime,这会带来额外的写 IO。relatime 是折中方案:只有在 atime 早于 mtime/ctime 时更新。而 noatime 则完全不更新,适合大量读的场景,能明显降低写压力。

mount -o rw,noatime,data=ordered /dev/sdb1 /data

还有一个参数是discard,它对应在线 TRIM 的功能。传统机械盘不需要,SSD 则需要定期或即时回收空闲块。但生产环境建议用 fstrim 定时任务跑,而不是一直开着 discard,因为在线 discard 在高负载下可能与 SSD 垃圾回收产生冲突,导致性能抖动。具体做法是 crontab 里每周跑一次fstrim -av。

nofail 参数也得记一下。写 /etc/fstab 时,如果某块设备在启动时不存在,默认可能导致系统进入维护模式。加 nofail 之后启动可以跳过这个挂载点,适合移动硬盘和备份盘。

/dev/sdb1 /data ext4 defaults,noatime,nofail,x-systemd.device-timeout=5 0 2

4.3 常见维护操作

格式化文件系统,最常用的三类是 ext4、XFS、Btrfs。

mkfs.ext4 /dev/sdb1 mkfs.xfs /dev/sdb1 mkfs.btrfs /dev/sdb1

格式化之前务必确认没选错盘,命令不会二次确认。生产上我习惯先用lsblk和wipefs -n确认设备干净,再用blkid看旧标识。

resize 操作也要谨慎。ext4 可以resize2fs扩大缩小,XFS 扩可以xfs_growfs,缩不行。LVM 与文件系统配合时,先扩 LV 再扩文件系统,顺序不能反。

lvextend -L +100G /dev/vg/data resize2fs /dev/vg/data # ext4 xfs_growfs /data # xfs

这些操作在生产上一定要先看快照或备份,尤其是缩容。用 ext4 缩分区时,resize2fs要求卸载文件系统或者只读挂载,否则拒绝执行。缩小到多少,先用df看已用空间,留足余量,不然数据处理会中途失败。

5. 深入问题排查

5.1 磁盘满但 df 显示还有空间的秘密

这是一个很典型的场景,我用一个小步骤就能定位。

df -h /data df -i /data du -sh /data/* lsof +L1 /data

先看空间是否满,再看 inode 是否满。如果 df -h 显示 30G 用了 29G,但 du 加起来只有 5G,大概率是有已删除文件仍被进程占用。lsof +L1会显示删除但仍打开的文件,找到 PID 后重启进程或者等它释放,空间就回来了。

还有一种情况是目录下隐藏文件特别多,du默认统计所有内容,一般不会漏。但如果文件系统上存在大量未连接的孤儿文件,比如 ext4 的 lost+found 目录塞满了东西,du能统计到,只是需要排查是不是之前发生过 fsck。

inode 耗尽更隐蔽。df -h显示一切正常,但touch新文件时报No space left on device。解决办法是清理海量小文件,或者把目录迁移到支持更多 inode 的文件系统。大多数文件系统在 mkfs 时已经按照每多少字节一个 inode 的比例分配好了,比如 ext4 默认 16KB 一个 inode。如果只存几百万个 4KB 小文件,就只能调大 inode 数重新格式化。

5.2 缓存、sync 与掉电保护

有同事问我,sync 到底有没有用。直接回答:sync 确实能强制把页缓存里的脏数据写回磁盘,但它不是万能保险。Linux 的写盘有很高的延迟容忍度,默认策略追求性能,如果在一个不稳定的环境(比如树莓派拔电)里不 sync,文件丢失的概率相当高。

从内核角度拆解一下你执行 sync 时会发生什么。首先它唤醒回写线程,把当前所有文件的脏页变成写 IO 队列;然后等待设备层把数据真正送到存储介质。对于机械盘,意味着磁盘必须完成寻道和写入;对于 SSD,意味着 FTL 必须完成映射。内核通过submit_bio层知道写的完成,sync 后关机相对更安全。

应对掉电更稳妥的方式是使用带掉电保护电容的 RAID 卡或者 NVMe 盘,它们能在断电瞬间把内容冲掉。如果没有这个条件,至少要保持文件系统的日志特性完整,比如 ext4 不要用data=writeback,因为 less protective。它们的差别在于数据块和元数据块的一致性程度,前面的 ordered 模式已经能保证元数据不引用还未落盘的数据块。

5.3 文件系统损坏的应急处理

Linux 通常不会无缘无故崩溃成无法挂载,一旦出现就需要 fsck 或 xfs_repair。常见诱因是异常断电、磁盘坏道、强制关机。我实践中的步骤是:

# 先确认设备当前状态 dmesg | tail blkid /dev/sdb1 # 卸载目标分区,避免在线检查 umount /dev/sdb1 fsck.ext4 -f -y /dev/sdb1

如果是 XFS:

xfs_repair -n /dev/sdb1 # 先检查 xfs_repair /dev/sdb1 # 实际修复

这里有一个坑:在文件系统挂载状态下运行 fsck,可能造成更大的破坏。即使忙,也不能直接对个设备 fsck。很多云主机默认挂了根分区,而你无法卸载它,这时可以用系统维护模式启动,或进入 rescue 环境。fsck 完成后,丢失的文件可能被放到lost+found,需要人工去看是否有可恢复数据。

对于损坏程度大的盘,先做磁盘镜像再修复更安全,例如用dd或ddrescue把整块盘克隆到另一块盘。这块盘就变成只读备份,然后再对副本进行 xfs_repair,避免在坏盘上越修越坏。

6. 一些我踩过的坑和经验

做文件系统方向这么久,有几个观念特别想分享。

第一,别把文件系统当成“存东西的地方”就完事了。文件系统的行为会影响数据库性能、备份可靠性、容器运行稳定性。很多疑难问题最终定位在文件系统层,比如 ext4 的 delayed allocation 导致某目录出现空文件,XFS 的 AG 冲突导致某进程卡在 D 状态。

第二,布局很重要。根分区和业务分区分开,日志、数据库、临时目录分开。系统盘如果被大量日志写满,会直接影响核心服务。备份盘、快照盘也不要和业务盘混在同一块物理盘上,RAID 卡缓存策略也要考虑写回还是写透。

第三,监控要持续。日常关注磁盘空间、inode 数、IO 负载,还要看文件系统是否转成只读。只是等到监控告警再去处理,往往已经晚了。

第四,搞懂 sync 和文件系统刷盘策略再谈“性能优化”。很多人上来就调 dirty_ratio 或者关闭日志,确实能提升一些吞吐,但也有可能引入数据丢失风险。没有取舍决策的前提,不建议盲调。

最后说一个小技巧:每次在 fstab 里改挂载项、格式化新盘之前,先sync再执行。虽然这只是个习惯,但它曾多次阻止我在错误操作前丢失重要数据。文件系统是 Linux 基础里最枯燥也最值得投入时间的一部分,把这些机制吃透,你再去看mount报错、IO 性能问题、数据恢复工具,整个思路会清晰很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询