文件系统与 IO 栈
open() 返回一个整数,read() 把它交给内核,数据就出现在用户缓冲区里;这中间经过 VFS、具体文件系统、页缓存、通用块层、块设备驱动五层。硬盘上的数据最终要落成「哪个块、在哪个块组、由哪个 inode 指向」,而这套映射藏在磁盘结构里。
本篇按两条线展开。第一条线是磁盘上的静态结构:inode 与目录项怎么分工(§1)、VFS 用哪四个对象把它们统一起来(§2)、一个进程同时打开多个文件时三层表怎么组织(§3)。第二条线是数据怎么落到块上:三种分配方式与索引分配的寻址上限推导(§4)、空闲空间怎么管理(§5)、块组内部长什么样(§6)、目录为什么也是文件(§7)、硬链接与软链接的结构差异(§8)。最后四节处理运行时行为:页缓存与回写(§9)、磁盘调度算法(§10)、可调参数(§11)与常见故障的排查顺序(§12)。
数据在内核态与用户态之间搬几次、每次拷贝的代价,在 §9.4 里展开,本篇只在 §9 给出与落盘相关的分支,拷贝次数的推导不重复。
机制字段以 Linux 内核文档 Documentation/filesystems/ext4/、Documentation/admin-guide/sysctl/、man 2 open、man 2 fsync 为准;取不到官方出处的推断标注为待验证。
1. 文件系统的基本组成
文件系统负责把文件组织到磁盘上,并在断电后还能找回来。它要回答的是三个问题:某个文件的数据在哪几个块上、哪些块还空着、这个文件能被谁以什么方式读写。
1.1 数据、元数据与目录项
Linux 给每个文件分配两个数据结构:索引节点(index node,inode)和目录项(directory entry,dentry)。
inode 记录文件的元信息,是文件在文件系统内的唯一标识。ext4 的一条 inode 记录默认 256 字节,前 128 字节是 ext2 时代就定下的经典字段,后面 128 字节放扩展字段与校验和(来源:Linux 内核文档 Documentation/filesystems/ext4/inodes.html)。经典字段包括:
i_mode:文件类型(普通文件、目录、字符设备、符号链接等)与权限位(rwxrwxrwx 加 setuid/setgid/sticky)。i_uid/i_gid:属主与属组的 ID。i_size_lo/i_size_high:文件大小,两个 32 位字段拼成 64 位。i_atime/i_mtime/i_ctime/i_crtime:访问、数据修改、inode 变更、创建四个时间戳,各自带一个*_extra字段提供亚秒精度。i_links_count:硬链接计数,即引用这个 inode 的目录项个数。i_blocks_lo:文件占用的磁盘块数,ext4 里以 512 字节为单位计。i_block[15]:60 字节的块映射区,指向文件的数据块。ext4 在这里放 extent 树的根节点(§4.3)。i_flags:inode 标志位,如EXT4_EXTENTS_FL(用 extent 树)、EXT4_HUGE_FILE_FL(大文件)。
inode 里唯独没有文件名。 文件名存放在目录项里,由目录的数据块保存。同一个 inode 可以被多个目录项指向,这些目录项可以有各自不同的名字——这就是硬链接(§8)。
元信息与数据分开存带来一个直接后果:权限、大小、时间戳记在 inode 上,改动它们不碰数据块;重命名只改目录项里的一段字符串。mv 在同一个文件系统内因此接近常数时间,跨文件系统才退化成「复制 + 删除」。
inode 编号在单个文件系统内唯一,ls -i 显示的就是它。inode 本身占磁盘空间——格式化时就要预留 inode 表,mkfs.ext4 的 -i 参数决定每多少字节配一个 inode。inode 分配光了,即使数据块位图里还有大段空闲,新文件也创建不出来(§12)。
目录项记录文件名、对应的 inode 号,以及与其他目录项的层级关系。多个目录项挂成一棵树,就是用户看到的目录结构。
目录项与 inode 的关键区别在存在哪里:inode 持久化在磁盘的 inode 表里,目录项是内核在内存里维护的缓存对象,掉电就没。内核读一个目录时,把解析出来的目录项放进 dentry cache,下次访问同一路径直接命中内存,不必再读磁盘上的目录块。
一个目录项指向一个 inode,一个 inode 可以被多个目录项指向,两者是多对一。把「名字在目录块里、元信息在 inode 表里、数据在数据块区里」这三者的连线画出来,就得到下面这张关系图:
┌───────────────────────┐ ┌──────────────────────────┐
│ 目录的数据块 │ │ inode 表 │
│ ┌─────────────────┐ │ │ ┌────────────────────┐ │
│ │ 名字 "a.txt" │ │ │ │ inode #42 │ │
│ │ inode 号 = 42 ───┼──┼───────▶│ │ i_mode / i_size │ │
│ └─────────────────┘ │ │ │ i_links_count = 1 │ │
└───────────────────────┘ │ │ i_block[0] ───┐ │ │
│ │ i_block[1] ─┐ │ │ │
│ └──────────────┼─┼───┘ │
└─────────────────┼─┼──────┘
│ │
┌─────────────────▼─▼──────┐
│ 数据块区 │
│ ┌────────┐ ┌────────┐ │
│ │ 第 1 块 │ │ 第 2 块 │ │
│ └────────┘ └────────┘ │
└──────────────────────────┘目录项和目录不是一回事
目录是文件,持久化在磁盘上,有自己的 inode;目录项是内存里的缓存结构,表示路径上的一个节点。名字出现在父目录的数据块里,同时对应内存里一个 dentry。
1.2 超级块与磁盘上的三个区域
格式化一块盘时,文件系统把可用空间划成三个区域(来源:Linux 内核文档 Documentation/filesystems/ext4/globals.html):
超级块存文件系统全局信息(块大小、块总数、inode 总数、空闲计数、magic 号);inode 区存每个文件的元信息记录,ext4 默认每条 256 字节;数据块区存文件内容、目录项与间接索引块。
超级块描述整个文件系统的形状。ext4 超级块里的关键字段(来源:Linux 内核文档 Documentation/filesystems/ext4/globals.html):s_blocks_count(块总数)、s_log_block_size(块大小,以 1024 字节为底的对数)、s_inodes_count(inode 总数)、s_blocks_per_group(每块组的块数)、s_magic(固定值 0xEF53)。dumpe2fs -h /dev/sda1 能把这些字段直接读出来。
三块内容的加载时机不同:
- 超级块在文件系统挂载时读入内存,常驻。后续的空闲块分配、inode 分配都要查它维护的计数。
- inode 表按需加载。访问一个文件时才把对应块组里的 inode 记录读进来,放进 inode cache。
- 数据块通过页缓存按需读入,属于 VFS 与具体文件系统的公共缓存层(§9)。
超级块是全有或全无的:它损坏后文件系统挂不上,里面的文件即使数据块完好也读不出来。所以 ext2 起就在多个块组里保留超级块与块组描述符的冗余副本(§6.2)。
1.3 一次 open 做了什么
用户看到的是「打开文件拿到 fd」,内核在这条路径上做的事比想象的多。以 open("/home/yj/a.txt", O_RDONLY) 为例(路径解析流程来源:Linux 内核文档 Documentation/filesystems/vfs.html):
- 路径解析:把
/home/yj/a.txt按/切开,从根目录的 inode 起逐段查 dentry cache。命中就跳过磁盘读;未命中就读父目录的数据块,找到名字对应的 inode 号,再读 inode 表。 - 权限检查:拿到目标 inode 后,用
i_mode、i_uid、i_gid与进程的 uid/gid 比对,判断能否读。 - 分配 file 对象:内核为这次打开创建一个
struct file,记录偏移、打开标志、指向 inode 的指针,并把 inode 的引用计数加一。 - 返回 fd:在当前进程的 fd 表里找最小的可用整数下标,把
file指针填进去,把这个整数返回给用户。
返回值是 fd 的编号而不是地址,所以一个进程的 fd 从 0 开始递增,0/1/2 默认被标准输入、输出、错误占用。open 返回后,后续的 read/write/lseek 只用这个整数,内核再拿它去查对应的 file 对象,不必再解析路径。
这也解释了一个现象:文件被 open 后,即使把它从目录里删掉,已拿到的 fd 仍能继续读写——fd 指向内存里的 file 对象,与磁盘目录项已解耦。这个性质在 §5.3 用来解释 df 与 du 的不一致。
2. 虚拟文件系统 VFS
ext4、XFS、Btrfs 的磁盘布局完全不同,但应用只想用一套 open/read/write。VFS 就是夹在系统调用与具体文件系统之间的抽象层(来源:Linux 内核文档 Documentation/filesystems/vfs.html)。
VFS 定了一组所有文件系统都要实现的数据结构与标准接口。应用只面对这组接口,不需要知道底下是哪个文件系统;具体文件系统只实现这组接口,不需要知道调用它的是哪个应用。
2.1 四个核心对象
VFS 用四个对象建模「一个文件系统」「一个文件」「一个路径节点」「一次打开」四件事(来源:Linux 内核文档 Documentation/filesystems/vfs.html):
superblock(超级块对象) 一次挂载的文件系统实例
│ s_op → super_operations
│ s_root → 根 dentry
├──────────────────────────────────────────────┐
│ │
inode(索引节点对象) 一个文件的元信息 │
│ i_op → inode_operations │
│ i_fop → file_operations │
│ i_sb → 所属 superblock │
│ │
dentry(目录项对象) 路径上的一个名字节点 │
│ d_inode → 命中的 inode │
│ d_parent → 父 dentry │
│ d_subdirs → 子 dentry 链 │
│ │
file(文件对象) 一次 open 的实例 │
│ f_op → file_operations │
│ f_inode → inode │
│ f_pos → 当前偏移 │
└─ f_path → 路径(mount + dentry)──────────────┘四个对象的分工:
- superblock:代表一个已挂载的文件系统,记录块大小、挂载点、根 inode、脏 inode 链等,对应磁盘上的超级块。
- inode:代表一个文件(或目录、设备节点),记录元信息与数据块位置,对应磁盘上的 inode 记录。
- dentry:代表路径里的一个节点,把「名字」与「inode」绑在一起,只在内存里存在。
- file:代表一次打开,记录偏移与打开标志。同一个 inode 可以被多次打开,产生多个
file对象。
「一次 open 一个 file 对象」这条在 §3 会变成一个具体问题:fork 之后父子进程的 fd 指向同一个 file 还是各有一个。
2.2 file_operations:read 怎么落到 ext4
应用调 read(fd, buf, n),内核拿到 fd 对应的 file 对象,取出 f_op 字段,调用它的 read_iter。f_op 指向的是创建这个 inode 的那个文件系统注册的函数表,所以同一个 read 系统调用能落到 ext4、XFS 或 tmpfs 各自不同的实现上。
同一条 read 路径,落到 ext4 是 ext4_file_read_iter,落到 tmpfs 是 shmem_file_read_iter,落到 NFS 是转成 RPC 的 nfs_file_read。
这张函数表是 VFS 的唯一抓手。C 里没有虚函数,用「结构体里放函数指针」实现多态:struct file_operations 的每个成员是一个函数指针(read_iter、write_iter、mmap、fsync、iterate_shared 等),具体文件系统在注册 inode 时把自己的实现填进去。VFS 只按固定偏移调用,不关心填的是谁。
iterate_shared 对应目录读取(readdir),mmap 对应内存映射,fsync 对应 §9.3 的落盘。同一个 inode 上的这些操作由同一张表提供,一个文件系统的读、写、映射、同步行为因此是一致的。
2.3 dentry cache、inode cache 与三类文件系统
路径解析是文件访问里最频繁的操作,/home/yj/project/src/main.c 每访问一次就要走六段。如果每段都读磁盘,开销无法接受。内核用两层缓存挡住:
- dentry cache(dcache):缓存「父目录 + 文件名 → inode」的映射。命中后路径解析在内存里走完,不碰磁盘。它也是
struct dentry对象池,由一个 slab 缓存管理。 - inode cache:缓存已加载的 inode 记录。多个 dentry 命中同一个 inode 时共享同一份元信息,避免重复读 inode 表。
命中顺序是 dcache → inode cache → 磁盘:dcache 命中直接拿 inode;不命中但 inode cache 命中,就新建 dentry 挂进 dcache;两级都不命中,才读磁盘的 inode 表与父目录数据块。
两层缓存的规模可以在 /proc/slabinfo 或 slabtop 里看:dentry 一行是 dentry cache 的条目数,ext4_inode_cache 一行是该文件系统 inode cache 的对象数。free 命令输出的 buff/cache 里,Buffers 是块设备缓冲,Cached 同时包含页缓存与这两类 slab 缓存的对象。
dentry cache 是「最近用过」的缓存,内存紧张时会回收;回收后的 dentry 在下次访问时重新解析。这一点对排查有影响:ls 一个刚访问过的目录很快,冷缓存时的第一次会明显更慢。
按数据存放位置,Linux 的文件系统分三类(来源:Linux 内核文档 Documentation/filesystems/):
| 类别 | 数据在哪 | 例子 |
|---|---|---|
| 磁盘文件系统 | 块设备上 | ext4、XFS、Btrfs |
| 内存文件系统 | 内存页里 | tmpfs、procfs(/proc)、sysfs(/sys) |
| 网络文件系统 | 远端主机 | NFS、SMB/CIFS |
文件系统要先挂载到目录树上才能用。挂载点把一个文件系统的根 inode 接到父文件系统的一个 dentry 上,形成一棵树。/proc、/sys、/dev 都是挂载进来的内存文件系统,读它们时内核现场生成内容,磁盘上没有对应文件。
内存文件系统不走向磁盘回写的路径,写 tmpfs 的数据只占内存与 swap,掉电即失。这也解释了为什么 tmpfs 上 fsync 几乎立即返回——没有介质要同步。
3. 文件的使用:fd 表、打开文件表与 inode 表
§1.3 说 open 返回一个整数 fd。这个整数到 inode 之间隔着三层表,理解这三层是看懂 fork、dup、重定向与「偏移为什么共享」的前提。
3.1 三层结构
一个已打开的文件在内核里对应三层结构(来源:Linux 内核文档 Documentation/filesystems/files.html):
进程级 系统级(所有进程共享) 文件系统级
┌──────────────┐ ┌────────────────────┐ ┌──────────────┐
│ 进程 A │ │ 系统打开文件表 │ │ inode 表 │
│ fd 表 │ │ (open file table) │ │ (inode table) │
│ 0 → ────────┼─────────▶│ ┌──────────────┐ │ │ ┌──────────┐ │
│ 1 → ────────┼───┐ │ │ file 对象 #1 │ │ │ │ inode │ │
│ 2 → ────────┼─┐ │ │ │ f_pos = 0 │ │ │ │ #42 │ │
│ 3 → ────────┼─┼─┼─────▶│ │ f_flags │ │ │ │ 元信息 │ │
└──────────────┘ │ │ │ └──────┬───────┘ │ │ │ i_block │ │
┌──────────────┐ │ │ │ │ 指向 inode│ │ └────┬─────┘ │
│ 进程 B │ │ │ │ ┌──────▼───────┐ │ └──────┼───────┘
│ fd 表 │ │ └─────▶│ │ file 对象 #2 │───┼──────────────┘
│ 0 → ────────┼─┼───────▶│ │ f_pos = 100 │ │ 多个 file 可指向同一 inode
│ 1 → ────────┼─┘ │ └──────────────┘ │
└──────────────┘ └────────────────────┘- 进程级 fd 表:每个进程一张,下标是 fd 编号,值是指向系统打开文件表条目的指针。
open返回的下标就是从这里取的。 - 系统打开文件表(open file table):内核全局一张,每个条目是一个
struct file,记录当前偏移f_pos、打开标志f_flags、指向 inode 的指针。 - inode 表:每个文件一条,记录元信息与数据块位置。
三层的关键在于:偏移 f_pos 存在中间的 file 对象上。两个 fd 是否共享偏移,取决于它们是否指向同一个 file 对象。
3.2 fork 与 dup:什么共享、什么不共享
三条规则:
open两次同一个文件:产生两个独立的file对象,各有自己的f_pos,读写互不影响。fork:子进程复制父进程的 fd 表,父子两边的 fd 指向同一批file对象,因此共享f_pos。父进程读走 100 字节,子进程的read从第 101 字节开始。dup/dup2/fcntl(F_DUPFD):在同一个进程内复制 fd,新 fd 指向同一个file对象,共享f_pos与标志。
两次 open 各得一个 file、偏移独立;fork 后父子共享同一批 file、偏移共享;同进程内 dup 出的新 fd 与旧 fd 指向同一 file、偏移共享。
这个差异在并发写日志时会出事:父子进程用同一个 fd 交替 write,两次写之间的 f_pos 是共享且逐次推进的,数据不会互相覆盖;两个进程各自 open 同一个文件再交替写,就可能写到同一段位置,后写的覆盖先写的。
要让多次写之间不互相覆盖,用 O_APPEND:内核把「定位到末尾」和「写入」合成一个原子步骤(来源:man 2 open),即使多个进程各自 open 了这个文件,每次写都追加到当前末尾。O_APPEND 的原子性在 NFS 上不成立——NFS 协议本身不支持追加,客户端内核只能靠重试模拟,多进程并发追加仍会损坏文件(来源:man 2 open)。
3.3 open 标志位:O_APPEND / O_SYNC / O_DIRECT
open 的 flags 参数里有一组影响读写语义的标志(来源:man 2 open):
| 标志 | 语义 | 代价与边界 |
|---|---|---|
O_APPEND | 每次写前把偏移定位到文件末尾,定位与写是单个原子步骤 | 保证多进程追加不互相覆盖;NFS 上不成立 |
O_SYNC | 写返回时,数据与关联的文件元数据都已到达底层硬件(等价于每次写后跟一次 fsync) | 每次写都等介质,吞吐大幅下降 |
O_DSYNC | 写返回时,数据与「取回数据所必需」的元数据已到达硬件(等价于每次写后跟一次 fdatasync) | 比 O_SYNC 少同步一部分元数据,仍要等介质 |
O_DIRECT | 尽量绕过页缓存,数据直接在用户缓冲区与设备之间传 | 可能带对齐限制(缓冲区地址、长度、偏移),不保证与 O_SYNC 同等的持久性 |
O_SYNC 与 O_DSYNC 的差别是「要不要连 st_mtime、st_atime 这类元数据一起同步」——它们对后续读回数据没有影响,所以 O_DSYNC 可以少刷一部分(来源:man 2 fsync 对 fdatasync 的说明)。文件大小属于「读回数据所必需」,truncate 改小文件时必须同步。
O_DIRECT 的对齐限制随文件系统与内核版本变化,可能要求缓冲区地址、长度、文件偏移都按块对齐;不满足时要么返回 EINVAL,要么静默退化成带缓存的 I/O(来源:man 2 open)。Linux 6.1 起可以用 statx 的 STATX_DIOALIGN 查询某个文件的实际对齐要求(来源:man 2 open)。
O_DIRECT 单独使用时只是「尽力同步传输」,要保证数据与元数据都落盘得同时加 O_SYNC(来源:man 2 open)。§9 会说明它为什么不是「写得更快」的开关。
4. 文件的存储:三种分配方式
一个文件的数据块在磁盘上怎么放,有三种基本方式。它们的分歧点在「随机访问」和「空间碎片」这两件事上:连续分配随机访问最快但会产生碎片,链式分配没有碎片但只能顺序访问,索引分配两者兼顾,代价是多出一层索引块。
4.1 连续分配与链式分配
连续分配把文件放在磁盘上一段连续的块里。文件头(在 Linux 里就是 inode)记录「起始块号 + 长度」两个值,读整个文件只要一次寻道。
优点是读写快:磁头一次寻道就能顺序读出整段。缺点同样直接:
- 外部碎片:删掉中间一个文件会留下一段空洞,新文件如果比任何单个空洞都大,即使所有空洞加起来够大也放不进去。
- 不易扩展:文件要变大时,后面的空间可能已被别的文件占用,只能整体挪动。
这两种缺陷让它只适合「创建时就知道大小、之后不再变大」的场景,比如一次写入的归档文件。
链式分配把文件拆成离散的块,每块里留一个指针指向下一块。按指针存放位置分两种:
- 隐式链表:指针放在数据块内部,inode 只记第一块与最后一块。要读第 n 块必须从头顺着指针走 n 步,无法直接访问;指针损坏会丢掉后面整条链。
- 显式链表:把所有指针抽出来,集中放在内存的一张表里。这张表就是文件分配表(File Allocation Table,FAT)。
FAT 整张表常驻内存,表项下标是磁盘块号,值是该块的后继块号:文件用了块 4、7、2、10、12,就从表项 4 查到 7、从 7 查到 2,一直走到结束标记 -1。查找在内存里做,比隐式链表快得多。
FAT 的问题在表的大小随磁盘块数线性增长。举一个算术:一块 200 GB 的盘,按 1 KB 的块切,有 2×10⁸ 个块,每表项 4 字节,整张表要 800 MB 内存。块越小,表越大。所以 FAT 只在小容量介质上(U 盘、SD 卡)还有使用,大磁盘上不现实。
4.2 索引分配与 12 + 1 + 1 + 1 布局
索引分配给每个文件一张「索引块」,里面是指向数据块的指针数组。inode 只记指向索引块的指针,找第 n 块就查索引块的第 n 个表项,随机访问复杂度降到 O(1)。
优点是创建、增大、缩小都方便,没有外部碎片,顺序读写与随机读写都支持。代价是多一层索引:小文件本来一个数据块就够,还要额外占一个索引块。
大文件放不进一个索引块时,有两种组合方式:
- 链式索引块:索引块末尾留一个指针指向下一个索引块。保留了链式分配的弱点——指针损坏会丢掉后面的索引。
- 多级索引块:一个索引块存多个下一级索引块的指针,逐层向下,像一棵多叉树。
经典 Unix 文件系统把直接索引与多级索引组合起来,inode 里放 15 个块指针(i_block[15],共 60 字节,来源:Linux 内核文档 Documentation/filesystems/ext4/inodes.html):
inode 的 i_block[15]
┌──────────────────────────────────────────────────────────────────┐
│ 0 .. 11 │ 12 │ 13 │ 14 │
│ 直接块 × 12│ 一级间接 │ 二级间接 │ 三级间接 │
└─────┬──────┴────┬─────┴─────┬─────┴──────────────┬─────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│数据块│ │索引块│ │索引块│ │索引块│ │索引块│
└──────┘ │ 指针 │ │ 指针 │ │ 指针 │ │ 指针 │
└──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘
▼ ▼ ▼ ▼
数据块 索引块… 索引块… 索引块…
▼ ▼ ▼
数据块 索引块… 索引块…
▼ ▼
数据块 …小文件只用 12 个直接块;超过 12 块后用一级间接;再不够用二级、三级。这套布局对「绝大多数文件很小」的实际分布做了优化:小于 12 块的文件只花 0 个索引块。
最大文件尺寸要算。 设块大小 4 KB = 4096 字节,块指针 4 字节,则一个索引块能放的指针数是 p = 4096 / 4 = 1024。各级能寻址的块数:
| 层级 | 指针数 | 可寻址块数 |
|---|---|---|
| 12 个直接块 | 12 | 12 |
| 一级间接 | 1024¹ | 1,024 |
| 二级间接 | 1024² | 1,048,576 |
| 三级间接 | 1024³ | 1,073,741,824 |
总块数 = 12 + 1024 + 1024² + 1024³ = 1,074,791,436 块。换算成字节:
1,074,791,436 × 4096 = 4,402,345,721,856 字节 ≈ 4 TiB
这个数不是估算——ext4 官方文档给出的「4 KB 块、块映射(block map)方式」单文件上限正是 1,074,791,436 块 / 4 TiB(来源:Linux 内核文档 Documentation/filesystems/ext4/blocks.html)。文档同时给出其余块大小的值:1 KB 块 → 16,843,020 块 / 16 GiB,2 KB 块 → 134,480,396 块 / 256 GiB。三个数都能用同一公式验算:块大小 B 时 p = B/4,总块数 = 12 + p + p² + p³。
上限同时受字段宽度约束
ext4 文档里 64 KB 块那一列算出 4,398,314,962,956 块,但文档标注它「实际受字段宽度限制,仍是 2^32」(来源:Linux 内核文档 Documentation/filesystems/ext4/blocks.html)。块号是 32 位,能表示的不同块号上限就是 2³²。所以「布局能寻址多少」与「字段能表示多少」是两条独立的上限,取小的那个。
三级间接的代价在访问大文件的尾部:读一个远离开头的块要逐级读三次索引块,每次都是一次磁盘 I/O。文件越大、访问越随机,这三次索引读的占比越高。这直接催生了 ext4 的 extent 方案。
4.3 ext4 的 extent 树
ext4 用 extent(区段)替换了「一个块一个指针」的映射方式。一个 extent 描述一段连续的物理块:起点文件块号、长度、起点物理块号。ext4 文档里的例子是:分配一段连续的 1000 个块,旧方案要一个装满 1000 个表项的间接块,用 extent 只需一条记录(来源:Linux 内核文档 Documentation/filesystems/ext4/ifork.html)。
相关结构的大小(来源:Linux 内核文档 Documentation/filesystems/ext4/ifork.html):
struct ext4_extent:12 字节,字段是ee_block(4 字节,起始文件块号)、ee_len(2 字节,覆盖块数)、ee_start_hi(2 字节)+ee_start_lo(4 字节,起始物理块号)。struct ext4_extent_idx:12 字节,指向下一层 extent 节点所在块。struct ext4_extent_header:12 字节,含eh_magic(固定0xF30A)、eh_entries、eh_max、eh_depth。
inode 的 i_block 60 字节里放 extent 树的根节点,去掉 12 字节头部还剩 48 字节,正好放 4 条 extent 记录。这意味着头 4 段连续区域不需要额外元数据块。超过 4 段时,根节点变成内部节点(eh_depth > 0),指向新的索引块;叶子节点(eh_depth == 0)指向数据块。
extent 树的深度上限是 5 层(来源:Linux 内核文档 Documentation/filesystems/ext4/ifork.html)。文档给的推导是:一个逻辑块号最多 2³²,要满足 4 × ((块大小 − 12) / 12)ⁿ ≥ 2³² 的最小 n 是 5。
单条 extent 覆盖的块数也有上限:ee_len 是 16 位,最大 32768 个初始化块(最高位为 1 时表示未初始化 extent,长度最大 32767,来源:Linux 内核文档 Documentation/filesystems/ext4/ifork.html)。按 4 KB 块算,一条记录最多覆盖 128 MB 连续区域。
extent 方案对大文件的两类访问都更有利:顺序文件用少数几条 extent 就描述完,索引块读得少;稀疏文件可以只记录有数据的区段,空洞不必分配块。
4.4 三种分配方式的取舍边界
| 维度 | 连续分配 | 链式分配(FAT) | 索引分配(间接 / extent) |
|---|---|---|---|
| 随机访问 | 一次寻道直达 | 必须顺序走链(FAT 例外) | 查索引;ext4 大文件查 extent 树 |
| 外部碎片 | 有,且难回收 | 无 | 无 |
| 文件扩展 | 差,需整体挪动 | 好 | 好 |
| 元数据开销 | 最低(两个数) | 表随磁盘块数线性增长 | 每文件至少一个索引块 |
| 指针损坏的影响 | 只丢本文件 | 丢整条链 / 整表 | 链式索引丢后半段,多级索引局部 |
| 典型使用 | 早期磁带、只读归档 | U 盘、SD 卡 | ext2/3 的间接块、ext4 的 extent |
判断分配方式是否合适,看文件大小分布与访问模式:小文件多、顺序读为主的场景,直接块加 extent 够用;大量随机访问大文件的场景,映射结构的层数决定每次读要付几次索引 I/O。
5. 空闲空间管理
§4 讲的是「已占用的块怎么组织」,这一节反过来:新文件要写时,从哪里找空闲块。
5.1 空闲表、空闲链表与位图
- 空闲表法:给所有空闲区建一张表,每条记录「第一个空闲块号 + 空闲块数」。分配时顺序扫描找合适区间。空闲区少时够用;空闲区一多,表就变大,扫描变慢。它只适合连续分配。
- 空闲链表法:把空闲块串成链表,每个空闲块里存一个指向下一个空闲块的指针。分配时从链头取,回收时接到链头。缺点是不能随机访问,在链上增删要读写多个块,且指针本身占空间。
两者都不适合大文件系统——表或链会随空间碎片化而膨胀。
位图用每块 1 bit 表示该块是否已分配:0 空闲、1 占用。整个磁盘的块对应一整段连续比特。要找一个空闲块,扫位图找 0;要判断某块是否被占用,取对应位。对 n 个块,位图占 n/8 字节——一个 4 KB 的位图块能表示 4096×8 = 32768 个块。
Linux 的 ext2/3/4 都用位图管理空闲空间,而且数据块与 inode 各有一张位图(来源:Linux 内核文档 Documentation/filesystems/ext4/blockgroup.html 的块组布局)。一张管数据块,一张管 inode 记录。这解释了 §1.1 的「inode 也会用尽」:inode 位图里没有 0 了,即使数据块位图还有大段 0,也创建不了新文件。
位图成为主流方案的原因有三点:开销小(1 bit/块,比链表里每个空闲块一个指针小得多);支持随机查找,容易找到连续的整段空闲块;实现简单,与 CPU 的位扫描指令匹配,一次就能在一个字节里定位到最低的 0 位。
5.2 成组链接法
成组链接法把空闲块按固定数量分组,每组的第一块存放「本组空闲块数 + 下一组各块的块号」。系统在内存里维护一个「空闲块号栈」,栈里是当前可分配的一组空闲块号。分配时从栈顶弹出;栈空时,读链头上那一组进栈继续分配。回收时把块压回栈;栈满就把整栈内容写进一个空闲块,作为新的一组挂到链头。
它同时避开了空闲表的膨胀与空闲链表的随机访问差:内存里只保留一组,链上的组按需读入。这是 UNIX System V 空闲空间管理的做法。
5.3 df 与 du 为什么对不上
df 和 du 看的是两种口径的「已用空间」:
df直接读文件系统的超级块与空闲计数,报告整个文件系统级别的已用/可用块。它统计的是「已经被分配出去的块」,不管这些块现在还有没有名字。du从指定目录往下遍历,把每个文件的实际占块数加起来。它统计的是「目录树里能看到的文件占了多少块」。
两者对不上,最常见的原因是被删除但仍被进程持有的文件:
被删除文件的 inode 的 i_links_count 减到 0,但进程的 fd 仍指向 file 对象,inode 引用计数不为 0,数据块继续被占用;df 计入这些块,du 遍历不到。
排查命令:
lsof +L1:列出所有已删除但仍被打开的文件,SIZE/OFF与NLINK=0两列能直接看到。ls -l /proc/<pid>/fd:进程打开的 fd 列表,被删除的文件在这个符号链接末尾显示(deleted)。
其余几类不一致:稀疏文件(du 计实际占块,ls -l 的表观大小更大)、硬链接(同一个 inode 被多个目录项引用,du 遍历时可能重复计入)、以及 df 默认把预留块(通常 5%)算作已用但不给普通用户使用(tune2fs -m 调整)。所以「df 满了但 du 没满」先查 lsof +L1,「du 比 df 的已用还大」查硬链接与挂载点重叠。
6. 文件系统的结构:ext4 实例
§5.1 留下一个问题:一个 4 KB 的位图块只能表示 32768 个块,也就是 128 MB 空间。如果整个文件系统只用「一张位图 + 一段数据块」,容量上限就只有 128 MB。ext4 用「块组」把这个规模放大。
ext4 把文件系统切成一系列块组(block group),每个块组是一个自包含的单元:有自己的位图、自己的 inode 表、自己的一段数据块。块组大小由 s_blocks_per_group 决定,常用值是 8 × 块大小(来源:Linux 内核文档 Documentation/filesystems/ext4/overview.html);4 KB 块时每组 32768 块,即 128 MB。文件系统大小除以块组大小就是块组数,N 个块组把可表示空间放大到 N×128 MB。
块组的尺寸和 128 MB 这个数不是巧合:它正是「一个位图块能覆盖的块数」乘以块大小。所以每个块组的一张位图恰好用一个块装得下。
6.1 块组的规模与内部构成
一个标准块组从前往后依次是(来源:Linux 内核文档 Documentation/filesystems/ext4/blockgroup.html):
块组 0 其余块组
┌──────────────────────────┐ ┌──────────────────────────┐
│ 引导区填充 1024 字节 │ │ (无填充) │
├──────────────────────────┤ ├──────────────────────────┤
│ 超级块(superblock)1 块 │ │ 超级块副本 或 直接开始 │
├──────────────────────────┤ ├──────────────────────────┤
│ 块组描述符 GDT(多块) │ │ 数据块位图 1 块 │
├──────────────────────────┤ ├──────────────────────────┤
│ 预留 GDT 块(多块) │ │ inode 位图 1 块 │
├──────────────────────────┤ ├──────────────────────────┤
│ 数据块位图 1 块 │ │ inode 表(多块) │
├──────────────────────────┤ ├──────────────────────────┤
│ inode 位图 1 块 │ │ 数据块(多块) │
├──────────────────────────┤ └──────────────────────────┘
│ inode 表(多块) │
├──────────────────────────┤
│ 数据块(多块) │
└──────────────────────────┘各部分的职责:
- 引导区:块组 0 开头 1024 字节留空,给 x86 引导扇区用;超级块从偏移 1024 字节开始(块大小恰为 1024 时,超级块落在块 1,块 0 被标记占用)。
- 超级块:文件系统全局信息,在块组 0 里是主副本。
- 块组描述符(group descriptor):每个块组一条记录,记录该块组的空闲块数、空闲 inode 数、位图与 inode 表的位置。块组 0 里的 GDT 覆盖所有块组的描述符。
- 预留 GDT 块:给将来扩容块组数预留的空间,格式化时分配。
- 数据块位图 / inode 位图:各 1 块,标记本块组内数据块与 inode 的使用情况。
- inode 表:本块组所有 inode 记录连续存放。
- 数据块:本块组的数据。
dumpe2fs /dev/sda1 | head 能看到块组列表与每组的空闲统计。
6.2 超级块为什么要冗余,inode 表为什么要分散
块组 0 里的超级块与 GDT 是全局信息,损坏就挂不上文件系统。ext2 起就把它们的副本写到其他块组,故障时可以从副本恢复。原始设计在每个块组都放副本;ext4 用稀疏超级块(sparse_super)只把副本写到块组 0、1 以及块组号是 3、5、7 的幂的那些块组,减少冗余占用的空间(来源:Linux 内核文档 Documentation/filesystems/ext4/overview.html)。
把 inode 表按块组分片,而不是集中放一处,有两个原因:
- 减少寻道:inode 与它描述的数据尽量落在同一个块组里,读 inode 再读数据的两次 I/O 集中在盘片的一小段区域,磁头不必跨越大半个盘面。块分配器也刻意把同一个文件的块留在同一组(来源:Linux 内核文档
Documentation/filesystems/ext4/overview.html)。 - 限制单点故障范围:某块组的元数据损坏只影响该组的文件,其余块组的文件仍可访问。
这就是 ext4 的设计目标:把随机 I/O 尽量变成同块组内的局部 I/O。
7. 目录的存储
7.1 目录是特殊文件
目录也是文件,有自己的 inode,i_block 指向的数据块里存的是「目录项列表」而不是普通文件内容。一个目录项的字段是(来源:Linux 内核文档 Documentation/filesystems/ext4/directory.html):
inode:该项指向的 inode 号(4 字节)。rec_len:本目录项的长度(2 字节),因为名字长度可变,靠它跳到下一项。name_len:文件名的字节数(1 字节)。file_type:文件类型(普通文件、目录、符号链接等,1 字节)。name:文件名本身,不补\0。
用 ls -i 看一个目录,显示的 inode 号就是目录项里那个字段。删掉一个文件只改它所在目录的这一项——把 rec_len 合并到前一项里、让这一项被跳过,数据块本身不动。这也是 §8 硬链接「多个目录项指向一个 inode」能成立的前提。
7.2 线性目录与 htree 索引
最早的目录就是一个线性列表,第一项是 .(当前目录),第二项是 ..(上级目录),后面是各文件的目录项。查找一个名字要逐项比对,复杂度 O(n)。
一个目录里塞进几万个文件后,ls 会明显卡住——内核要线性扫过整个目录的数据块才能把结果列全,open 某个文件也要扫到它为止。ext4 用 htree 把目录改成树形索引:以文件名做哈希,哈希值是索引键,目录的数据块组织成一棵多层的哈希树,查找复杂度从 O(n) 降到接近 O(log n),目录很大时也只需要读少数几个块。
htree 的代价是要处理哈希冲突。mkfs.ext4 的 -O dir_index 特性启用它,现代 ext4 默认开启。
目录也有 inode,也占空间
目录文件的大小随目录项增多而增长,也是按块分配的。空目录只有 . 与 .. 两项,占一个块的量级。目录项本身不占 inode——inode 是给文件和子目录的。
7.3 .、.. 与根目录的 inode 2
. 和 .. 是真实存在的目录项,各指向一个 inode:
.指向本目录自己的 inode,所以目录的i_links_count至少为 2(被自己一次、被父目录的目录项一次)。每多一个子目录,父目录的链接计数就加一,因为子目录里有一个..指向父目录。..指向父目录的 inode。根目录的..指向自己。
根目录的 inode 号固定为 2(来源:Linux 内核文档 Documentation/filesystems/ext4/special_inodes.html)。inode 0 不存在;1 是坏块列表;2 保留给根目录;3 与 4 给配额文件;5 给引导加载器;6 给 undelete 目录;7 是 resize inode;8 是日志 inode;11 起才是普通文件可用的第一个 inode,通常先给 lost+found 目录(来源:Linux 内核文档 Documentation/filesystems/ext4/special_inodes.html)。
stat / 看 inode 号会返回 2,这是校验文件系统是否正常的常用手段之一。
8. 软链接与硬链接
给一个文件取别名有两条路:硬链接让多个名字指向同一个 inode;软链接创建一个新文件,内容里存目标路径。两者的结构差异决定了各自的适用范围与删除行为。
8.1 硬链接:同一 inode 的多个目录项
硬链接的做法是在目录里新增一个目录项,让它指向目标文件的同一个 inode,并把 inode 的 i_links_count 加一。
硬链接:两个目录项 → 同一个 inode
/home/yj/a.txt /home/yj/b.txt
│ │
└──────────┐ ┌─────────────┘
▼ ▼
┌──────────────────────┐
│ inode #42 │
│ i_links_count = 2 │
│ i_mode = 普通文件 │
│ i_block → 数据块 │
└──────────┬───────────┘
▼
┌─────────┐
│ 数据块 │ ← 只有一份数据
└─────────┘由这个结构推出硬链接的性质:
- 两个名字完全等价。判断「哪个是原文件」没有意义——inode 是同一个,
ls显示的大小、权限、时间戳都一样。改一个,另一个也变。 - 只能指向同一个文件系统内的文件。inode 号只在单个文件系统内有意义,跨文件系统的一个「2 号 inode」是另一个文件。跨文件系统建硬链接会返回
EXDEV。 - 通常不能指向目录。如果允许,会在目录树上造出环,
find、du这类遍历会陷进去。ext4 会在link一个目录时返回EPERM。.与..是内核自己维护的例外(§7.3)。
8.2 软链接:独立 inode 存目标路径
软链接(符号链接)是一个单独的文件,有自己的 inode,i_mode 标记为符号链接,数据块里存的是目标路径字符串。访问软链接时,内核读到路径字符串,再从该路径重新解析一次。
软链接:新 inode,内容是目标路径
/home/yj/a.txt /home/yj/c.txt
│ │
▼ ▼
┌──────────────────────┐ ┌────────────────────────┐
│ inode #42(普通文件)│ │ inode #77(符号链接) │
│ i_block → 数据块 │ │ 数据 = "/home/yj/a.txt"│
└──────────┬───────────┘ └──────────┬─────────────┘
▼ │ 解析路径
┌─────────┐ │
│ 数据块 │ ◀────────────────────────┘
└─────────┘软链接的性质:
- 可以跨文件系统。它存的是路径字符串,与 inode 无关。
- 可以指向目录。
- 有自己的元数据。软链接文件的大小通常是目标路径字符串的长度,权限位一般显示为
lrwxrwxrwx。 - 目标不存在时软链接仍然存在,只是「悬空」——访问它会返回
ENOENT。
8.3 删除原文件后的行为与观测手段
这是两类链接最容易混淆的地方,判据是「删除动的是 inode 还是名字」:
硬链接:删掉一个名字只让计数减一。计数归零的那次删除才真正回收 inode 与数据块。删掉
a.txt后b.txt仍然完好。软链接:
c.txt是一个独立文件,删a.txt不影响c.txt这个文件本身,但它指向的路径已不存在,解析失败。ln 目标 链接名:建硬链接(默认)。ln -s 目标 链接名:建软链接(-s即 symbolic)。ls -l:硬链接的真身看起来就是普通文件;软链接显示为l类型并以-> 目标结尾。stat 文件:硬链接看Links字段(就是i_links_count);软链接看Size字段(通常是目标路径长度)。stat -c '%i %h'直接给 inode 号与硬链接数。readlink 链接:打印软链接目标路径;对硬链接无意义(硬链接不「指向」,它与目标同体)。find 目录 -xdev -samefile 目标:找出指向同一 inode 的所有硬链接。
一个常见用法是 ls -i 对比:两个硬链接的 inode 号相同,两个软链接与目标的 inode 号不同。这是区分两类链接最直接的观测手段。
9. 文件 I/O 与页缓存
§1.3 与 §3 讲的是「打开」,这一节讲数据怎么在用户缓冲区与磁盘之间流动,以及 write 返回到数据真正落盘之间隔着什么。
9.1 缓冲 I/O 与直接 I/O
两种「缓冲」容易混:标准库缓冲与内核页缓存。
- 标准库缓冲:
fwrite、fprintf这类 C 标准库函数在用户态维护一块缓冲,攒够一批再调一次write。目的是减少系统调用次数——每次write都要一次用户态到内核态的切换。用fflush或setvbuf控制。不经过标准库、直接用write系统调用的写法叫非缓冲 I/O(这里的「非缓冲」专指不经过标准库缓冲)。 - 内核页缓存(page cache):内核为块设备数据维护的缓存。默认所有的
read/write都经过页缓存:读时数据从设备读进页缓存再拷给用户;写时数据从用户拷进页缓存,write就算返回,真正落设备由内核在之后决定。带这个缓存的叫非直接 I/O,绕开它的是直接 I/O(O_DIRECT)。
默认(buffered)路径 O_DIRECT 路径
┌─────────┐ ┌─────────┐
│ 用户缓冲区│ │ 用户缓冲区│
└────┬────┘ └────┬────┘
│ ① CPU 拷贝(写方向) │ ① DMA 直达
▼ ▼
┌─────────┐ ┌─────────┐
│ 页缓存 │ ← write 在这里就返回了 │ 设备控制器│
└────┬────┘ └────┬────┘
│ ② 之后由回写线程 DMA 写设备 │
▼ ▼
┌─────────┐ ┌─────────┐
│ 磁盘 │ │ 磁盘 │
└─────────┘ └─────────┘O_DIRECT 的取舍:它省掉「用户缓冲区 ↔ 页缓存」的一次 CPU 拷贝,也不污染页缓存(对数据库这类自带缓存的应用,避免双份缓存占用内存),代价是丧失页缓存的合并与预读能力,并且要自己处理对齐。它不是「写得更快」的开关——man 2 open 直接写明它「in general this will degrade performance」。它适合的场景是应用自己做缓存(数据库的 buffer pool),而且能按块对齐读写。
用户缓冲区 ↔ 内核缓冲区之间搬几次、DMA 与 CPU 各搬哪一段,这条路径的拷贝次数分析见 §9.4,这里不重复。
9.2 PageCache 与回写
写路径先落页缓存就把 write 返回,真正的设备写在之后由内核的回写机制完成。回写的触发条件(来源:Linux 内核文档 Documentation/admin-guide/sysctl/vm.html、内核源码 mm/page-writeback.c):
- 脏页占比超过
dirty_background_ratio:后台回写线程(flusher)开始把脏页写出去,写进程本身不被阻塞。 - 脏页占比超过
dirty_ratio:触发回写的进程自己也要参与同步回写,写入被阻塞。这是「写延迟突然尖刺」的典型来源。 - 脏页驻留时间超过
dirty_expire_centisecs:回写线程下次醒来时把它写出去。 - 内存紧张:需要回收页面时被迫先回写脏页。
两条阈值的关系是:脏页先越过较低的 dirty_background_ratio,后台线程开始回写但不阻塞写进程;继续增长越过较高的 dirty_ratio,写进程被拉进同步回写。
回写线程是内核的 kworker 一类线程(历史上叫 pdflush,现代内核是每 backing device 的 flusher 线程)。dirty_writeback_centisecs 决定它多久醒一次(默认 500,即 5 秒),dirty_expire_centisecs 决定脏页多老才有资格被写(默认 3000,即 30 秒)(来源:内核源码 mm/page-writeback.c)。
回写把随机写合并成顺序写:多个进程零散写的脏页攒在页缓存里,回写线程按块在设备上的位置排序后成批写出,机械盘上因此省掉大量寻道。这是 buffered I/O 吞吐通常高于 direct I/O 的原因之一。
9.3 fsync、fdatasync、sync 与 write 后的数据位置
write 返回不代表数据落盘。要保证持久性,要靠这三个调用(来源:man 2 fsync):
| 调用 | 范围 | 是否等介质 | 阻塞 |
|---|---|---|---|
fsync(fd) | 该文件的脏数据 + 关联元数据(含 st_mtime 等) | 是,等到设备报告完成 | 是 |
fdatasync(fd) | 该文件的脏数据 + 「取回数据所必需」的元数据(如 st_size) | 是 | 是 |
sync() | 系统范围内所有脏页与元数据 | 通常(排队刷盘) | 依实现,不保证逐一等待 |
fdatasync 比 fsync 少刷一部分元数据:st_atime、st_mtime 这类时间戳不影响数据读回,可以不刷;改文件大小(st_size)必须刷(来源:man 2 fsync)。
一个常被忽略的点:fsync 一个文件并不保证该文件名所在的目录项也落盘。文件内容与 inode 持久化了,但目录里那条「名字 → inode」的映射可能还在内存里,崩溃后会看到一个数据完整却没名字的文件。要让「新建文件」这个动作也持久,还得对所在目录的 fd 再调一次 fsync(来源:man 2 fsync)。
代价方面,每次 fsync 都要等设备确认写入完成:机械盘上是一次寻道加旋转等待(毫秒量级),SSD 上是一次编程加完成返回(几十微秒到毫秒)。每条记录一次 fsync 会让吞吐从每秒数万次掉到每秒数百次;把 fsync 批量化(一次刷一批提交)是数据库写日志的惯常做法。
把 §9.1 与 §9.2 串起来,一次 write 返回时,数据可能处在四个位置之一:
位置 write 返回 断电后是否还在
────────────────────────────────────────────────────────
① 用户缓冲区 —— 不在(还没交给内核)
② 页缓存(内核内存) 是 不在
③ 设备自身的易失缓存 是 通常不在(除非带掉电保护)
④ 磁盘介质 是 / 否 在write返回:数据在 ② 或更靠后。此时断电,② 里没回写的部分丢失。fsync/fdatasync返回:数据已到 ④,③ 里的设备缓存也被要求刷出(来源:man 2 fsync的「writing through or flushing a disk cache」)。- 只保证数据到达设备控制器缓存(③)而没要求落介质的调用,在带易失写缓存的磁盘上掉电仍可能丢。
这也解释了两个常见现象:写完立刻断电可能丢最近几秒的写;程序 write 返回成功、close 也没报错,数据仍可能没到介质。日志、数据库这类要求「提交即持久」的场景,必须在提交点安排一次 fsync 或 fdatasync,不能依赖页缓存的自动回写。
9.4 零拷贝:mmap、sendfile 与 DMA
把一份文件从磁盘送到网卡,最朴素的 read + write 要走八步(来源:man 2 read、man 2 write):
- 用户调用
read,上下文从用户态切到内核态(切换 1); - DMA 把磁盘数据拷进内核缓冲区;
- CPU 把内核缓冲区的数据拷进用户缓冲区;
read返回,切回用户态(切换 2);- 用户拿到数据后调用
write,再切到内核态(切换 3); - CPU 把用户缓冲区的数据拷进 Socket 缓冲区;
- DMA 把 Socket 缓冲区的数据拷到网卡;
write返回,切回用户态(切换 4)。
传统 read + write:4 次上下文切换 + 4 次数据拷贝
用户态 用户缓冲区
▲ │
┌──────────┘ └──────────┐ ← CPU 拷贝(2 次)
│ ▼
内核态 内核缓冲区 ────────▶ Socket 缓冲区
▲ │
│ DMA 拷贝 │ DMA 拷贝
磁盘 网卡
切换 1/2 包住 read,切换 3/4 包住 write零拷贝(zero-copy)做的就是砍掉中间那两次 CPU 拷贝与随之而来的上下文切换。它由一组把「用户态参与」从路径上拿掉的方案构成,并非某一个系统调用。

