Theory · OS · File System & Page Cache
VFS 四大对象 · inode · 硬链接与软链接 · ext4 日志 · write 成功 ≠ 数据落盘 —— 从 fd 到磁盘块的完整链路
VFS 用四个对象(superblock/inode/dentry/file)把 ext4/xfs/tmpfs 抹平成同一套 open/read/write
文件 = inode(元数据)+ 数据块;硬链接是"多个名字同一个 inode",软链接是"存路径的独立 inode"
Page Cache 让读写都先过内存——write 返回只进缓存,fsync 才真正落盘(MySQL redo log 的存在理由)
Why File Systems Matter
// 一个再普通不过的写日志函数 func onOrder(pay []byte) { n, err := f.Write(pay) if err != nil { return } // 一切正常 ack() // 已回客户端"成功" } // 30 秒后机房掉电。重启后: // 最后 30 秒的订单日志 —— 一条都没有。 // 而且没有任何一行代码报错。
write 的返回值只承诺"内核收下了",不承诺"磁盘写完了"。数据此刻躺在内存的 Page Cache 里,等后台线程延后落盘——掉电一来,内存里的东西全部归零。
如果没有 VFS 与 inode,每个程序都得自己算磁盘扇区、管空闲块、记哪个文件在哪些块上——换一块盘、换一种文件系统就要重写。VFS 把 ext4 / xfs / tmpfs / /proc 全部抹平成了同一套 open / read / write,程序只认路径。
文件名和文件内容必须分开存:名字(dentry)随便改、可以多个,内容(数据块)由 inode 统一管位置、权限、时间戳。正因为拆开了,重命名、硬链接、权限修改才成了"改一行元数据"的廉价操作。
磁盘比内存慢 5~6 个数量级。要么每次写都同步等盘(安全但慢到不能用),要么永远异步(快但会丢)。OS 的答案是给两个旋钮:默认 write-back 保性能,要安全就自己 fsync——面试问"write 成功数据在哪"就是问你知不知道这个旋钮。
先看清抽象层(VFS 四对象)→ 再走一遍寻址链(inode / 链接 / 目录)→ 再看磁盘侧怎么保证崩溃一致性(ext4 日志)→ 最后回到内存侧的缓存与持久化(Page Cache / fsync)——这条链走完,开头那个事故的原因与解法都会自己浮出来。
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 文件描述符 fd | 进程打开一个文件后拿到的整数编号,后续 read/write 全用它指代这个文件 |
| VFS 虚拟文件系统 | 内核里的统一接口层,让 ext4 / xfs / tmpfs 对上呈现同一套 open/read/write |
| inode 索引节点 | 一个文件的身份证:权限、大小、时间戳、数据块位置——唯独不含文件名 |
| dentry 目录项 | 路径里的"一段名字",作用是把名字映射到 inode |
| 目录 | 一种特殊文件,内容就是一张 dentry 表(名字 → inode 号) |
| 硬链接 / 软链接 | 硬链接 = 同一 inode 的多个名字;软链接 = 一个存着路径字符串的独立文件 |
| 元数据 metadata | "描述文件的数据":权限、属主、大小、时间戳——不是文件内容本身 |
| Page Cache 页缓存 | 文件数据在内存里的副本,读写都先过它,避免每次都访盘 |
| 脏页 / fsync | 脏页 = 被改过但还没落盘的缓存页;fsync = 强制把它刷到磁盘的系统调用 |
| 日志 journal | "先记下要做哪些修改,再去改"的崩溃恢复机制,与数据库 WAL 同源 |
先读这两篇再回来,本 deck 默认你已经知道它们:
操作系统总览 → 系统调用怎么从用户态进内核态
虚拟内存 → "页"是什么、mmap 与缺页是怎么回事
一个文件 = 名字(dentry)→ 身份证(inode)→ 财产(数据块)。删文件是摘名字,不是烧财产;改名是换名字,不动身份证;读文件是"按名字找到身份证、按身份证找到财产",而这一切的中间站是内存里的 Page Cache。
整条链路上只有一个地方会真丢数据:数据在内存(Page Cache)里、还没到盘上的那段窗口。后面 ext4 日志、fsync、目录 fsync,全部是在回答"怎么把这个窗口关掉或者缩短"。
Virtual File System
开场那个"write 成功却丢数据"的事故,要回答它必须先看清一次 write 到底穿过了哪几层——这就是 VFS 的层次图。
Inode Internals
文件类型与权限、属主 uid/gid、大小、时间戳(atime/mtime/ctime)、链接计数、数据块指针。注意两件"不在":文件名(在 dentry)和文件内容(在数据块)。
经典方案:15 个指针 = 12 直接 + 1 一级间接 + 1 二级间接 + 1 三级间接。直接指针管小文件(零间接访问),间接层按需扩容——大文件要经过多层查表。
ext4 改用 extent:一个条目记录"起始块 + 连续长度"——1 个条目描述 128MB 连续空间(4KB 块),大文件从"成千上万指针"变"几层区段树",既省 inode 空间又对顺序 I/O 极友好。
格式化时定死 inode 总量(mkfs -i 可调)。inode 耗尽 = 磁盘明明有空间却写不了文件(海量小文件场景),df -i 查看——运维经典事故。
| 方案 | 机制 | 特点 |
|---|---|---|
| 间接指针(ext2/3) | 12 直接 + 3 级间接 | 稳定可预测;大文件多层查表 |
| extent(ext4) | 连续区段 + 区段树 | 大文件友好、省元数据 |
| B-tree(xfs/btrfs) | 扩展 B 树 / CoW B 树 | 海量文件与快照能力强 |
stat file 能看到 inode 号、链接数、三个时间戳。ctime 是元数据变更时间(不是创建时间!),chmod 也会动它——高频混淆点。
Hard Link vs Symbolic Link
Directory · dentry cache
目录是一张特殊文件,内容是 dentry 列表:名字 → inode 号。所以"创建文件" = 目录里加一行 + 分配 inode;"删除" = 摘一行 + inode 链接计数减一。同一目录下名字唯一——就是这张表的键约束。
/a/b/c.txt 的解析:从根 / 的 inode 找到 a 的 dentry → 装入 a 的 inode 与内容 → 找 b → … 每一级都可能是一次磁盘读。真实系统 dcache(dentry cache)缓存"名字→inode"结果,热路径全内存命中。
目录里看到 1000 个文件 = 1000 行 dentry + 至多 1000 个 inode;若 999 个是硬链接,实际只有 1 份内容——文件数 ≠ 数据量,海量小文件的成本主要在元数据(inode/dentry)。
.. 不是魔法,是目录里的一行 dentry(指向父目录 inode)——这也是"目录不能建硬链接"的另一个理由:否则环上会画出多个自洽的父路径。
ext4 Layout · Journal
磁盘切成多个 块组(block group),每组自带一份元数据副本:超级块、块位图(哪些数据块可用)、inode 位图、inode 表、数据块——把元数据摊近数据,既分散热点又减少寻道。数据在 inode 邻近分配(预分配策略),顺序写更快。
写一个文件要改多处(inode、位图、目录、数据块)——掉电只写了一半,元数据就撕裂了(fsck 扫全盘也未必救回)。日志的思想:先把"要做哪些修改"记到 journal(顺序追加,快),再正式落盘;崩溃后重放日志即可恢复到一致点。
journal:元数据+数据都进日志(最安全,最慢);ordered(默认):只记元数据,但保证数据先于元数据落盘;writeback:只记元数据,数据随意(最快,崩溃后可能看到新元数据+旧数据)。与 MySQL redo log / WAL 是同一思想:顺序日志换随机写一致性。
Page Cache · Write-back
read() 先查 Page Cache(以 4KB 页为单位的文件缓存):命中直接拷给用户(不访盘);未命中读盘并预读(readahead,赌空间局部性——顺序读的吞吐法宝)。
write() 只写 Page Cache 就返回(页标脏),刷盘线程(per-BDI writeback)延后批量落盘——触发条件:脏页比例超 dirty_ratio(约 20%,开始节流写入者)、脏页老化超 dirty_expire_centisecs(默认 30s)、用户显式 sync。
① write 返回成功 ≠ 数据在盘上——掉电丢最多 30s 的写入,"写文件崩溃丢数据吗"的答案全在这;② 多进程读同一文件天然共享缓存(高效);③ mmap 写与 read 的视图一致(同一缓存),但与直接设备写有窗口期不一致。
自带缓存的应用(数据库)用 O_DIRECT 绕过 Page Cache,避免双重缓存与缓存污染——MySQL InnoDB 就是代表;普通应用老实用 Page Cache(它有预读与全局复用)。
| 控制点 | 默认 | 含义 |
|---|---|---|
| dirty_ratio | ~20% | 脏页超此比例,写入者被节流(同步落盘) |
| dirty_background_ratio | ~10% | 超过则唤醒后台刷盘线程 |
| dirty_expire_centisecs | 3000 | 脏页 30s 后必须落盘 |
| readahead | 128KB 起 | 顺序读预读窗口,可按文件调 |
free -h 的 buff/cache(Page Cache 大小)、cat /proc/meminfo | egrep 'Dirty|Writeback'(正在积累/正在刷的脏页)、iostat 的 w/s 与 wMB/s(落盘速率)。
Durability · fsync / O_DIRECT
① write:进 Page Cache 即返回——最快,掉电可能丢;② fsync(fd):该文件的脏页 + 元数据全部落盘;fdatasync:只刷数据与必要元数据(大小变了才刷)——省一次元数据写;③ sync():全系统脏页(运维用,应用别用);④ O_DIRECT + 自管缓存:完全绕开内核缓存,自己决定持久化。
新文件 write + fsync 数据 ≠ 安全:目录项还没落盘,掉电后文件整个消失。正确姿势:fsync 文件 → fsync 所在目录。SQLite/LevelDB 的文档都强调这一步——"持久化边界是整条元数据链"。
一次 fsync ≈ 一次磁盘寻道 + 刷写,HDD 毫秒级、SSD 也要百微秒级。高频小写入系统(消息队列、WAL)靠三招摊薄:组提交(攒一批一次 fsync)、顺序追加文件、journal 预留区——MySQL redo log 与 Kafka 的 log 就这么干。
// 正确的"安全写一个小文件" fd = open(path, O_WRONLY|O_CREAT, 0644); write(fd, data, len); fsync(fd); // 数据+该文件元数据落盘 close(fd); // 关键一步(经常被忘): dfd = open(dirpath, O_RDONLY|O_DIRECTORY); fsync(dfd); // 把目录项也落盘! close(dfd); // Go 对应: // f.Sync() → fsync // 目录 fsync 需要自开目录 fd
Tooling Cheat Sheet
| 命令 | 看什么 |
|---|---|
stat f | inode 号、链接数、三个时间戳 |
df -h / df -i | 容量 vs inode 余量(海量小文件事故) |
lsof | grep deleted | "已删除但仍被打开"的文件(空间不释放) |
du -sh * vs df -h | du 小 df 大 = 有 deleted-but-open 文件 |
/proc/meminfo: Cached/Dirty | Page Cache 与脏页水位 |
iostat -x 1 | 设备 util、w_await(落盘压力) |
lsof | grep deleted 找到后重启/截断该进程即可释放。原理正是"删名字不删 inode"。
① 写不了文件但 df 有空间 → df -i 看 inode 耗尽(海量小文件/坏目录);② 删了日志磁盘不释放 → lsof deleted(fd 引用);③ 写入毛刺 → 看 Dirty 水位(dirty_ratio 节流)与 iostat util(撞上 journal 刷盘)。
vmtouch file 显示文件有多少页在 Page Cache 里;vmtouch -t 预热(把热数据搬进缓存)——低延迟服务的启动预热手段。
单目录百万文件:dcache 压力 + 目录操作变慢(线性扫 dentry)。工程上限思维:分桶目录(hash 前缀两级),这也是图床/对象存储的通用布局。
Go × File I/O
os.Open/os.Create → 持有 fd 的 os.File;Read/Write 直发 read/write 系统调用(无内置缓冲!)。所以小数据高频写必须包 bufio.Writer(默认 4KB,把 N 次 write 合并成 1 次)——syscall 成本意识直接来自第 1 篇。
f.Sync() = fsync;目录 fsync 要自己 open 目录 fd 再 Sync;需要"追加且顺序"的日志型写入用 O_APPEND(原子追加,多进程安全)——Go 写 WAL/日志文件的标准组合。
标准库无 mmap,golang.org/x/exp/mmap 提供只读映射;随机访问大文件(索引文件、只读字典)mmap 省一次拷贝且享受 Page Cache——与下一篇 zero-copy 呼应。
os.File 的 Write 并发安全(内部锁),但多 goroutine 交错写会打乱逻辑顺序——协议类输出仍建议单写者 + channel 聚合(与 ipc deck 的 CSP 结论一致)。
// 高性能追加日志的标准骨架 f, _ := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) w := bufio.NewWriterSize(f, 64<<10) func onLog(line []byte) { w.Write(line) // 进 64KB 用户态缓冲 } func onFlush() { w.Flush() // 批量 write(一次 syscall) f.Sync() // 需要落盘时才 fsync } // 语义分层:bufio 管成本, // Sync 管安全——两者独立控制
Cheat Sheet
| superblock | 整个文件系统的全局信息(块大小、inode 总量) |
| inode | 一个文件的元数据 + 数据块位置 |
| dentry | 路径的一段,名字 → inode 的映射(可缓存) |
| file | 一次打开的实例,持有 offset 与模式 |
| 硬链接 | 同一 inode 的多个名字;不能跨 FS、不能指目录;删一个名字 links-1 |
| 软链接 | 独立 inode 存路径串;可跨 FS、可指目录;目标删除后悬空 |
| write | 只进 Page Cache 即返回,掉电可丢(默认最多 30s) |
| fdatasync | 刷数据 + 必需的元数据(大小变了才刷 inode) |
| fsync | 刷数据 + 该文件全部元数据 |
| O_DIRECT | 绕开 Page Cache,应用自管缓存(数据库专用) |
| 有空间却写不进 | df -i(inode 耗尽) |
| 删了日志空间不释放 | lsof | grep deleted(fd 还引用着) |
| 写入毛刺 / 卡顿 | /proc/meminfo 的 Dirty + iostat -x 1 |
Interview QA · Part 1
先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回速查页。
read(fd) → 进程 fd 表找到 file(offset)→ dcache/dentry 定位 inode → 先查 Page Cache:命中拷贝返回;未命中经块层读盘(带预读)→ 拷贝到用户缓冲。全程两次拷贝(盘→缓存→用户)。
inode 是文件元数据 + 数据块位置,文件名在 dentry。删除只摘 dentry,inode 链接计数减一;还有进程开着 fd 就不回收——lsof | grep deleted 找到元凶(日志未切割场景)。
硬链接:多个 dentry 指同一 inode(等价副本,不能跨文件系统/不能指目录);软链接:独立 inode 存路径字符串(可跨 FS/指目录,目标删除后悬空,多一次解析)。
目录树要求唯一父路径:目录硬链接可成环(a→b→a),find/遍历死循环、.. 语义崩坏。系统只保留 . 和 .. 两个受控"目录链接",用户级一律用软链接。
软链接只存路径(几十字节、独立 inode、目标变则内容"变");复制是完整新 inode + 新数据块(空间全量、与源解耦)。硬链接介于两者:零拷贝但真等价。
atime 读访问(Noatime 挂载可关以省性能)、mtime 内容修改、ctime inode 元数据变更(chmod/mv/链接数变化都触发)。没有"创建时间"字段(部分 FS 扩展有 birth time)。
Interview QA · Part 2
同样建议先自答。这一页的题都需要"结论 + 一句代价/边界"才完整——只答结论会被追问。
不一定:write 只进 Page Cache(脏页),后台线程延迟批量刷盘(比例触发/30s 老化)。要持久必须 fsync;要连目录项一起持久还要 fsync 目录——掉电窗口的来源。
fsync 刷数据 + 全部元数据(mtime、inode);fdatasync 只刷数据 + 持久化数据所必需的元数据(文件大小变了才刷)——追加写场景 fdatasync 明显省一次 inode 写,数据库 WAL 常用。
应用自带缓存(InnoDB Buffer Pool)时绕过 Page Cache:省内存双份、避免缓存污染、自己控制刷盘节奏。代价:放弃预读与全局复用,一般要对齐 I/O。普通应用别用——Page Cache 的预读很值钱。
内核检测到顺序访问模式就提前把后续页读进 Page Cache——把"每次读都等磁盘"变成"磁盘一直在跑"。这就是数据库强调顺序 I/O、日志型存储(LSM/Kafka)碾压随机写的物理原因。
一次文件写涉及多处元数据,崩溃会撕裂。日志先把修改意图顺序记下再执行,重放恢复一致点——WAL 思想。MySQL redo log 是同一思想在事务层的实现(区别:redo 面向页、带 LSN、配合两阶段提交)。
Page Cache 是"可回收内存"——内存紧张时自动让位(第 7 篇回收流水线的第一顺位),不需要人工清理。真要限:cgroup v2 的 memory.high 限业务进程,或 drop_caches 应急(生产慎用,之后冷启动更慢)。
Related & References
I/O 模型与 epoll →("等数据就绪"的另一半故事)
零拷贝 →(把本篇的两次拷贝再省掉)
虚拟内存 →(mmap/缺页是文件映射的地基)
内存布局 →(Page Cache 与用户堆共用物理内存)
三大日志与 crash-safe →(WAL 的数据库版)
Kafka 高吞吐 →(顺序写 + Page Cache + 零拷贝)
参考来源(本 deck 结论可溯源至下列一手材料)
| OSTEP ch.39–43(文件与目录 / 崩溃一致性) | inode、目录实现、日志(journaling)的理论框架 |
| CSAPP ch.10(系统级 I/O) | fd / file 表 / v-node 的层次与共享语义 |
| man 2 fsync / open(O_DIRECT)/ stat | 持久化语义、直读模式、元数据字段的一手定义 |
| ext4 wiki(kernel.org)+ xfs docs | extent、三种日志模式、块组布局 |
| MySQL 8.0 Manual(innodb_flush_method / doublewrite) | O_DIRECT 的数据库实践与崩溃一致性补强 |
| xiaolincoding.com《图解系统》file_system / pagecache | inode 全家桶与脏页丢失实验的中文叙述 |