AI 资讯 知识库 后端技术操作系统内功 · 第 24 篇|VFS 与一切皆文件:统一抽象层的代价与红利

操作系统内功 · 第 24 篇|VFS 与一切皆文件:统一抽象层的代价与红利

为什么需要 VFS:一个接口,几十种后端

Linux 支持几十种文件系统(ext4、XFS、btrfs、NFS、procfs……),但应用只用一套接口:open/read/write/close。让「几十种实现」对上「一套接口」的就是 VFS(virtual file system)——内核里的抽象层。它定义了一组通用对象与操作表,每个具体文件系统实现自己的操作表挂上来。上一章的日志、第 4 篇的 Page Cache、第 6 篇的 sendfile,全都发生在 VFS 及其下层。

「一切皆文件」的准确含义因此更清楚:不是「一切存储在文件里」,而是「一切暴露出文件的接口」。socket 有 fd、管道有 fd、设备有 fd,它们共享同一个 fd 表与系统调用入口,但内部各自的实现天差地别(第 16 篇的 UDS 与普通文件走同一个 read,路径完全不同)。

四个核心对象:superblock、inode、dentry、file

对象 代表什么 生命周期 关键内容
superblock 一个已挂载的文件系统实例 挂载到卸载 文件系统类型、块大小、根 inode
inode 一个文件(元数据) 与文件同寿命 大小、权限、时间戳、块指针
dentry 路径中的一个分量 内存缓存为主 名字 → inode 的映射
file 一次打开的会话 open 到 close 读写偏移、打开模式、fd 绑定

