文件系统 入门

我们服务器把每个请求都写到 access.log。 那次 write() 调用骑着一摞:page cache、journal、 block device、物理介质 —— 每层加延迟、每层有自己的一致性规则。 5 个 section 把画面搭出来:解剖(inode、目录、文件抽象);page cache + 可交互的写路径走读;持久性 —— journaling、fsync、以及「已写」和「能扛断电」的区别; 具体的文件系统(ext4、XFS、ZFS、btrfs)和它们的取舍; 最后是速查表

01

文件系统解剖 —— inode、目录、block

「文件」不是基本概念。它是一个名字指向 inode 指向一堆磁盘 block。 每个文件系统操作都在重新排这 3 样东西。

任何 Unix 风格文件系统的最底层是 block —— 底下磁盘的定大小块 (通常 4 KB 匹配页大小)。之上是 inode:每文件的元数据结构 —— 大小、权限、时间戳、owner、指向装文件数据 block 的指针。 inode 之上,目录本身就是特殊 inode,其数据是 (name → inode-number)项的表。

inode 细看

传统 Unix inode 是 ~128 字节装着:

mode(文件类型 + 权限,比如 rwx)
uid、gid(owner)
size(字节)
atime、mtime、ctime(时间戳)
link count(几个目录条目指这)
直接 block 指针(文件前 12 个 block)
间接 block 指针(一个装更多 block 指针的 block)
双间接、三间接(给巨大文件)

4 KB block + 8 字节指针下,直接指针覆盖 48 KB;单间接加 2 MB;双间接加 1 GB; 三间接加 512 GB。现代文件系统(ext4、XFS)改用 extent —— (起点、长度)对描述连续段,所以大文件可能用几个 extent 表示, 而不是几千个指针。

路径解析

open("/var/log/access.log") 时,内核:

  1. 找根 inode(ext4 上约定是 inode 2)。
  2. 读根的目录数据、找 var 条目,inode 号 N。
  3. 读 inode N(确认是目录)。
  4. 读 inode N 的目录数据、找 log 条目,inode M。
  5. ……一直到最后的名字。

每步都可能是一次 inode 读 + 一次目录数据读。没缓存的话,打开深路径会很慢。 内核里的dentry cache(directory entry cache)缓存最近的 (路径分量 → inode)查找;inode cache 缓存最近的 inode 内容。 两者都住在 page cache 里,典型工作负载下,比数据缓存有效得多 —— 大部分路径查找零磁盘。

硬链接和软链接

硬链接就是另一个指向同一 inode 的目录条目。inode 的 link count 加 1; 归零时文件被释放。ln file1 file2 建硬链接; 你区分不出「原件」和「链接」。软链接(symlink)是特殊 inode,其数据是路径字符串;内核透明地跟随它。 Symlink 能跨文件系统、能指不存在的路径;硬链接不行。

文件描述符回顾

你进程里一个打开的 fd 是每进程文件描述符表的索引。 每条目指向一个内核 struct file,装着 inode、当前偏移、 open flag(O_RDONLY、O_APPEND)、位置。两个进程打开同一文件得到两个不同的struct file 但同一底层 inode(和同一数据)。 一个进程里两个 dup 出来的 fd 共享一个 struct file(因而一个偏移)。

要点。「文件系统是 3 层:block(原始 4 KB 块)、 inode(每文件元数据 + block 指针)、目录(把名字映射到 inode 号的特殊 inode)。 路径解析走目录树;dentry / inode 缓存让它便宜。 打开的 fd 就是每进程表的索引,条目指 struct file 指 inode。」

02

Page cache —— 每次读写实际去的地方

用户代码和磁盘之间坐着 page cache:一个把文件页缓存在 RAM 的内核结构。 它是任何 Linux 系统空闲内存的最大单一消费者,几乎每次读写都碰它而不碰磁盘。

Page cache 是内核存最近访问的文件数据的地方,按(inode、offset)索引。read 时,内核先查 page cache;页在那就拷到用户 buffer 立即返回。 不在就从磁盘抓进 page cache,再拷。write 时,内核拷进 page cache、 标页 dirty、返回 —— 实际磁盘写在后台异步发生。

它在内存哪儿

典型 Linux 系统上,page cache 拿走进程没在用的任何物理内存。free -h 在「buff/cache」下显示它;这个数按设计很大, 应用要内存时自动回收。冷启动服务器、跑工作负载,page cache 涨到填满 ~空闲 RAM。 这就是为啥 Linux 「老是要内存不够了」—— 它不是;cache 只是在干活。