mmap:把文件映射进进程的虚拟地址空间,用户缓冲区与内核的页缓存指向同一批物理页,于是第 3 步那次拷贝消失。路径变成 mmap + write,4 次切换、3 次拷贝(来源:man 2 mmap)。

FileChannel fileChannel = new RandomAccessFile("test.txt", "rw").getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());它的关键特征是映射区可写:对映射区的改动会直接反映到页缓存,因此「先改内容再发送」这类需求只有 mmap 能满足(RocketMQ 用的就是它)。代价是映射建立与缺页处理本身有开销,且文件被截断时访问映射区会收到 SIGBUS。
sendfile:系统调用直接在内核空间把数据从一个 fd 送到另一个 fd,全程不进用户态,所以应用拿不到文件内容 —— 它适合纯转发。路径是 2 次切换、3 次拷贝:DMA 把数据从磁盘读进内核缓冲区,CPU 把内核缓冲区拷进 Socket 缓冲区,DMA 再送到网卡(来源:man 2 sendfile)。因为它不经过用户态,无法跨文件系统传输(Linux 上源 fd 必须是普通文件)。

FileChannel channel = FileChannel.open(Paths.get("./test.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
channel.transferTo(position, len, target); // 底层走 sendfileKafka 用的就是它。把 mmap 与 sendfile 并排看:mmap 换来了可写与随机访问,代价是多一次切换;sendfile 换来了最少的切换,代价是应用完全看不到数据。
**DMA(Direct Memory Access)**是这两条路径共同的底座(来源:Linux 内核文档 Documentation/core-api/dma-api.rst)。没有它,每一次 I/O 拷贝都要 CPU 逐字节搬,而搬数据并不需要 CPU 的判断能力 —— 把它交给专用控制器,CPU 才能腾出来做计算。
DMA 控制器承担四类传输:外设到内存、内存到外设、内存到内存、外设到外设。

一次传输的时序是:
一次 DMA 传输的四个阶段
① 请求 外设就绪 → 发 DREQ 给 DMA 控制器
DMA 控制器 → 发总线请求给 CPU
② 响应 CPU 在当前机器周期结束后释放总线
DMA 控制器接管总线,开始搬运
③ 搬运 占用至少一个总线周期(可用周期窃取:趁 CPU 不用总线时插空搬)
④ 完成 DMA 控制器释放总线 → 发 IRQ 给 CPU
CPU 保存现场、跳转到中断处理程序收尾传输由四个参数描述:源地址、目标地址、传输量、传输模式。模式有两种 —— 正常模式(DMA_Mode_Normal)只执行一次、完成即停,适合文件读写与单次采集;循环模式(DMA_Mode_Circular)完成一次后自动重置传输量寄存器重新开始,适合音频播放、实时采集这类连续流,好处是不必每次都由 CPU 重新发起。
「零拷贝」这个名字的准确含义:指的是消除 CPU 参与的冗余拷贝,DMA 那次搬运仍然存在。所以严格说它省的是 CPU 周期与上下文切换,而不是把拷贝次数降到 0。
10. 磁盘调度算法
10.1 磁盘结构与 FCFS、SSTF
机械盘的访问时间由三部分构成:寻道时间(磁头移动到目标磁道)、旋转延迟(等待目标扇区转到磁头下)、传输时间。寻道时间是三者里最大的一项,它由磁头移动的距离决定。磁盘调度算法要做的就是重排请求顺序,减少磁头来回移动的总距离。
下面用一组固定请求演示各算法。请求序列的磁道号是 98、183、37、122、14、124、65、67,磁头初始在第 53 磁道,磁盘磁道范围 0–199。
按到达顺序依次服务,不做任何重排。上例的顺序就是原序列,磁头移动总距离:
53→98→183→37→122→14→124→65→67
45+85+146+85+108+110+59+2 = 640 磁道
FCFS 公平,没有请求会被跳过,但请求分散时磁头来回大幅度移动,寻道开销大。
每次选离当前磁头最近的请求。上例的顺序与距离:
53→65→67→37→14→98→122→124→183
12+2+30+23+84+24+2+59 = 236 磁道
总距离从 640 降到 236。代价是饥饿:磁头在一小块区域来回移动时,远处的请求可能长期得不到服务。上例里如果后续不断有 53–67 附近的请求到达,183 这类远端请求就永远轮不到。
10.2 SCAN、C-SCAN 与 LOOK
SCAN(电梯算法)规定磁头沿一个方向移动时服务沿途所有请求,直到该方向没有请求(或到达最边缘)才反向。上例假设先向磁道号减小的方向走:
53→37→14→0→65→67→98→122→124→183
16+23+14+65+2+31+24+2+59 = 236 磁道
SCAN 不会饥饿。但它让中间磁道占便宜:磁头每次扫过都必须经过中间区域,中间段的请求被服务的频率高于两端。
C-SCAN(循环扫描)规定只在一个方向上服务请求,到达一端后快速回到另一端,回程不服务任何请求。上例先向磁道号增大的方向走:
53→65→67→98→122→124→183→199→(快速回 0)→14→37
回程(199→0)不服务请求,直接复位。C-SCAN 让各磁道的响应频率更平均,代价是回程这段移动被浪费。
SCAN 与 C-SCAN 都要移动到磁盘最边缘(0 或 199),但请求可能都集中在中段,这段到边缘的路白跑。LOOK 是 SCAN 的优化:磁头只移动到该方向上最远的请求就反向,不跑到物理边缘,回程照常服务。C-LOOK 是 C-SCAN 的优化:只走到最远请求就折返,回程不服务。
上例里请求的最左端是 14、最右端是 183,若用 LOOK,磁头到 14 就反向,不必走到 0;到 183 就反向,不必走到 199。五种算法的轨迹对照:
磁道号 0 ─────── 53 ──────── 98 ── 122 124 ────── 183 ── 199
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ FCFS 53→98→183→37→122→14→124→65→67 移动 640 磁道 │
│ SSTF 53→65→67→37→14→98→122→124→183 移动 236 磁道 │
│ SCAN 53→37→14→0→65→67→98→122→124→183 移动 236 磁道 │
│ C-SCAN 53→65→67→98→122→124→183→199 回 0→14→37 回程不服务 │
│ LOOK 53→37→14→65→67→98→122→124→183 不到物理边缘 │
└──────────────────────────────────────────────────────────┘10.3 SSD 上为什么失效与块层 I/O 调度器
SSD 没有磁头,也没有「寻道」这个概念。它内部由闪存芯片与控制器组成,随机访问的延迟与顺序访问的延迟处于同一量级,访问位置对时间的影响远小于机械盘。SCAN、SSTF 这类按磁道位置排序的算法在 SSD 上没有可优化的量——按位置排序不减少任何时间。
SSD 仍需要队列管理,但它关心的不再是寻道,而是请求合并(把相邻小请求拼成大请求)、读写公平性与延迟上限。这类机制在块层的 I/O 调度器里。
Linux 通用块层的请求队列上挂一个 I/O 调度器,可选 none、mq-deadline、bfq、kyber 四类(来源:Linux 内核文档 Documentation/block/switching-sched.html)。查看与切换:
cat /sys/block/nvme0n1/queue/scheduler
# [none] mq-deadline kyber bfq
echo mq-deadline > /sys/block/nvme0n1/queue/scheduler方括号标出当前生效的调度器。选择依据:
| 调度器 | 关注点 | 适合的设备与场景 |
|---|---|---|
none | 不做重排,请求直接下发 | NVMe 等多队列设备;虚拟机把调度交给物理机 |
mq-deadline | 给请求设截止时间,防止读写饿死 | 混合读写压力大的通用场景,多数发行版默认 |
bfq | 按进程分配 I/O 时间片,公平性优先 | 桌面交互、多进程竞争 I/O |
kyber | 以延迟为约束控制队列深度 | 高速设备(NVMe),想压住尾延迟 |
机械盘上用 none 会失去按位置排序的收益;NVMe 上用 mq-deadline 也不会更差,只是重排没有对应收益。现代内核对多队列设备默认倾向 none 或 mq-deadline。
11. 参数与观测
这一节按六要素(默认值 → 语义 → 什么时候改 → 改大改小的后果 → 联动 → 失败模式)逐条列出与本篇机制相关的可调参数。默认值取自内核源码与官方文档,随内核版本与发行版配置变化,以实际读数为准。
脏页后台回写阈值 /proc/sys/vm/dirty_background_ratio
- 默认值:10(来源:内核源码
mm/page-writeback.c的dirty_background_ratio = 10)。 - 语义:占「可用内存」(空闲页 + 可回收页,不等于总内存)的百分比。脏页超过它时后台 flusher 线程开始回写,写进程不被阻塞(来源:Linux 内核文档
Documentation/admin-guide/sysctl/vm.html)。 - 什么时候改:希望回写更早、更平滑时调小;希望攒批做顺序写时调大。
- 改大改小的后果:调小后回写更早、单次量小,延迟平稳但吞吐可能下降;调大能吸收更大的写突发,代价是回写启动时脏页已积压很多,一次刷出量大。
- 联动:
dirty_ratio(必须比它大)、dirty_expire_centisecs、dirty_writeback_centisecs。 - 失败模式:过于接近
dirty_ratio时,后台回写来不及压脏页,写进程频繁撞上同步回写,出现周期性卡顿。
脏页同步回写阈值 /proc/sys/vm/dirty_ratio
- 默认值:20(来源:内核源码
mm/page-writeback.c的vm_dirty_ratio = 20)。 - 语义:脏页占可用内存的百分比。达到它时,触发写脏页的进程自己必须参与同步回写,
write被阻塞到脏页降到阈值以下(来源:Linux 内核文档Documentation/admin-guide/sysctl/vm.html)。 - 什么时候改:写延迟出现秒级尖刺而设备并不忙时,先怀疑这个值过高,调小让回写提前。
- 改大改小的后果:调大能吸收更大的突发写,但断电丢数据窗口变大、回写停顿更长;调小延迟平稳,但持续写吞吐下降。
- 联动:
dirty_background_ratio、dirty_bytes(与dirty_ratio二选一,写一个另一个读作 0,来源:Linux 内核文档Documentation/admin-guide/sysctl/vm.html)。 - 失败模式:设得过高,一次大突发后回写瞬间打满磁盘;设得过低,进程频繁被拉进同步回写,写吞吐塌陷。
脏页最长驻留时间 /proc/sys/vm/dirty_expire_centisecs
- 默认值:3000,单位 1/100 秒,即 30 秒(来源:内核源码
mm/page-writeback.c的dirty_expire_interval = 30 * 100)。 - 语义:脏页在内存里停留超过这个时间后,回写线程下次醒来就有资格写出(来源:Linux 内核文档
Documentation/admin-guide/sysctl/vm.html)。 - 什么时候改:要求「脏数据不在内存里超过 N 秒」时调小。
- 改大改小的后果:调小后数据更早落盘、丢失窗口变小,但回写次数增多;调大攒批更充分,代价是数据滞留更久。
- 联动:
dirty_writeback_centisecs(决定这个时间最多被推迟多久)、dirty_ratio一族。 - 失败模式:唤醒间隔远大于驻留时间时,实际生效的是唤醒间隔,驻留时间这个阈值形同虚设。
回写线程唤醒间隔 /proc/sys/vm/dirty_writeback_centisecs
- 默认值:500,单位 1/100 秒,即 5 秒(来源:内核源码
mm/page-writeback.c的dirty_writeback_interval = 5 * 100)。 - 语义:内核 flusher 线程周期性唤醒的间隔,醒来检查有没有到期的脏页并写出(来源:Linux 内核文档
Documentation/admin-guide/sysctl/vm.html)。 - 什么时候改:要求脏数据尽快落盘时调小;想减少周期性唤醒开销时调大。
- 改大改小的后果:调小落盘更及时、唤醒开销略增;调大单次回写量更大,数据在内存里更久。
- 联动:
dirty_expire_centisecs、dirty_background_ratio。 - 失败模式:设得过大,脏页到期后仍要等下一个唤醒周期,实际持久化延迟被拉长。
块设备 I/O 调度器 /sys/block/<dev>/queue/scheduler
- 默认值:随设备与内核配置变化,
cat到的方括号项就是当前值(来源:Linux 内核文档Documentation/block/switching-sched.html)。 - 语义:选择该设备请求队列上挂的 I/O 调度器,可选
none、mq-deadline、bfq、kyber(来源:Linux 内核文档Documentation/block/switching-sched.html)。 - 什么时候改:NVMe 或虚拟机磁盘想压延迟改
none;桌面交互卡顿试bfq;数据库类混合读写试mq-deadline。 - 改大改小的后果:切到
none单进程顺序吞吐上升、多进程公平性下降;切回带调度的,延迟更稳但多一层队列处理。 - 联动:
/sys/block/<dev>/queue/nr_requests(队列深度)、设备的rotational属性、硬件多队列数。 - 失败模式:写不存在的调度器名返回
Invalid argument;机械盘上用none会失去按扇区排序的收益。
系统级文件句柄上限 /proc/sys/fs/file-max
- 默认值:内核按可用内存量级计算,开机时确定(来源:Linux 内核文档
Documentation/admin-guide/sysctl/fs.html,具体值以cat读数为准)。 - 语义:内核能分配的系统范围文件句柄上限。超过时内核日志出现
VFS: file-max limit <number> reached,系统调用返回ENFILE(来源:Linux 内核文档Documentation/admin-guide/sysctl/fs.html)。 - 什么时候改:服务端进程多、日志出现 file-max 上限告警时调大。
- 改大改小的后果:调大能容纳更多并发文件,但要配套留意内核内存,否则只是把上限推高;调小会提前触发
ENFILE。 - 联动:单进程上限
RLIMIT_NOFILE(ulimit -n,超出返回EMFILE)、/proc/sys/fs/file-nr(三个数依次是已分配句柄数、已分配未使用数、上限,来源:Linux 内核文档Documentation/admin-guide/sysctl/fs.html)、fs.nr_open。 - 失败模式:
Too many open files可能来自两个限制——进程撞RLIMIT_NOFILE(EMFILE),系统撞file-max(ENFILE)。先看/proc/sys/fs/file-nr的第一个数是否逼近上限,再查进程的ulimit -n。
12. 常见故障与排查
No space left on device,但 df 显示有空间
数据块用尽或 inode 用尽都会报这个错。
排查顺序:
df -i <挂载点>看 inode 使用率。inode 用尽而df -h还有空间,说明目录里有海量小文件,每个都占一个 inode;清理无用文件,或用mkfs.ext4 -i调整 inode 密度。df -h与du -sh对比。du远小于df的已用时,查被删除但仍被进程持有的文件:lsof +L1列出所有已删除但打开的句柄,ls -l /proc/<pid>/fd里带(deleted)的项指向它们。重启持有进程或让它close即可释放。- 预留块:
df把 5% 的预留块算作不可用,tune2fs -l <dev> | grep -i reserved看预留比例,tune2fs -m <n>调整。
判读原则:df -h 有空间而写失败先看 df -i;df -i 也有空间,再看 df 与 du 的差是不是被删除文件占着。
Too many open files
这个报错来自两处不同的限制:
- 单进程限制
RLIMIT_NOFILE:进程打开的文件数超过ulimit -n时报EMFILE。cat /proc/<pid>/limits | grep files看实际限制;服务端通常在 systemd unit 的LimitNOFILE里改。 - 系统限制
/proc/sys/fs/file-max:全系统句柄总数超限时报ENFILE,内核日志出现VFS: file-max limit ... reached(来源:Linux 内核文档Documentation/admin-guide/sysctl/fs.html)。
排查顺序:先 cat /proc/sys/fs/file-nr 看系统已分配数是否逼近上限,再 cat /proc/<pid>/limits 看进程限制,最后用 ls /proc/<pid>/fd | wc -l 数该进程打开了多少、是否单调增长(泄漏)。
判读原则:EMFILE 是进程级、ENFILE 是系统级。fd 泄漏的症状是单个进程 fd 数随时间单调增长,ls -l /proc/<pid>/fd | grep -c socket 能区分泄漏的是普通文件还是套接字。
fsync 慢导致写延迟尖刺
fsync 要等介质确认,它的耗时直接反映设备的同步写延迟。延迟尖刺的常见来源与定位:
- 回写队列积压:脏页已经攒了很多,一次
fsync要连带排在前面的脏页一起冲,耗时随积压量上升。看/proc/vmstat的nr_dirty与nr_writeback是否长期偏高;调小dirty_ratio、dirty_background_ratio让回写提前。 - 设备自身缓存策略:无掉电保护的盘靠缓存吸收写,
fsync强制下刷时延迟跳升;带掉电保护的写缓存能显著改善。看smartctl -a的写缓存状态。 - 文件系统日志切换频繁:频繁创建 / 删除大文件导致日志事务多,
fsync要多刷日志。
排查顺序:先统计应用侧 fsync 的 P99,再对同一时刻看 /proc/vmstat 的脏页指标与 iostat -x 的 await、aqu-sz,判断是回写积压还是设备本身慢。
判读原则:fsync 的延迟不是常数,它随当时的回写积压量变化。读到一个高 P99 先看当天脏页指标,再下「设备慢」的结论。
目录下文件过多导致 ls 卡住
一个目录里有几十万文件时,ls 要遍历整个目录、open 要查找,线性目录下退化成 O(n),表现为 ls 长时间不返回。
排查与处理:
ls -f <目录> | wc -l:-f跳过排序只数条目,比默认ls快,先确认规模。debugfs -R 'stat <dir-inode>' <dev>或dumpe2fs看目录用的是线性目录还是 htree 索引;ext4 默认启用dir_index,若被关掉会退化为线性。- 拆分目录:把文件按前缀分散到多层子目录,让每层的条目数降下来。
判读原则:ls 慢先看目录条目数量级与是否启用 htree;htree 已启用仍慢,是单层目录本身太大,要靠目录结构拆分解决。
df 与 du 不一致的排查路径
排查顺序:① lsof +L1 找已删除但仍被打开的文件(最常见,成本最低);② /proc/<pid>/fd 里带 (deleted) 的项;③ 挂载点重叠(du 把子挂载算进父目录,du -x 可避免,来源:man 1 du);④ 预留块与元数据(inode 表、日志、位图);⑤ 硬链接让同一 inode 被多个目录项引用,du 口径与直觉不同。
相关
- 00-系统专栏导览 —— 本专栏的入口、边界与阅读顺序
- 02-内核结构与设备管理 —— I/O 栈的下一段:从通用块层到设备
- 09-IO 多路复用与 Reactor —— 同一套 I/O 概念在网络方向上的用法
参考
- Linux Kernel Documentation. ext4 Disk Layout. https://www.kernel.org/doc/html/latest/filesystems/ext4/index.html
- Linux Kernel Documentation. Virtual File System. https://www.kernel.org/doc/html/latest/filesystems/vfs.html
- Linux Kernel Documentation. Filesystems in the Linux kernel. https://www.kernel.org/doc/html/latest/filesystems/index.html
- Linux Kernel Documentation. Filesystem sysctl. https://www.kernel.org/doc/html/latest/admin-guide/sysctl/fs.html
- Linux Kernel Documentation. vm sysctl. https://www.kernel.org/doc/html/latest/admin-guide/sysctl/vm.html
- Linux Kernel Documentation. Switching Scheduler. https://www.kernel.org/doc/html/latest/block/switching-sched.html
- Linux Kernel Source. mm/page-writeback.c. https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/mm/page-writeback.c
- Linux man-pages. open(2). https://man7.org/linux/man-pages/man2/open.2.html
- Linux man-pages. fsync(2). https://man7.org/linux/man-pages/man2/fsync.2.html
- Linux man-pages. du(1). https://man7.org/linux/man-pages/man1/du.1.html
- 小林coding. 图解系统 v1.0. https://xiaolincoding.com/os/
YJ