两个最常被混淆的区分在这里讲透。inode 与 file:fstat 读的是 inode 信息(全系统唯一),``/proc/PID/fd/ 里的是 file 会话(每进程独立)——两个进程打开同一文件有各自的偏移,但共享同一个 inode 与 Page Cache 页。**dentry 与 inode**:硬链接的原理就是多个 dentry(不同名字)指向同一个 inode;「删除文件」删除的是 dentry,inode 的引用计数归零才真正释放空间——这解释了「删了日志文件磁盘没释放」的经典故障:进程还握着 file 会话,inode 活着,lsof | grep deleted` 能找到它。

dentry 缓存与 path lookup:路径解析的快慢两界

把 /var/log/app/error.log 解析成 inode 要逐级查:/ → var → log → app → error.log,每级一次目录查找。如果每次都去磁盘遍历目录块,路径查找就是性能灾难。内核的答案是 dcache(dentry cache):把「名字 → inode」的映射全部缓存在内存,配合 inode cache 缓存 inode 本身。热路径的查找是纯内存哈希,冷路径才落盘。

由此有几个可观测的推论:目录里塞几百万个文件的查找会退化(dcache 装不下、目录块遍历变长,ext4 用 HTree 缓解但物理极限仍在——按时间分片目录是通用解);find/ls 全量扫描比应用层索引慢得多(每次 lookup 都可能 miss);容器镜像层数越多、路径越深,冷启动的 path lookup 越贵(overlayfs 每层都要 lookup,第 8 篇讲过它的层叠机制)。

页缓存与 inode 的绑定:buffered IO 的锚点

第 4、21 篇反复出现的 Page Cache,在 VFS 的坐标系里就是「以 inode 为索引的页集合」:address_space 结构把一个 inode 与它的缓存页绑定,read/write/mmap/shared mapping 全部汇聚到这里。这个视角统一了前面的知识点:双缓存问题(第 22 篇)是「应用的缓存池 vs per-inode 的 address_space」;mmap(第 17 篇)是把 address_space 的页映射进地址空间;writeback(第 21 篇)是回写线程扫 address_space 里的脏页。VFS 就是把这些机制挂在一起的骨架。

伪装成文件的内核接口:procfs、sysfs、devtmpfs

三个特殊的「文件系统」是后端工程师每天在用的:

  • procfs(/proc):每个进程一个目录(fd、status、maps、smaps_rollup),加上内核全局状态(/proc/meminfo、/proc/vmstat、/proc/net/dev)。本系列大量指标——脏页水位、OOM 评分、NUMA 统计——都从这里读。它是内核函数的「文件皮」:读 /proc/meminfo 时内核现场计算各统计值,不是读存储。
  • sysfs(/sys):内核对象(设备、驱动、cgroup、网络栈)的属性视图,结构化程度比 procfs 高。cgroup v2 的统一层级(第 12 篇)就挂在 /sys/fs/cgroup。
  • devtmpfs(/dev):设备节点的自动管理,udev 在其上做权限与符号链接策略。

理解「这些文件是现场计算的」有两个实用推论:其一,读 /proc 开销不低(尤其 /proc/PID/smaps 全量页表遍历,容器密度高的机器上监控 agent 滥用它会打爆 CPU——smaps_rollup 是官方给的聚合替身);其二,不同内核版本的字段有差异,监控脚本要做兼容。

# 快速感受 VFS 对象的存在
stat /etc/hosts          # inode 信息(inode 号、硬链接数)
ls -li /etc/hosts        # -i 显示 inode 号
df -i                    # inode 用量(小文件把 inode 耗尽导致写入失败的经典故障)
findmnt                  # 挂载树:superblock 的列表

df -i 那一行值得展开:inode 是格式化时按比例预分配的,「磁盘没满却 ENOSPC」的最常见原因就是 inode 耗尽(海量小文件场景)。处理方式只有清理或重建——所以「海量小文件」架构设计的第一课就是预估 inode 预算。

VFS 之上的叠层:overlayfs 与 FUSE

VFS 允许文件系统互相堆叠,两个后端工程师必须认识的叠层:overlayfs(容器镜像的层叠 union 文件系统,lower 只读层 + upper 可写层,第 8 篇讲过其写时复制与 whiteout 语义);FUSE(用户态文件系统框架,把 VFS 请求转发给用户态进程——sshfs、s3fs、大量对象存储挂载工具的基础)。FUSE 的代价是每 IO 多两次上下文切换与拷贝,延迟敏感路径慎用;它的价值是把文件系统开发从内核挪到用户态——JuiceFS、CephFS 的 FUSE 客户端都靠它。

inotify 与 fanotify:文件变更的观测接口

「一切皆文件」只有读取语义是不够的,变更观测同样重要——轮询扫描既慢又延迟,内核提供的两个事件接口是正解。inotify 是进程级接口:watch 一个文件或目录(inotify_init 加 inotify_add_watch),得到事件流(IN_MODIFY、IN_CREATE、IN_DELETE、IN_MOVED_FROM 等),事件驱动地感知变更——日志采集器(filebeat、fluentd 的轮转检测)、开发工具的热重载都靠它。限制要清楚:watch 数量有上限(默认每实例 8192,/proc/sys/fs/inotify/max_user_watches 可调,容器镜像层多的机器监控 agent 常把这个打爆);它不递归(子目录要逐个 add);且跨文件系统不可见、NFS 上不可靠。fanotify 是另一档位:系统级接口(需要 CAP_SYS_ADMIN),以挂载点为粒度监听全文件系统的事件,还支持访问控制(拦截 open 请求做审批)——杀毒引擎、数据防泄漏(DLP)产品的地基。两个接口都不适合当消息总线:事件有合并语义(短时间多次修改可能合并为一个事件),可靠消费必须以「事件触发 + 全量重扫」双保险。日志轮转的经典陷阱也在这层:inotify 报 IN_MOVED_FROM 后如果采集器没处理 rename 的语义(新文件从零开始),就会漏采或重复采——logrotate 与采集器的协议要按这套事件语义显式设计。

容量观测的会计学:df、du 与 statfs 的口径战争

「磁盘满了」的排查经常在 df 与 du 的数字对不上时陷入混乱,根源是三个口径的会计差异。df 读 statfs:文件系统的全局视图(总块、已用块),它知道所有已分配的块——包括被删除但被进程持有的文件(第 24 篇的 inode 段讲过:dentry 删了、file 会话还在,块未释放);du 遍历目录树累加文件大小,天然看不见「无名的已删除文件」——df 大、du 小的经典现场就是它,lsof 加 grep deleted 找到持有人,重启或触发其日志轮转即释放。第二个差异源:预留块(root reserved blocks,默认 5%)——df 的 Use% 把预留算进已用,非 root 进程实际可用空间比 df 显示的多 5%;ext4 的 -m 参数可调,大容量数据盘常降到 1%。第三个差异源:稀疏文件与 reflink——du 默认按实际块算(--apparent-size 换口径),容器镜像层 reflink 共享的块只在一个层里计一次,du 的数字在 overlay 的 lower/upper 各看各的。写脚本时的正确姿势:容量告警用 statvfs(与 df 同口径)而非遍历计数;inode 容量单独监控(df -i,小文件场景 inode 先耗尽);删除文件后验证释放要对比 df 而非 du。磁盘会计的本质与内存的 RSS/PSS 一课(第 17 篇)同构:每个数字都有它的统计边界,先用对口径再谈容量。

tmpfs 与内存文件系统:容量会计的特殊科目

tmpfs(/dev/shm 与 /tmp 的默认形态)是内存会计里的特殊科目:它的文件占的是匿名内存(不可回收、计入内存压力),却以「文件系统」的面目出现在 df 里。三个工程要点。其一,tmpfs 的容量参数是上限不是预分配(size=50% 默认为内存一半),写入多少占多少——「往 /tmp 写大文件」实质是吃匿名内存,第 20 篇的 OOM 评分全额计入,这是「/tmp 塞了个 20G 文件然后 OOM」的机制解释。其二,容器的 /dev/shm 默认只有 64MB(docker 的历史默认值),共享内存型应用(PyTorch DataLoader、某些 IPC 密集中间件)的「诡异 bus error」多半是它——docker run 的 --shm-size 与 K8s 的 emptyDir medium: Memory 加 sizeLimit 是标准处方。其三,tmpfs 的内容在 swap 可用时会被换出(它的页是可换出的匿名页),「内存文件系统」的延迟在内存紧张时退化为盘级——延迟敏感的共享内存区要配 mlock(第 31 篇的实时配方同款)或关闭 swap。观测口径:tmpfs 的占用看 df(文件视角)与内存视角(shmem 统计,/proc/meminfo 的 Shmem 行)一致,但与 anon 的区别要靠 /sys/fs/cgroup/memory.stat 的 shmem 字段拆开——容量告警与 OOM 复盘时把它当独立科目,不要混进 Page Cache。

超级块与挂载的工程面:容器时代的 mount 细节

挂载视图是 VFS 工程面上最容易被低估的一块。容器的普及让 mount namespace(第 8 篇)成了日常:每个容器独立挂载树,同一台宿主机上几十个 superblock 实例并行运转,probe 类故障(mount 泄漏、重复挂载)的排查需要 per-process 视图——/proc/PID/mountinfo 是唯一诚实的入口,它列出该进程可见的每个挂载点、superblock ID 与挂载选项,比全局 /proc/mounts 更接近真相(mounts 显示的是发起者的视图)。三个高频细节:其一,bind mount 与 subtree 的 mountinfo 行不同(root 字段标明子树起点),排障时看 root 字段而不是只看挂载点;其二,overlay 容器里上层的 upperdir 与 workdir 是隐藏挂载,mountinfo 能看到它们在宿主机的真实路径——「容器里删了文件宿主机没释放」的排查起点;其三,systemd 的 PrivateTmp 等沙箱选项靠 mount namespace 加 bind 实现,服务在 systemd 下看到的 /tmp 与 shell 里不是同一个 tmpfs 实例——「脚本里跑得好好的,进 systemd 就找不到文件」的经典答案。把 mountinfo 加进容器故障的工具箱,是 VFS 知识到运维能力的最后一公里。

小结

VFS 用四个对象(superblock/inode/dentry/file)把几十种存储实现统一到一套接口,「一切皆文件」的准确含义是接口统一而非存储统一。dcache 让路径解析在热路径纯内存化,冷路径成本解释了巨型目录与容器镜像的性能现象;inode 与 dentry 的区分解释了硬链接与「删除未释放」故障;per-inode 的 address_space 是 Page Cache 的锚点,串起了第 4、17、21、22 篇的所有缓存话题。procfs/sysfs 的「现场计算」本质决定了监控脚本的正确姿势。下一篇回到网络编程的高频陷阱:epoll 惊群与 SO_REUSEPORT。

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?