write() 到磁盘 —— 真正发生什么、fsync 在哪Step 0 —— 用户调 write(fd, buf, n)用户进程⚠ volatile (lost on power-off)PAGE CACHE(内核 RAM)⚠ volatile (lost on power-off)JOURNAL(磁盘上的预写日志)⚠ volatile (lost on power-off)BLOCK 层(请求队列、调度器)⚠ volatile (lost on power-off)物理介质(NAND / 转盘)⚠ volatile (lost on power-off)
应用在用户态 buffer 里有字节,求内核把它们写到文件。标准 150 ns syscall 过渡。
1 / 8
write() 返回成功意思是「内核拿到你字节在 RAM 里了」,不是「在磁盘上」。 Page cache 和 journal 都是 volatile,直到 fsync 强制把它们挤出去。 这就是为啥「我写了」和「能扛断电」是两个不同的陈述,数据库执着于做对。 对非关键数据(日志、缓存)内核几秒后批写;对关键数据(数据库)每次 commit 后 fsync。

写不是你以为的那样

write() 成功意思是「数据在 page cache 里」。就这。 内核可能 5 秒后写到磁盘(默认 dirty_writeback_centisecs), 或者低内存压力下更晚。write() 和磁盘提交之间断电,数据就没了 —— 即便 write() 返回成功。

3 个 syscall 强制持久性:

  • fsync(fd) —— 把这个文件的所有 dirty 页和元数据刷到磁盘、 完成时返回。典型:NVMe 上 5–15 ms、转盘上 5–50 ms。
  • fdatasync(fd) —— 像 fsync 但跳过元数据, 如果文件大小和修改时间没变。略快。
  • sync() —— 给整个系统起 writeback;不等。 单文件不等版用 sync_file_range。

O_DIRECT —— 绕过 page cache

在打开的文件上设 O_DIRECT 告诉内核:不要 page cache、 直接从用户 buffer DMA 到设备。要求 buffer 对齐(通常 512 字节)、 用户 buffer 大小约束,而且你失去了 cache 好处 —— 每次读真的打磁盘。 数据库(Oracle、Postgres 配 direct_io)和文件系统 benchmark 用, 在应用比内核更知道留什么在内存里的场景下。

Readahead 和预取

内核注意到对文件的顺序读时,它推测式读 —— 取应用还没要的页。 把看起来 10 次单独的 read 调用转成一次大 I/O 操作, 显著改善吞吐。posix_fadvise 让你给内核提示 (POSIX_FADV_SEQUENTIAL、POSIX_FADV_RANDOM、POSIX_FADV_WILLNEED) 关于你的访问模式。

要点。「Page cache 把文件数据缓存在空闲 RAM 里。read 通常命中它;不命中就抓 + 缓存 + 拷。write 总是命中它 —— 实际磁盘写异步发生, 意思是 write() 返回意味着「在 RAM」不是「在磁盘」。 用 fsync 强制持久;O_DIRECT 绕过;posix_fadvise 给内核提示访问模式。」

03

持久性 —— journaling、fsync、crash 被允许干什么

断电是每个文件系统必须通过的测试:机器重启后,文件系统必须还有意义。 Journaling 就是现代文件系统不靠全盘 fsck 做到这点的方法。

没 journaling,多块写中间断电会让文件系统处于不一致状态 —— 一半元数据更新、一半没。boot 时的 fsck 得扫每个 inode 和每个块找不一致, 大盘上数分钟到数小时。现代文件系统用 journaling(预写日志)避免。

Journaling 怎么工作

Journal 是定大小的待处理事务循环日志。对文件系统本体做任何改动前,内核:

  1. 写一个begin-transaction标记到 journal。
  2. 把每个受影响 block 的新内容写到 journal。
  3. 写一个带 checksum 的 commit 标记,之后事务可持久恢复。
  4. 把改动应用到真实文件系统块。
  5. 把 journal entry 标完成。

Crash 恢复时,文件系统扫 journal:任何带 commit 标记的事务回放到文件系统块; 没的丢弃。恢复以 journal 大小为界(通常几 MB),所以是秒级不是小时级。

