文件系统
什么是文件系统
磁盘在硬件层面只是一堆可以随机读写的块(block):没有任何结构,也不区分"文件"和"空闲空间"。文件系统(File System)是操作系统用于在存储设备上组织和管理文件的一套数据结构与算法,它回答三个问题:
- 每个文件的数据存放在哪些块上;
- 哪些块是空闲的、可以分配;
- 如何从文件名定位到这些块。
可以粗暴地理解成:文件系统是"磁盘块管理 + 名字空间管理"的组合。
文件系统向上提供文件的抽象(打开、读、写、关闭),向下把操作翻译成对块设备的读写。对于上层应用来说,文件的读写就像操作一段连续的字节流;至于这些字节在磁盘上的真实位置、是否连续,全部由文件系统负责。同一块磁盘可以划分多个分区,分别格式化为不同的文件系统,各自独立管理。
存储介质基础
讨论文件系统之前,需要先了解底层存储介质的特性,因为很多文件系统的设计都是围绕介质的物理特性展开的。
机械硬盘 HDD
机械硬盘由盘片、磁头、马达等机械部件组成。数据存放在盘片的磁道上,磁道又划分为扇区(sector),传统扇区大小是 512 字节,现代硬盘多为 4K 扇区(Advanced Format)。读写数据时,磁头需要先移动到目标磁道(寻道),再等待盘片旋转到目标扇区(旋转延迟),这两者都是毫秒级的机械延迟:
- 寻道时间:约 2~15 ms;
- 旋转延迟:毫秒级,7200rpm 盘片平均旋转半圈约 4.2ms。
随机读写与顺序读写性能差距巨大,因此为机械硬盘设计的文件系统(ext4、XFS 等)都把"数据连续存放、减少寻道"作为重要目标——例如 inode 表、位图等元数据都有固定位置,文件数据块尽量连续分配。
固态硬盘 SSD
SSD 基于 NAND 闪存,没有机械部件,随机读性能远超机械硬盘。但闪存有自己的一套特性:
- 读写单位不对称:读和写的最小单位是页(page,通常 4K~16K),而擦除的最小单位是块(block,通常包含 64~256 个页);
- 不支持原地覆盖:闪存单元必须擦除后才能写入。修改一个页的流程是"读整块 → 修改 → 写新块 → 擦旧块",实际的写入量大于逻辑写入量(写入放大);
- 擦写次数有限:每个单元擦写次数有限(寿命),控制器通过磨损均衡(wear leveling)把写入均匀分布到整个盘;
- 需要 TRIM:文件删除后,文件系统通过 TRIM/Discard 命令告知 SSD 哪些块已经空闲,SSD 才能在后台擦除复用,否则写入性能会持续下降。可以用
fstrim命令手动触发。
SSD 随机访问快,碎片化的代价小,但大文件顺序写仍然是它最友好的访问模式。
扇区与块
文件系统不直接按扇区管理空间,而是定义了更大的分配单位——块(block,又称簇),通常是 4K。文件占用的磁盘空间按块分配,这也是"文件只有 1 字节却占用 4K 空间"的原因。ext4 的块大小在 mkfs 时指定(1K/2K/4K/64K),创建后不可更改。
[root@host ~]# lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
sda 8:0 0 100G 0 disk
├─sda1 8:1 0 512M 0 part /boot
└─sda2 8:2 0 99.5G 0 part /
[root@host ~]# fdisk -l /dev/sda | grep "Sector size"
Sector size (logical/physical): 512 bytes / 512 bytes整个 Linux 存储栈自上而下大致是:VFS → 具体文件系统 → 通用块层 → IO 调度器 → 设备驱动 → 磁盘,完整的分层如下:
![]()
文件系统的内部结构
不同的文件系统实现差别很大,这里以 Linux 最常用的 ext4 为例,讲清楚一个文件系统内部都有哪些数据结构。
超级块 Superblock
超级块记录整个文件系统的元信息:块大小、块总数、inode 总数、空闲块数、挂载次数、最近一次挂载时间、magic number(0xEF53)等。超级块被破坏,整个文件系统就无法识别。ext4 把磁盘划分为若干个块组(block group),在部分块组中保存了超级块与块组描述符的备份,当主超级块损坏时可以用备份恢复。
用 dumpe2fs 可以查看超级块信息:
[root@host ~]# dumpe2fs -h /dev/sda2 | grep -E "Block count|Inode count|Block size|Filesystem features"
Filesystem features: has_journal ext_attr resize_inode dir_index filetype extent 64bit flex_bg sparse_super large_file huge_file metadata_csum
Block count: 26214400
Inode count: 6553600
Block size: 4096inode:文件的"身份证"
在 Linux 文件系统中,文件名和文件内容是分离的:文件内容存放在数据块里,文件的元数据存放在 inode(index node)中。每个 inode 有一个编号,inode 表在 mkfs 时就固定分配好。inode 记录的信息包括:
- 文件类型(普通文件、目录、符号链接、设备文件等);
- 权限(rwx)、属主(uid/gid);
- 大小、链接数(硬链接计数);
- 时间戳:atime(最近访问)、mtime(最近修改)、ctime(inode 变更);
- 数据块位置(extent 树,见下文)。
注意 inode 里没有文件名,文件名存放在目录里。用 stat 查看一个文件的 inode 信息:
[root@host ~]# stat test.txt
File: test.txt
Size: 13 Blocks: 8 IO Block: 4096 regular file
Device: 801h/2049d Inode: 131074 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2026-09-26 10:00:00.123456789 +0800
Modify: 2026-09-26 10:00:00.123456789 +0800
Change: 2026-09-26 10:00:00.123456789 +0800
Birth: 2026-09-26 09:00:00.123456789 +0800三个时间戳的区别:读取文件更新 atime(多数发行版默认使用 relatime 挂载选项,仅在必要场合更新);修改文件内容更新 mtime;修改 inode 本身(权限、属主、链接数等)更新 ctime,mtime 变化必然伴随 ctime 变化。inode 数量在 mkfs 时确定,创建后不能动态增加——这是"磁盘有空间却提示 No space left on device"的根源之一(inode 耗尽,见下文)。
数据块寻址:从间接块到 extent
inode 需要记录文件数据存放在哪些块上。早期 ext2/ext3 的做法是间接块(indirect block):inode 里有 12 个直接指针,每个指向一个数据块;再加一级间接、二级间接、三级间接指针。以 4K 块、每个块号占 4 字节为例,每个间接块可以存 1024 个块号:
| 指针类型 | 指向的数据块数 |
|---|---|
| 12 个直接指针 | 12 |
| 一级间接 | 1024 |
| 二级间接 | 1024² |
| 三级间接 | 1024³ |
总容量约 4TB。这种设计的缺点是:访问大文件深处数据时要多次读取间接块,而且大文件难以连续存放、碎片化严重。
ext4 改用了 extent(区段)树:extent 用"起始块号 + 长度"描述一段连续的物理块,inode 内嵌 4 个 extent 槽位,文件大时再挂接额外的 extent 节点。一个 extent 就能描述几十 MB 的连续数据,寻址效率和连续性远好于间接块。filefrag 命令可以看到文件由多少个 extent 组成:
[root@host ~]# filefrag -v bigfile.bin
Filesystem type is: ef53
File size of bigfile.bin is 104857600 (25600 blocks of 4096 bytes)
ext: logical_offset: physical_offset: length: expected: flags:
0: 0.. 25599: 174080.. 199679: 25600: last,eof
bigfile.bin: 1 extent found目录的本质
目录本质上也是一个文件,只是它的内容是"文件名 → inode 号"的映射列表(目录项)。这也是"文件名存在目录里而不是 inode 里"的由来。早期的目录项是线性存储的,大目录下查找一个文件需要线性扫描;ext4 通过 dir_index 特性用 HTree(哈希 B 树)组织目录项,大目录的查找也很快。
dentry:路径查找的内存缓存
按绝对路径打开文件时,内核需要逐级解析:/usr/share/doc/x.txt 要先找到 /usr 的 inode,读出它的内容找到 share 的 inode……每次都从磁盘读目录内容太慢,所以内核用 dentry(目录项缓存)在内存中缓存"路径分量 → inode"的映射,配合 inode 缓存,路径解析大部分情况下不再需要访问磁盘。dentry 同时实现了软链接、挂载点等路径语义。
VFS:一切皆文件的基石
Linux 可以同时挂载 ext4、XFS、tmpfs、NFS 等几十种文件系统,而应用层只看到统一的目录树,这要归功于 VFS(Virtual File System,虚拟文件系统)。VFS 定义了一组抽象对象和操作接口,各文件系统注册自己的实现:
| 对象 | 含义 | 操作接口 |
|---|---|---|
| super_block | 一个已挂载的文件系统 | s_ops:分配 inode、写超级块等 |
| inode | 一个文件 | i_ops:创建、查找、链接等 |
| dentry | 路径中的一个分量 | d_ops:比较、删除等 |
| file | 进程打开的文件 | f_ops:read、write、llseek 等 |
系统调用 open()、read()、write() 进入 VFS 层,VFS 根据文件所在文件系统调用对应的 file_operations 实现,最终落到磁盘。VFS 的另一个作用是统一"一切皆文件":procfs 把进程信息组织成文件,sysfs 把设备信息组织成文件,tmpfs 把内存组织成文件,这些都不是磁盘文件系统,但通过 VFS 拥有了相同的文件接口——所以你可以 cat /proc/cpuinfo、echo 1 > /proc/sys/vm/drop_caches。
文件系统通过 mount 挂载到目录树的某个挂载点上,/etc/fstab 记录开机自动挂载的配置。一个"文件系统"的身份不是分区本身,而是"分区 + 挂载点"的组合。
日志:文件系统如何保证一致性
为什么需要日志
写文件通常涉及多处元数据更新:新建一个文件要更新目录数据块(写入目录项)、分配一个 inode、更新块位图、更新 inode 位图。如果中途断电,这些更新只完成了一部分,文件系统就处于不一致状态——目录指向了不存在的 inode,或者位图显示已分配但实际没有,轻则丢文件,重则整个文件系统无法挂载。传统做法是重启时运行 fsck 全盘扫描修复,但现代磁盘容量巨大,全盘 fsck 可能要几小时。
日志文件系统(Journaling File System)的思路类似数据库的 WAL:对元数据的修改先顺序写入一块专门的日志区(journal),全部落盘后再修改实际位置。断电后重启时,回放日志中的事务:日志完整的重做,日志写了一半的丢弃,从而把文件系统恢复到一致状态,fsck 只需要秒级时间。ext4 默认开启日志(has_journal 特性),XFS、NTFS 等现代文件系统也都支持。
ext4 的三种日志模式
ext4 的日志范围可配置(mount 选项 data=),涉及数据是否也进日志:
| 模式 | 数据写入顺序 | 安全性 | 性能 |
|---|---|---|---|
| data=journal | 数据先写日志,再写实际位置 | 最高 | 最低,所有数据写两遍 |
| data=ordered(默认) | 数据先于元数据日志提交 | 高 | 较高 |
| data=writeback | 数据写入顺序不保证 | 较低 | 最高 |
ordered 模式不把数据本身写进日志(避免双写),只保证"先写数据块、后提交元数据日志",防止出现"元数据指向了尚未写入的数据块"这种不一致。writeback 模式下断电可能出现在旧数据上挂着新元数据的窗口,安全性最弱。
此外 ext4 还有延迟分配(delayed allocation)特性:write 的数据在 page cache 中暂缓分配磁盘块,等到真正写回时才分配,配合 extent 可以把多次小写入合并成连续的大块写入,减少碎片。代价是断电时"已 write 但未分配块"的数据可能丢失——这正是 2009 年那场关于"ext4 延迟分配导致丢数据"的著名讨论的背景。
Page Cache:内存中的文件
写入路径:write 并不直接落盘
应用调用 write() 之后,数据并没有立刻写到磁盘,而是先拷贝进内核的页缓存(Page Cache),对应的页被标记为 dirty,write() 随即返回。真正把 dirty 页写回磁盘的是内核后台的写回线程(flusher,早期叫 pdflush),写回时机由 vm 参数控制:
| 参数 | 默认值 | 含义 |
|---|---|---|
| vm.dirty_background_ratio | 10 | 脏页超过内存 10% 时后台开始写回 |
| vm.dirty_ratio | 20 | 脏页超过内存 20% 时,写入进程被阻塞 |
| vm.dirty_expire_centisecs | 3000 | 脏页驻留超过 30 秒必须写回 |
| vm.dirty_writeback_centisecs | 500 | 检查写回的周期(5 秒) |
读路径同样经过 page cache:读过的页缓存在内存里,下次读直接命中,这正是"第二次读取快得多"的原因。缓存让读写都很快,代价是数据有"还在内存、未落盘"的窗口:进程崩溃没关系(数据在 page cache 里),但整机断电会丢失最近几秒到几十秒的写入。这也是为什么日志、数据库等对持久性有要求的程序必须显式 fsync。
强制落盘
需要保证数据落盘时,应用调用 fsync/fdatasync:
fsync(fd):把该文件 dirty 数据和元数据全部写回磁盘;fdatasync(fd):只写回数据以及影响后续读取的元数据(如文件大小),不写回 atime 等无关元数据,比 fsync 快;- 打开文件时加
O_SYNC/O_DSYNC标志则每次 write 都同步落盘。
许多"丢数据"的事故根源就是少了一次 fsync:例如程序认为写完了,其实数据只在 page cache 里,宕机后丢失。反过来,每次都 fsync 的性能代价也很大(磁盘 IO 是毫秒级),工程上通常在批量提交时做一次 fsync(例如 MySQL 的事务提交)。
O_DIRECT:绕过缓存
对有些程序来说 page cache 是多余甚至有害的:
- 数据只有一次读的机会(如视频流),缓存命中率低还挤占其他进程缓存;
- 程序自带缓存(如数据库的 Buffer Pool),page cache 造成"双份缓存",内存被浪费。
打开文件时带 O_DIRECT 标志即可绕过 page cache,数据在用户缓冲区与磁盘之间直接传输。代价是绕过了内核的预读、合并、排序等优化,读写必须对齐块大小,顺序 IO 性能可能下降。MySQL InnoDB 就提供了 innodb_flush_method=O_DIRECT 的选项来避免双重缓存。
硬链接与软链接
inode 与文件名分离的设计,自然产生了硬链接(hard link)和软链接(symbolic link):
- 硬链接:
ln old new,new 与 old 指向同一个 inode,inode 的链接数加一。两个名字地位完全平等,删除任意一个,另一个仍然有效。硬链接只能用于同一文件系统(inode 不能跨文件系统引用),且不能给目录创建硬链接(防止目录成环)。 - 软链接:
ln -s old new,new 是一个独立的 inode,类型为符号链接,内容是目标路径字符串。访问软链接时内核重定向到目标路径。软链接可以跨文件系统、可以指向目录,但目标被删后软链接变成悬空链接。
[root@host ~]# echo hello > f.txt
[root@host ~]# ln f.txt h.txt
[root@host ~]# ln -s f.txt s.txt
[root@host ~]# ls -li f.txt h.txt s.txt
131074 -rw-r--r-- 2 root root 6 Sep 26 10:00 f.txt
131074 -rw-r--r-- 2 root root 6 Sep 26 10:00 h.txt
131075 lrwxrwxrwx 1 root root 5 Sep 26 10:01 s.txt -> f.txt注意到 f.txt 和 h.txt 的 inode 号相同(131074)、链接数都是 2,s.txt 则是独立 inode。rm 的真实语义不是"删除文件",而是"删除一个目录项并把 inode 链接数减一";链接数归零且没有任何进程打开该文件时,inode 和数据块才真正被释放。
一些常见问题
inode 耗尽
inode 数量在 mkfs 时固定,创建后不可增加。当文件系统中大量小文件(如海量缓存文件、邮件、日志)把 inode 用光时,即使磁盘有剩余空间也无法创建新文件。df -i 查看 inode 使用率:
[root@host ~]# df -i /data
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sdb1 3276800 3276800 0 100% /data
[root@host ~]# touch /data/new_file
touch: cannot touch '/data/new_file': No space left on device磁盘明明有空间却报 No space left on device,先 df -i 看 inode。解决办法是把小文件归档或转移到其他文件系统,或者备份数据后重新 mkfs 指定更大的 inode 数量。
df 与 du 不一致
df 统计的是文件系统的空闲块,du 统计的是文件实际占用的块,两者对不上最常见的原因是:已删除但仍被进程占用的文件。某个进程打开着文件,然后文件被 rm 删除——目录项没了,但 inode 的链接数没有归零(还有进程引用),数据块不会释放,du 看不到它,df 的空间却被占着。日志轮转、大文件被删但程序没重启是典型场景:
[root@host ~]# df -h /data
Filesystem Size Used Avail Use% Mounted on
/dev/sdb1 100G 80G 20G 80% /data
[root@host ~]# du -sh /data
60G /data
[root@host ~]# lsof +L1 /data | head
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
java 12345 app 7w REG 8,17 21474836480 0 131100 /data/app.log (deleted)lsof 里 NLINK 为 0 的 (deleted) 文件就是罪魁祸首:重启对应进程即可释放。另一个常见原因是稀疏文件——ls -l 显示大小与 du 实际占用块数不符,见下文。
稀疏文件
truncate -s 1T big.img 可以瞬间创建一个 1T 的文件,因为文件系统只分配了元数据,没有分配实际数据块。读取未写入的区域会返回 0,写入时才真正分配块。ls 看到的是逻辑大小,du 看到的是实际占用:
[root@host ~]# truncate -s 1T sparse.img
[root@host ~]# ls -lh sparse.img
-rw-r--r-- 1 root root 1.0T Sep 26 10:00 sparse.img
[root@host ~]# du -h sparse.img
0 sparse.img虚拟机镜像、备份文件的快速预分配经常用到这个特性。复制稀疏文件时要注意工具是否保留稀疏性(cp 默认保留,scp、tar 等工具则可能把它展开成实打实的 1T)。
常见文件系统一览
| 文件系统 | 特点 | 适用场景 |
|---|---|---|
| ext4 | 成熟稳定,兼容性最好,多数发行版默认 | 通用场景 |
| XFS | 大规模并行 IO、动态 inode、支持超大容量 | 大文件、数据库、高吞吐 |
| Btrfs | CoW、快照、子卷、校验和 | 需要快照/回滚的场景 |
| ZFS | CoW、快照、去重、RAIDZ | 存储服务器(Linux 上通过 OpenZFS) |
| tmpfs | 纯内存,掉电即失 | /tmp、需要极速读写的临时数据 |
| procfs/sysfs | 内核信息伪文件系统 | 查看/调整内核状态 |
| overlayfs | 联合挂载,写时复制 | 容器镜像分层 |
| NFS | 网络文件系统 | 多机共享存储 |
| vfat/ntfs | 兼容 Windows | U 盘、双系统数据盘 |
ext4 和 XFS 是 Linux 上最主流的选择:RHEL/CentOS 系默认 XFS,Debian/Ubuntu 系默认 ext4。ext4 胜在兼容性和成熟生态,XFS 胜在超大容量和并行性能;需要快照、子卷等高级特性时考虑 Btrfs/ZFS(CoW 特性与数据库这类随机写负载的配合需要额外关注)。
常用命令速查
| 命令 | 作用 |
|---|---|
| df -h / df -i | 查看空间/inode 使用率 |
| du -sh | 目录占用空间 |
| stat | 查看 inode 元信息 |
| ls -li | 查看 inode 号与链接数 |
| mount / findmnt | 查看挂载情况 |
| lsblk | 查看块设备拓扑 |
| dumpe2fs / xfs_info | 查看文件系统参数 |
| filefrag | 查看文件 extent 分布/碎片情况 |
| fallocate / truncate | 快速分配/预占空间 |
| lsof +L1 | 查找已删除但仍被占用的文件 |