3 种 journaling 模式(ext4)

  • data=writeback —— 只 journal 元数据;数据可能乱序落。最快, 但 crash 能让新扩展的文件暴露陈旧数据。生产里很少用。
  • data=ordered(默认)—— 只 journal 元数据,但确保数据块在 元数据 commit 之前写。元数据从不引用未刷数据。速度和安全的好平衡。
  • data=journal —— 数据也过 journal。写翻倍,但提供最强 crash 语义。 有时被数据库或高完整性文件系统用。

fsync —— 唯一的持久性承诺

fsync(fd) 是 POSIX 里唯一保证调用前已提交的数据能扛过 crash 的东西。 内部它:

  1. 把文件所有 dirty 页刷到 block 层。
  2. 发硬件 write cache flush 给存储设备。
  3. 等设备 ack 完成。

步骤 2 重要。SSD 和 HDD 有 write cache,把数据留在设备 volatile DRAM 里直到设备决定提交。 裸写到设备可能返回成功而数据还 volatile。fsync 发一个 FLUSH_CACHE / FUA 命令, 强制设备在 ack 之前提交。

fsyncgate 和 Postgres 学到的

Postgres 2018 年有过一次著名的差点灾难(「fsyncgate」): fsync 失败时,内核可能从 page cache 丢掉 dirty 页不重试。 同 fd 上后续的 fsync 返回成功 —— 但数据没了。 Postgres 的 WAL 设计假设 fsync 失败可重试;不是。 行业内的修法:任何 fsync 失败当作 panic;重启进程,从 WAL 重建。

撕裂写和 POSIX 的诚实范围

POSIX 保证 ≤ PIPE_BUF(通常 512 字节)的 N 字节写是原子的。 超过的话,写能被拆 —— 包括中间 crash。数据库用 WAL 里全页写(Postgres) 或写时复制语义(ZFS、btrfs)处理。文件系统通常不承诺超过 POSIX 要求的东西。

要点。「Journaling 让 fsck 以 journal 大小为界、不以磁盘大小为界。 ext4 默认(data=ordered)只 journal 元数据但先写数据。fsync 是唯一承诺持久性的东西 —— 而且它必须成功; 失败时,把内核当作丢了数据(fsyncgate)。PIPE_BUF 以上的撕裂写合法; 数据库用全页写或 COW 补偿。」

04

具体文件系统 —— ext4、XFS、ZFS、btrfs

Linux 发行版默认 ext4 或 XFS。ZFS 和 btrfs 提供更简单文件系统给不了的功能 —— 快照、校验和、内置卷管理 —— 代价是复杂度。

ext4

大多数 Linux 发行版的默认。ext3 的接班人,带 extent、延迟分配、journal checksum。 成熟、调好、支持文件到 16 TB、文件系统到 1 EB。功能集保守 —— 没快照、没透明压缩。你想「一个文件系统」啥也不多问时拿的那个; 也是大多数 Docker 镜像跑在上面的。

XFS

原来是 SGI 的,现在是 Red Hat / Fedora 的默认。为非常大的文件和非常高的并行设计。 基于 extent、延迟分配、allocation group(每 CPU 并行分配)。 大文件工作负载上比 ext4 略快;不能缩(只能长);历史上没在线 defrag (近期版本加了)。RHEL 严肃存储的默认。

ZFS

原来是 Sun 的;Linux 上通过 OpenZFS 可用。区别在:写时复制(每次写都到新块,元数据指针翻是原子提交);每块强校验和(抓硬盘固件悄悄允许的 bit rot);O(1) 快照和 clone(就是元数据操作);内置卷管理(zpool 替代 LVM + 文件系统);透明压缩和去重

代价:RAM 用量高(ZFS 的 ARC 缓存跟 page cache 分开,想要很多内存 —— 1 TB 对 1 GB 是常见经验法则);Linux 上有 licensing 复杂 (CDDL vs GPL 意味着它在树外出货);比 ext4 调好得复杂。

btrfs

Linux 对 ZFS 的回答。写时复制、快照、校验和、压缩、子卷。在树内(GPL 干净)。 某些方面比 ZFS 更灵活(在线 resize、send/receive 复制), 历史上某些 RAID 配置下不太稳。Facebook 给它巨大的磁盘存储用; Fedora 33+ 和 openSUSE 的默认。

tmpfs 和专用

tmpfs 是 RAM 假扮成文件系统:写 /tmp, 数据只在 page cache 里、不碰磁盘。快,但 volatile。procfs(/proc)和 sysfs(/sys)是虚拟文件系统,内容由内核按需生成, 把进程和设备状态作为文件暴露。

选一个

  • 默认 Linux 机器 → ext4(或你发行版选的)。
  • 大文件 / 高并行服务器(数据库、备份目标)→ XFS。
  • 强完整性和运维功能(快照、校验和、send/receive 复制)→ ZFS 或 btrfs。 有 licensing 灵活性的环境用 ZFS、仅 GPL 的用 btrfs。
  • 容器镜像和瞬态数据 → overlayfs 里的 ext4。 Linux 内核的容器基础设施(overlayfs)层叠只读底 + 可写上, 通常都在 ext4 上。

要点。「ext4 是安全默认;XFS 是高并行 / 大文件选择。 ZFS 和 btrfs 加写时复制、快照、校验和,代价是复杂度和 RAM。 tmpfs 是文件系统形状的 RAM 盘;procfs 和 sysfs 是装扮成文件的内核状态 API。 大多数应用代码不在意跑在哪个上面,直到它在意。」

05

速查表

6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。

block、inode、目录的关系?

Block 是磁盘的原始定大小块(通常 4 KB)。Inode 是每文件元数据结构 (mode、owner、时间戳、大小、block 指针)。目录是特殊 inode,其数据是 (name → inode-number)条目表。路径解析走目录树; dentry / inode 缓存让重复查找便宜。

write() 实际干什么、数据什么时候持久?

write() 把字节从用户 buffer 拷到 page cache(内核 RAM)、返回。 数据还没到磁盘;write() 和最终 writeback 之间断电丢数据。 只有 fsync(fd) 保证持久性 —— 它把 dirty 页过 journal 刷到磁盘, 等设备 ack。典型 fsync 代价:NVMe 上 5–15 ms。

Journaling 是什么、它保护什么?

Journal 是磁盘上的循环预写日志。改文件系统块前,内核把改动(或 data=ordered 模式下只有元数据) 写到 journal,带 commit 标记。Crash 恢复时,带 commit 标记的 journal entry 回放; 未提交的丢弃。把 fsck 时间限到 journal 大小(秒)而不是磁盘大小(小时), 并防止 fsync 让文件系统处于不一致状态。

Page cache 是什么、有多大?

Page cache 是内核的内存缓存,装最近访问的文件数据,按(inode、offset)索引。 它长到填满进程没用的任何 RAM —— free -h 里以「buff/cache」可见。 典型服务器上,可能是总 RAM 的 50–80%。应用要内存时自动回收。

fsyncgate 是什么、你该从它拿到什么?

2018 年的披露(围绕 Postgres),Linux 内核会在 fsync 失败时丢掉 dirty 页、 然后对同一 fd 的后续 fsync 返回成功 —— 悄悄丢数据。 现代数据库现在把 fsync 失败当 panic-worthy:记录失败、终止进程、重启时回放 WAL。 总原则:fsync 错误意思是「内核不再能保证这个文件的任何东西」,不是「过会儿再试」。

什么时候选 XFS 而不是 ext4?

大文件(单文件几百 GB)、高并行(很多同时的写者)、或非常大目录的工作负载。 XFS 的 allocation group 让多核并行分配块不争用。ext4 对一般情况够用; XFS 的优势在规模上才有意义。对于快照、校验和、写时复制语义,两个都不行 —— 用 ZFS 或 btrfs。

Code review 红旗

  • 循环里 write() 不 fsync,声称持久。数据在 page cache,不在磁盘。 如果持久性重要,逻辑事务最后一次写之后 fsync。
  • 把 fsync 失败当瞬态。看 fsyncgate。当作「数据没了、重启进程」—— 内核可能已经丢了页。
  • 用 printf 日志不 flush。stdio 内部 buffer; 即便文件按行 buffered 打开,crash 也会丢最后几 KB 日志。 用无 buffer 的 stderr 或在关键点显式 flush。
  • 一个目录里存许多小文件。ext4 和 XFS 能处理, 但目录操作过 ~10 万文件后显著变慢。哈希到子目录 (aa/aabbcc-deadbeef),像 git 或任何大对象存储。
  • 假设 O_DIRECT 更快。没 page cache,你的读总打磁盘。 O_DIRECT 只在你有自己缓存且比内核更知道时更快。 否则默认 page cache 大幅赢。