第 03 章 文件系统:一切皆文件的野心与代价
Unix 有一个著名到被滥用的口号:“一切皆文件”(Everything is a file)。
它听起来优雅、简洁、哲学。但当你真的把磁盘、键盘、网卡、随机数发生器、甚至进程状态都塞进
read/write这两个动词里时,会发生什么?答案是:你会得到一个极其优雅的统一接口,和一堆藏在暗处的、要你命的语义差异。
引子:一个让你困惑 30 分钟的现象
先看一段 Go 代码,你猜它哪里会出问题:
f, _ := os.OpenFile("data.txt", os.O_CREATE|os.O_WRONLY, 0644)
f.Write([]byte("hello world"))
f.Close()
// 数据写进去了吗?写进去了。安全了吗?未必。
// 再看这个
os.Rename("data.tmp", "data.txt")
// 这个操作是原子的吗?是的。为什么?因为它没写数据,只改了个指针。两个操作,一个”看起来写了数据但可能没持久化”,一个”看起来只改了个名字但保证原子”。这背后是文件系统最核心的设计智慧,也是最多人栽跟头的地方。
这一章,我们拆开”文件”这个我们天天用、却很少有人真正理解的东西。
给你一句总纲:
文件系统的本质,是在”持久化存储”这个又慢又容易坏的物理介质之上,用层层缓存和抽象,伪装出一个”又快又可靠”的假象。
它靠三样东西维持这个假象:VFS(统一抽象)、page cache(用内存换速度)、日志(用冗余换可靠)。
而它的账单,就是这三个东西各自的裂缝——抽象泄漏、缓存丢失、日志开销。
本章路线图
第一幕:一切皆文件的真相
Q1 "一切皆文件"到底是什么意思?真的"一切"都是文件吗?
Q2 文件描述符是什么?它为什么是"整数的指针"?
第二幕:VFS —— 抽象层的抽象层
Q3 VFS 解决了什么问题?代价是什么?
Q4 inode、dentry、page cache,这三层分别管什么?
第三幕:链接 —— 文件名的真相
Q5 硬链接和软链接的本质区别是什么?
Q6 为什么"删除文件"其实是"减引用计数"?
第四幕:持久化 —— 最深的坑
Q7 fsync 到底保证什么?为什么不保证什么?
Q8 为什么文件写完了,掉电后变成 0 字节?
Q9 日志(journaling)的三种模式,差别在哪?
Q10 rename 为什么是原子的?怎么用它做安全更新?
第五幕:性能与陷阱
Q11 为什么 ls 一个大目录会卡住?
Q12 ext4 的"延迟分配"是怎么让你丢数据的?
第六幕:抽象的反噬
Q13 为什么网络设备不是文件?ioctl 这个"逃生舱"说明了什么?
Q14 这套设计面临哪些结构性的问题?
第一幕:一切皆文件的真相
Q1 “一切皆文件”到底是什么意思?真的”一切”都是文件吗?
先把神话戳破一半。
“一切皆文件”的准确含义,不是”万物都是文件”,而是:
所有资源的访问,都被抽象成同一种接口:
open/read/write/close。
也就是说,无论你面对的是磁盘、键盘、鼠标、网卡、打印机、随机数发生器,还是 /proc 里的进程信息、/dev 里的设备节点,你都用同一套系统调用来操作:
int fd = open("/dev/sda", O_RDONLY); // 打开硬盘设备
char buf[512];
read(fd, buf, 512); // 读 512 字节
int kbd = open("/dev/input/event0", O_RDONLY); // 打开键盘
read(kbd, &event, sizeof(event)); // 读一次按键
int rnd = open("/dev/urandom", O_RDONLY); // 打开随机数发生器
read(rnd, &seed, 8); // 读 8 字节随机数
FILE *f = fopen("/proc/self/status", "r"); // 打开进程自己的状态
// 读出来的是"名字:值"的文本这个统一的接口,好处是巨大的:
- 工具通用:
cat、grep、dd、hexdump这些工具,不需要为每种设备写一个版本。因为它们只认read/write。 - 组合能力强:管道把”一个程序的输出”接到”另一个程序的输入”,因为两边都是文件描述符,天然能接。
- 学习成本低:学会了
read/write,就学会了访问所有资源。
但代价同样巨大,而且藏得很深:
不同的”文件”,read 的语义完全不同:
read(磁盘文件) → 返回你要求的字节数(或者 EOF),可以 seek,有位置概念
read(键盘) → 返回"一次按键"的数据,没有"位置"概念,会阻塞
read(网络socket) → 返回"当前可读的字节数",可能比你要的少,可能阻塞
read(/dev/urandom) → 永远不阻塞,永远有数据
read(/proc/... ) → 每次读都是"实时生成的",没有持久化的"文件"存在抽象给你的是”统一的调用方式”,但没给你”统一的语义”。
这就是”抽象泄漏”的又一例:你以为所有文件都一样,其实它们只是”长得一样”,行为天差地别。
停下来想:这跟你的日常工作有什么关系?
你调用一个内部 RPC 接口,接口签名都是
func Call(ctx, req) (resp, err),看起来统一。但有的接口是同步的,有的内部是异步的;有的幂等,有的不幂等;有的能重试,有的重试会重复扣款。统一的签名 ≠ 统一的语义。 如果你假设”长得一样就行为一样”,就会踩坑。这正是”一切皆文件”给你上的课。
Q2 文件描述符是什么?它为什么是”整数的指针”?
open() 返回一个 int,比如 3。这个小小的整数,是 Unix 最精妙的设计之一。
它叫文件描述符(file descriptor,fd),本质是一个指向内核态结构的索引。
内核为每个进程维护一张表——文件描述符表。fd = 3 的意思就是”去这张表的第 3 项找”。
进程 A
┌─────────────────────────────┐
│ 文件描述符表(进程私有) │
│ 0 → 标准输入(终端) │
│ 1 → 标准输出(终端) │
│ 2 → 标准错误(终端) │
│ 3 → 指向"打开的文件"结构 ───┐ │
│ 4 → 指向另一个文件 ────────┐│ │
└───────────────────────────┼┼┘
││
┌───────────────────┘└──────────┐
↓ ↓
┌──────────────┐ ┌──────────────┐
│ 打开文件描述 │ │ 打开文件描述 │
│ (file struct)│ │ (file struct)│
│ 当前偏移量 │ │ 当前偏移量 │
│ 打开模式 │ │ 打开模式 │
└──────┬───────┘ └──────┬───────┘
│ │
↓ ↓
┌──────────────────────────────────────────────┐
│ inode(真正的文件,有引用计数,全系统共享) │
└──────────────────────────────────────────────┘
这个设计的精妙之处:
精妙一:fd 把”进程私有的状态”和”共享的文件实体”分开了。
注意中间那层 file struct——它存的是当前偏移量(你 read 到哪了)和打开模式(只读还是读写)。这层是每次 open 都新建一份的。
而最底层的 inode 是全系统共享一份的(不管多少人打开同一个文件,inode 只有一个,用引用计数管理)。
这意味着:
两个进程各自 open 同一个文件:
- 各自有自己的"偏移量"(file struct 各自一份)
- 但共享同一个 inode(真正的数据)
fork() 之后父子进程:
- 共享同一个 file struct(所以共享偏移量!)
- 这是父子进程能协作读写同一个文件的原因
精妙二:fd 是”能力(capability)“的雏形。
一个进程拿到 fd,就拥有了”对这个文件的操作能力”。你可以把 fd 通过 Unix socket 传给另一个进程(SCM_RIGHTS),这就是**“传递一个能力”**,而不是”传递一个名字”。
被传过去的进程,直接就有了操作这个文件的权限,不需要重新 open(也就绕过了路径权限检查)。
这个”能力”模型,后来在安全领域发扬光大:capability-based security。Docker 的权限管理、Kubernetes 的 ServiceAccount、甚至零信任网络里的”短时令牌”,都是这个思想的延伸。
“传递能力”比”传递凭证”更安全——因为能力是”对具体资源的操作权”,凭证是”证明你是谁”,后者的泄露面大得多。
精妙三:fd 让”重定向”变得无比简单。
# 把标准输出重定向到文件
./program > output.txt这个 > 做了什么?就是:
1. open("output.txt", O_WRONLY|O_CREAT)
2. 把 fd 1(标准输出)替换成这个新文件的 fd
3. program 里所有 write(1, ...) 都写到了 output.txt
因为程序只认”fd 1”,不关心 fd 1 背后是终端还是文件,所以重定向是零改动的。
精妙四:fd 是最小的整数 = 最高效的引用。
进程内引用一个文件,只需要 4 个字节(一个 int)。比存路径字符串、存对象指针都轻。内核里所有”文件”相关的操作,都通过这个 int 索引,快。
第二幕:VFS —— 抽象层的抽象层
Q3 VFS 解决了什么问题?代价是什么?
VFS(Virtual File System,虚拟文件系统)是 Linux 内核里最成功的一次抽象。
它解决的问题:
内核要支持多种文件系统——ext4、XFS、btrfs、NFS(网络)、FAT32(U盘)、procfs(虚拟)、tmpfs(内存)……如果每个文件系统调用都写一套专门的代码,内核会爆炸。
VFS 的做法:定义一套通用的接口(inode 操作、文件操作、目录操作),每个具体文件系统实现这套接口。
用户态
│ read() / write() / open() / close()
↓
┌─────────────────────────────────────────┐
│ VFS(通用层,不关心具体文件系统) │
│ - 管理 dentry cache、inode cache │
│ - 定义接口:read/write/readdir/lookup │
└─────────────────────────────────────────┘
│ 调用具体实现的函数指针
↓
┌───────┐ ┌───────┐ ┌───────┐ ┌────────┐
│ ext4 │ │ XFS │ │ btrfs │ │ NFS │
└───────┘ └───────┘ └───────┘ └────────┘
这就是经典的”面向接口编程”——VFS 是接口,ext4/XFS 是实现。跟你在 Go 里写 type Fs interface { Read(...) } 然后各种实现,是一模一样的思想。
VFS 的代价:
代价一:接口是”最小公分母”,表达能力受限。
VFS 的接口要能同时描述 ext4 和 NFS,那它就只能提供两者都有的能力。任何”某个文件系统特有”的能力,都进不了 VFS 的接口。
这导致了两个问题:
1. 功能损失:btrfs 的快照、ZFS 的克隆、XFS 的某些特性,
无法通过标准接口访问,只能通过 ioctl 或者专用工具
2. 语义被抹平:不同文件系统的 fsync 语义、原子性保证、性能特征
完全不同,但 VFS 接口上它们"看起来一样"
代价二:抽象层的性能开销。
VFS 不是零成本的。每次文件操作,要经过 VFS 层的函数指针跳转、锁、引用计数。虽然优化得很好,但在极端性能场景(比如数据库直接操作裸盘),VFS 这层就是”多余的”。
这就是为什么数据库(MySQL、PostgreSQL、RocksDB)的存储引擎,有的会绕过 VFS 直接 I/O(O_DIRECT),甚至绕过文件系统直接操作块设备(裸设备 raw device)。
当通用抽象成为瓶颈时,高手会绕过它。 但要清醒:绕过 VFS 意味着你放弃了 page cache 的便利(缓存、预读、批量写回),这些你都要自己实现。
Q4 inode、dentry、page cache,这三层分别管什么?
文件系统在内存里的数据结构,最重要的三个:
inode —— 文件的”本体”。
inode 存的是一个文件的元数据(metadata):
inode 包含:
- 文件类型(普通文件、目录、设备、软链接)
- 权限(rwx + owner + group)
- 大小、时间戳(atime/mtime/ctime)
- 指向数据块的指针(数据在哪)
- 引用计数(还有多少路径指向我)
关键:inode 里没有文件名。
文件名存在哪?存在目录里。目录本质上是一个”文件名 → inode 号”的映射表。
dentry —— 路径到 inode 的缓存。
dentry(directory entry)是路径查找的缓存。它把”路径字符串”和”inode”关联起来:
路径 /home/user/data.txt
→ dentry("/") → dentry("home") → dentry("user") → dentry("data.txt") → inode
dentry 是纯内存结构(不像 inode 会落盘),它存在的唯一目的就是加速路径解析——不用每次都从磁盘读目录。
dentry cache 就是路径解析的缓存,跟 TLB 是地址翻译的缓存,是同一个思想。
page cache —— 文件内容的缓存。
page cache 缓存的是文件的数据内容(不是元数据)。它是内核里最大、最重要的一块缓存。
读文件:
第一次 read → 磁盘读到 page cache → 从 page cache 拷贝给用户
第二次 read → 直接在 page cache 里,不碰磁盘
写文件:
write → 写到 page cache(标记为脏页)→ 返回
稍后 → 内核后台把脏页刷到磁盘(writeback)
这三层的关系,一句话:
inode 是”文件是谁”,dentry 是”文件叫什么(在哪个路径)“,page cache 是”文件的内容在哪(内存里)”。
为什么拆成三层?因为它们的生命周期和访问模式不同:
| 结构 | 生命周期 | 能回收吗 | 落盘吗 |
|---|---|---|---|
| inode | 有引用(被打开或有 dentry 指向)就活着 | 引用清零后回收 | 是(存在磁盘) |
| dentry | 有路径引用就活着,可被内存回收 | 可以(重新解析路径即可) | 否(纯内存) |
| page cache | 文件被访问就活着,可被内存回收 | 可以(脏页先写回) | 是(脏页写回) |
这个分层设计,是理解文件系统一切行为的基础。后面讲 fsync、ls 为什么慢、为什么文件会”假删除”,都会回到这三层。
第三幕:链接 —— 文件名的真相
Q5 硬链接和软链接的本质区别是什么?
这是面试高频题,但大多数人的答案停留在”软链接存路径,硬链接存 inode”。这没错,但没说到”为什么”。
先说事实:
硬链接(hard link):ln target link
- 多个名字指向同一个 inode
- 删除任何一个名字,文件还在(只要还有一个名字指向它)
- 不能跨文件系统(因为 inode 号是文件系统内部的)
- 不能链接目录(防止成环)
软链接(symbolic link):ln -s target link
- 一个特殊的"文件",内容存的是"目标路径"字符串
- 删除目标文件,软链接就"断"了(悬空链接)
- 可以跨文件系统
- 可以链接目录
本质区别:
硬链接是”同一个 inode 的两个名字”,软链接是”一个指向名字的名字”。
用房屋比喻(接上一章):
硬链接:一个房子(inode)有两个门牌号。你从哪个门进去,都是同一个房子。
拆掉一个门牌(删一个链接),房子还在,因为另一个门牌还在。
软链接:一个牌子写着"请去 XX 路 YY 号"。牌子本身不是房子,
你顺着牌子走,才到真正的房子。
房子拆了(删目标),牌子就成了"指向空地的牌子"(悬空链接)。
关键推论一:硬链接的”引用计数”。
inode 有个 link count 字段,记录”有多少个名字指向我”。
创建文件 → link count = 1
加硬链接 → link count = 2
删除一个名字 → link count = 1(文件还在)
删除最后一个名字 → link count = 0(文件真正被删除)
这就是”删除文件”的真正含义:不是”抹掉数据”,是”引用计数减一”。
关键推论二:软链接的悬空问题。
软链接不增加目标 inode 的引用计数。所以:
ln -s real.txt link.txt
rm real.txt
# link.txt 还在,但指向一个不存在的文件
# 读 link.txt 会报 ENOENT
关键推论三:软链接的”路径解析陷阱”。
软链接存的是路径字符串,这个路径是相对于”软链接所在目录”解析的,还是相对于”当前工作目录”解析的?这是个经典的坑。
ln -s ../data/config.json link_to_config
# 这个软链接存的字符串是 "../data/config.json"
# 解析时,是相对于软链接所在目录(而不是你的 cwd)解析的
# 所以:/app/conf/link_to_config → /app/data/config.json
# 而不是 相对于你 cwd 的 ../data/config.json
工程上,软链接的这些”微妙”是很多部署事故的来源。K8s 的 configmap 挂载、npm 的依赖链接、Python 的虚拟环境,都大量用软链接,也都踩过这些坑。
Q6 为什么”删除文件”其实是”减引用计数”?
这个问题引出一个非常深刻、也非常实用的知识点:“删除的文件”和”打开的文件”是两个状态。
// 这段代码演示了一个经典技巧:匿名临时文件
int fd = open("/tmp/tempfile", O_RDWR|O_CREAT);
unlink("/tmp/tempfile"); // 立刻删除文件名
// 但 fd 还开着,文件数据还在!直到 close(fd) 才真正删除发生了什么?
1. open() → inode 引用计数 +1(fd 打开着)
2. unlink() → link count 减到 0,但 inode 引用计数(fd)还是 1
3. 文件从目录里"消失"了(ls 看不到),但数据还在
4. close(fd) → 引用计数减到 0 → 文件真正被删除,磁盘空间释放
这个技巧的用途:
-
临时文件自动清理:程序崩溃时,fd 自动关闭,文件自动删除,不用手动清理。
-
进程间安全共享临时数据:A 进程创建文件,立刻 unlink,然后通过 fd 传给 B 进程。这个文件在文件系统里”不可见”,只有拿到 fd 的进程能访问。这是一种”基于能力的文件共享”,比”共享一个名字”更安全。
为什么”ls 看不到但空间没释放”?
这是运维最常见的问题之一:
$ df -h
/dev/sda1 100G 98G 2G 100% / ← 磁盘满了!
$ du -sh /*
... 加起来才 80G ← 但只找到 80G 的文件
# 少掉的 18G 去哪了?答案:被"删除了但仍被打开"的文件占着
诊断:
# 找出所有"已删除但仍被打开"的文件
lsof +L1
# 或者
lsof | grep deleted解决:
# 找到那个进程,重启它(或让它 close 那个 fd)
# 经典案例:日志文件被 logrotate 改名了,但进程还在往旧 fd 写
# 1. logrotate 把 app.log 改名为 app.log.1(rename)
# 2. 进程的 fd 还指向旧 inode,继续写 app.log.1
# 3. 磁盘满了,但 ls 看不到"大文件"(因为它叫 app.log.1,被忽略了)
# 4. 或者进程没重开文件,一直往已删除的 inode 写这就是”引用计数”这个设计带来的后果——它精确,但反直觉。
工程迁移:这个”引用计数 + 延迟释放”的模式,你在很多地方都会用到。
- 内存管理:引用计数 GC、
sync.WaitGroup- 连接管理:连接池里的”借出中”状态
- 对象生命周期:Go 里
Close()和finalizer- 文件锁:多个进程共享一个锁文件
核心思想都是:“可见性”(名字还在吗)和”存在性”(资源还在吗)分离。 名字没了,不代表资源没了;资源没了,才是真的没了。
第四幕:持久化 —— 最深的坑
Q7 fsync 到底保证什么?为什么不保证什么?
这是全章最重要的一问,也是数据库开发者、存储开发者必须彻底搞清楚的问题。
先说结论:
fsync(fd)保证:当它返回时,这个文件的所有脏数据(page cache 里的)都已经写到了物理磁盘的持久化介质上。
它保证什么(严格版):
- 数据确实离开了内存(page cache),写到了磁盘
- 返回后,即使立刻掉电,数据也在
- 数据的顺序:先
fsync的,一定先落盘
它不保证什么(这才是坑):
1. 不保证"到了磁盘"就真的"持久了"
- 现代磁盘/SSD 自己也有缓存(磁盘 write cache)
- 数据可能还在磁盘的 volatile cache 里,没到盘片/闪存
- 要彻底保证,需要"写穿"(write-through),或者用带电容保护的阵列卡
2. 不保证目录项
- 你 fsync 了文件,但文件的"名字"(目录项)可能还没落盘
- 新建文件:fsync(文件) 还要 fsync(所在目录),否则掉电后文件"消失"
3. 不保证父目录的完整
- 嵌套创建:mkdir("a"); mkdir("a/b");
- 要保证 a/b 存在,得依次 fsync a 和 a/b
4. 代价极其昂贵
- fsync 会强制把该文件的脏页全部刷盘,可能阻塞几毫秒到几十毫秒
- 对 HDD 尤其贵(要等待磁头归位、寻道)
为什么 fsync 这么贵?
因为 write() 只是把数据放进 page cache(内存),几十微秒就返回。而 fsync() 要等数据真正落到物理介质,这个过程受磁盘物理特性限制:
机械硬盘:一次 fsync 可能 10-100 毫秒(寻道 + 旋转延迟)
SATA SSD:一次 fsync 约 1-10 毫秒
NVMe SSD:一次 fsync 约 0.1-1 毫秒(取决于是否开了 write cache)
这就是为什么”write 很快,fsync 很慢”。
一个真实的工程教训:
很多”日志库”宣称自己”高性能”,压测时 QPS 很高。但你仔细看,它们默认不 fsync,只是 write 到 page cache 就返回。这意味着:
- 正常运行时:快,数据在内存里
- 掉电时:最近 30 秒的日志全丢(page cache 还没刷盘)
如果你的日志是”审计日志”(法律要求不能丢),或者”金融交易日志”(丢了就是事故),那必须 fsync,代价是 QPS 掉一个数量级。
这就是”性能”和”可靠性”的取舍,没有免费的午餐。
Q8 为什么文件写完了,掉电后变成 0 字节?
这是 fsync 问题的一个具体表现,非常经典。
场景:
// 场景一:覆盖写一个已存在的文件
fd = open("data.txt", O_WRONLY|O_TRUNC); // O_TRUNC 清空
write(fd, "hello", 5);
// 掉电!此时 data.txt 可能是 0 字节,也可能是旧的完整内容,也可能是 "hello"为什么会这样?涉及两个机制:
机制一:ext4 的”延迟分配”(delayed allocation)。
ext4(和 XFS)为了性能,用了”延迟分配”:
write("hello") 时:
1. 数据先写进 page cache(内存)
2. 但"数据块"还没分配!ext4 故意推迟分配磁盘块
→ 目的是:把多次小写合并成一次大的连续写,减少碎片
3. 只有到了"回写"(writeback)时,才真正分配磁盘块、写数据
所以 write 返回后,“hello” 只存在于 page cache,磁盘上可能连数据块都还没分配。
机制二:元数据(文件大小)的落盘时机。
文件大小(inode 里的 size 字段)也是元数据,它也在内存里,要等 inode 落盘才更新。
如果掉电时:
- 数据块分配了、写入了,但 inode 的 size 字段没更新 → 文件大小是旧的(可能是 0)
- 或者数据块根本没分配 → 文件是 0 字节
所以”write 完掉电变 0 字节”的根本原因:
write()只保证”数据到了内存”,不保证”数据到了磁盘”,更不保证”元数据(大小、块分配)到了磁盘”。
怎么避免?
// 正确做法
fd = open("data.txt", O_WRONLY|O_TRUNC);
write(fd, "hello", 5);
fsync(fd); // 1. 刷数据 + 刷 inode 的 size
fsync(dir_fd); // 2. 如果是新建文件,还要刷目录项
close(fd);注意第 2 步容易漏:如果是新建文件,光 fsync 文件不够,还要 fsync 父目录,否则目录项(“data.txt → inode X”这条映射)没落盘,掉电后目录里根本没有这个文件。
这在数据库里是常识(PostgreSQL、RocksDB 的 WAL 落盘都严格处理了目录 fsync),但普通业务代码里 99% 的人不知道。
Q9 日志(journaling)的三种模式,差别在哪?
上一问说到掉电会丢数据。那文件系统怎么保证”掉电后文件系统不损坏”?
答案是日志(journaling)。它的核心思想我们会在第 14 章详细讲(WAL),这里先讲文件系统层面的实现。
日志解决的不是”丢数据”,是”文件系统结构损坏”。
掉电时最怕的不是丢几个字节,是文件系统的元数据结构损坏——比如一个 inode 说”我用了块 100”,但块 100 又被标记为空闲,下次分配给另一个文件,两个文件撞车,整个文件系统乱掉。
日志的做法:先写日志(intent),再改实际数据,最后标记日志完成。
正常流程:
1. 写日志:"我要把 inode 5 的块指针从 A 改成 B"
2. 改实际数据:inode 5 的块指针 = B
3. 标记日志完成
掉电恢复:
- 如果日志里有一条"未完成"的记录 → 重放它(redo)
- 如果日志记录已完成,但数据没写 → 无所谓,下次覆盖
ext4 有三种日志模式(关键区别在”数据”进不进日志):
模式 1:journal(数据+元数据都进日志)
写数据 → 先写进日志 → 再写到实际位置
最安全,最慢(数据写两遍)
掉电:数据和元数据都不丢
模式 2:ordered(默认,只有元数据进日志)
写数据 → 直接写到实际位置
写元数据 → 先写进日志 → 再写到实际位置
保证:元数据不会损坏;数据在元数据之前落盘
掉电:可能丢"最后一个写操作的数据",但文件系统结构完整
模式 3:writeback(只有元数据进日志,且不保证顺序)
写数据和元数据都直接写,不保证顺序
最快,最不安全
掉电:可能元数据指向了"还没写完的数据块"(数据损坏)
关键理解 ordered 模式(默认):
它保证的是一件很微妙的事:“数据一定在元数据之前落盘”。
为什么这个重要?因为它保证了:文件系统不会出现”元数据说文件有 5 字节,但数据块还是旧的”这种不一致。
ordered 模式下的掉电,最坏情况是:文件大小没更新(还是旧的大小),但你写的数据可能部分或全部丢失。文件系统结构是完整的,fsck 不会报错,只是”你以为写了的数据没写进去”。
这就是为什么 Q8 说”掉电变 0 字节”——在 ordered 模式下,元数据(size)和数据都可能在 page cache 里没落盘,掉电就丢了。日志保护的是”文件系统不崩”,不是”你的数据不丢”。
要保证你的数据不丢,只能靠应用层 fsync。 文件系统的日志帮不了你。
Q10 rename 为什么是原子的?怎么用它做安全更新?
rename(old, new) 是文件系统里为数不多的原子操作。
为什么它是原子的?
因为 rename 不搬动数据,它只是改两个目录项:
rename("data.tmp", "data.txt") 做的事:
1. 把目录里的 "data.tmp → inode X" 改成 "data.txt → inode X"
2. 把原来的 "data.txt → inode Y" 移除
整个过程是一个元数据操作,要么成功要么失败,不存在中间状态。
这有什么价值?答案是:原子替换文件。
这是配置热更新、滚动发布、数据落盘的标准技巧:
// 安全更新一个文件的正确姿势
func safeWrite(path string, data []byte) error {
tmp := path + ".tmp"
// 1. 写临时文件
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil { return err }
if _, err := f.Write(data); err != nil { return err }
if err := f.Sync(); err != nil { return err } // 2. fsync 数据
if err := f.Close(); err != nil { return err }
// 3. 原子替换
if err := os.Rename(tmp, path); err != nil { return err }
// 4. fsync 目录(保证 rename 本身也落盘)
dir, _ := os.Open(filepath.Dir(path))
defer dir.Close()
dir.Sync()
return nil
}为什么这样写?每个步骤都在防什么:
1. 写临时文件(.tmp):如果写一半崩溃,原文件还在(.tmp 是半成品)
2. fsync 临时文件:保证临时文件数据完整落盘
3. rename 原子替换:一瞬间,路径从旧文件切到新文件
→ 任何时刻读 path 的进程,要么读到完整的旧文件,要么读到完整的新文件
→ 绝不可能读到"写了一半"的状态
4. fsync 目录:保证 rename 这个操作本身也落盘(否则掉电后 rename 丢失)
这个模式的巨大价值:
1. 读者永远读到完整文件:没有"读到一半"的状态
2. 写者崩溃不影响读者:最坏情况是 .tmp 残留,path 还是旧的
3. 多进程安全:多个进程同时更新,rename 的原子性保证 path 始终指向完整文件
这个模式在业界的应用:
- 配置热更新:写新配置到
.tmp,rename 替换,进程 watch 到变化重新加载 - K8s ConfigMap 挂载:kubelet 就是用”写 tmp + rename”来更新挂载的配置文件的
- 数据库数据文件:很多存储引擎用”写新文件 + rename”来切换数据文件
- 滚动发布:新版本二进制写到
.new,rename 替换,重启进程
一个非常深刻的类比:这个”写临时 + 原子改名”的模式,和数据库事务的”提交”是同一个思想。
数据库事务:先写 WAL 日志,最后 commit 时原子地更新状态。 文件更新:先写 .tmp,最后 rename 原子地切换。
核心都是:准备阶段随便失败(不影响已发布状态),提交阶段原子完成(要么旧要么新,没有中间态)。
这个”准备 + 原子提交”的模式,是分布式系统里无数设计的原型(两阶段提交、Git 的 commit、甚至 HTTP 的 201 Created + Location)。第 14、18 章会反复出现。
第五幕:性能与陷阱
Q11 为什么 ls 一个大目录会卡住?
你肯定遇到过:ls 一个有几万、几十万个文件的目录,卡好几秒甚至更久。
根本原因:目录是”线性存储”的,没有索引。
在 ext4 里,目录是一个特殊文件,内容是一串”目录项”(dentry entry),线性排列,没有 B 树、没有哈希索引:
目录文件的内容(简化):
┌─────────────────┐
│ "file1" → inode 100 │
│ "file2" → inode 101 │
│ "file3" → inode 102 │
│ ... │
│ "file50000" → inode 50000 │
└─────────────────┘
ls 要列出所有文件,就得从头到尾读这个目录,O(n)。
而且如果每个目录项是 256 字节,5 万个文件就是 12.5MB,从磁盘读进来(如果没缓存)+ 逐个解析,几秒钟很正常。
对比:为什么”按文件名找单个文件”不慢?
因为查找单个文件时,dentry cache 会帮忙。你第一次 open("/big/dir/file123") 可能慢,但之后 dentry 缓存了,就快了。
但 ls 是”列出全部”,dentry cache 帮不上忙——它还是得遍历整个目录。
更糟的情况:inode 元数据的随机读。
ls -l 不仅要读目录,还要读每个文件的 inode(为了拿大小、权限、时间戳)。
5 万个文件的 inode 分散在磁盘各处,ls -l 就是 5 万次随机读。机械硬盘上,每次随机读 10ms,就是 500 秒(8 分钟)。SSD 好很多,但也明显慢。
这个问题的深层原因:目录不是”索引”,是”文件”。
现代文件系统(ext4 有 htree 目录索引,btrfs/ZFS 有 B 树)其实做了改进。ext4 默认开启
dir_index,用哈希索引加速”单个文件查找”,但**“列出全部”仍然是 O(n)**,因为哈希索引不保序。
工程启示(这个很重要):
1. 别在一个目录放几十万个文件
→ 分层:/data/2026/08/30/xxx,按时间或哈希分片
→ 这是对象存储(S3)、日志系统、图片服务的标准做法
2. 这和"数据库单表太大"是同一个问题
→ 目录 = 一张没有索引的表,文件 = 行
→ 目录太大 = 全表扫描
→ 解决 = 分区(分层目录)/ 分表
3. 如果你必须处理海量文件,用专门的工具
→ find、ls 在这种目录上都很慢
→ 考虑用 ext4 的 dir_index、或者换 btrfs
→ 更根本的:换存储模型(对象存储、数据库)
Q12 ext4 的”延迟分配”是怎么让你丢数据的?
这个问题和 Q8 呼应,但更具体到 ext4 的一个著名 bug/特性。
延迟分配(delayed allocation)回顾:
write() 到文件:
1. 数据进 page cache(内存)
2. 不分配磁盘块(推迟!)
3. 等 writeback 时才分配磁盘块 + 写数据
好处:多次小写合并成一次连续大块写,减少磁盘碎片,提升性能
坏处:write() 返回后,数据"还在内存里,磁盘上没块"
著名的”ext4 延迟分配丢数据”事件(2009 年):
场景:一个程序这样写文件:
// 错误但常见的写法
fd = open("file", O_WRONLY|O_CREAT);
write(fd, data, size); // 数据进了 page cache,但块没分配
close(fd); // close 不会自动 fsync!
// 程序退出,以为数据安全了
// 掉电 → 数据块没分配 → 文件是 0 字节问题的核心:close() 不保证数据落盘,而延迟分配让”数据还没落盘”的情况更普遍。
在旧的 ext3 上,同样代码可能”侥幸”数据还在(因为 ext3 分配块更早)。而 ext4 的延迟分配,让”write 到落盘”的窗口更长,丢数据的概率更高。
这个事件引发的反思和修复:
-
修复一:ext4 后来加了保护——如果一个文件有”延迟分配但未落盘”的数据,
rename/unlink时会先强制刷盘。 -
修复二:业界重新强调了”写入者必须自己 fsync”的原则。
-
反思:这是”性能优化引入语义变化”的经典案例。延迟分配是纯性能优化,但它改变了”write 的持久化语义”(虽然文档上 write 从来就不保证持久化,但大家默认 ext3 的行为)。
这是一个极其重要的教训,值得反复咀嚼:
“优化”如果改变了行为语义,哪怕文档里写了”行为未定义”,也会造成真实的故障。
因为用户(程序员)依赖的不是文档,是”实际观察到的行为”。ext3 上
write+close掉电不丢(大多数时候),ext4 上丢了,用户就会认为是”ext4 的 bug”。在你的系统里也一样:
- 你优化了缓存,让”读”变成”最终一致”,那些依赖”强一致读”的调用方就会出 bug
- 你优化了写,让”写成功”变成”异步落盘”,那些依赖”同步”的调用方就会丢数据
- 你加了重试,让”最多一次”变成”至少一次”,那些没做幂等的调用方就会重复扣款
优化前,先想清楚:这个优化改变了什么语义?谁依赖旧语义?
第六幕:抽象的反噬
Q13 为什么网络设备不是文件?ioctl 这个”逃生舱”说明了什么?
如果”一切皆文件”,那网络(socket)为什么不是文件?
答案:socket 是文件(有 fd),但它的操作和普通文件差异太大,read/write 这套接口不够用。
普通文件的操作:
open / read / write / close / seek(定位)
socket 需要的操作:
socket(创建,指定协议族)/ bind / listen / accept / connect
sendto / recvfrom(带地址)
setsockopt(设置超时、缓冲区、TCP_NODELAY...)
...
socket() 返回的确实是 fd,你也能对它 read/write。但建立连接、绑定地址、设置选项这些操作,read/write 表达不了。
所以有了 ioctl(input/output control)——一个”万能系统调用”,给那些”统一接口表达不了”的操作开的后门:
ioctl(fd, request, ...) // request 是操作码,参数是变长的
// 比如:
ioctl(fd, FIONREAD, &n); // 查还有多少字节可读
ioctl(fd, TIOCGWINSZ, &size); // 查终端窗口大小
ioctl(fd, BLKGETSIZE64, &size); // 查块设备大小ioctl 的问题(这也是它被骂的原因):
- 类型不安全:
request是个 int 操作码,参数是void*,传错类型编译器不报错,运行时崩。 - 无文档化:成千上万个操作码,分散在各处,没有统一文档。
- 破坏了抽象:本来说好”一切皆 read/write”,现在又开了个”什么都能干”的后门。
Linus Torvalds 对 ioctl 的评价是”设计上的败笔”,他认为统一接口的价值被 ioctl 这个”万能逃生舱”破坏了。
这个设计给你的启示(比技术本身重要):
抽象的价值,取决于它”漏不漏”。
一个好的抽象,应该覆盖 95% 的场景,剩下 5% 有明确的、可控的逃生口。
一个坏的抽象,是”看起来统一,但到处开洞”,结果你还是要理解底层,抽象反而成了负担。
判断标准:你的抽象下,调用方是否需要”知道抽象背后是什么”才能正确使用?
- 如果不需要 → 好抽象(比如 Go 的
io.Reader)- 如果需要 → 抽象泄漏了(比如 socket 的
setsockopt,你必须知道 TCP 才能设对TCP_NODELAY)
Q14 这套设计面临哪些结构性的问题?
最后泼冷水。文件系统这套设计,有几个根子上的矛盾:
问题一:fsync 困境(性能 vs 可靠性的死结)。
要快:write 到 page cache 就返回(数据在内存,掉电丢)
要可靠:每次 write 都 fsync(数据落盘,但慢 100-1000 倍)
没有中间状态。你要么快,要么可靠,不能兼得。
这是存储系统最根本的矛盾。所有数据库、消息队列、存储引擎,都在这个矛盾上做文章:
- PostgreSQL:默认 write + 周期 fsync,靠 WAL 减少 fsync 次数
- MySQL:
innodb_flush_log_at_trx_commit参数,1=每次 fsync(安全慢),0/2=不 fsync(快但丢) - Redis:AOF 有 appendfsync always/everysec/no 三档
没有银弹,只有权衡。
问题二:page cache 的”双重缓存”问题。
你的应用(比如数据库)自己有一套缓存(buffer pool),内核又有一层 page cache。两层缓存,数据存了两份,还可能不一致。
数据库 buffer pool(应用层缓存)
↓ 落盘
文件系统的 page cache(内核缓存)
↓ 落盘
磁盘
问题:
1. 内存浪费:同一份数据,buffer pool 一份 + page cache 一份
2. 一致性:数据库以为落盘了,其实还在 page cache(没 fsync)
3. 性能:写要经过两层,读也可能重复缓存
解法是 O_DIRECT(绕过 page cache):
open("data", O_RDWR|O_DIRECT); // 直接 I/O,绕过 page cache但代价是:你放弃了 page cache 的预读、合并写、缓存命中。这些你都要自己实现。所以 O_DIRECT 是”专业用户”的工具,普通程序用它反而更慢。
问题三:文件系统 vs 数据库,谁该管数据。
这是个哲学问题:文件系统本质是一个”通用、但简单的数据库”(存储 + 索引 + 事务),而数据库是一个”专用、但复杂的存储引擎”。
文件系统:通用、简单、性能一般、功能有限
数据库:专用、复杂、性能好、功能强(事务、索引、查询)
什么时候用文件系统?数据是"文件"(文档、图片、日志、代码)
什么时候用数据库?数据是"记录"(有结构、要查询、要事务)
问题四:POSIX 语义在现代硬件上的过时。
POSIX 文件 API 是 1970 年代设计的,面向”慢速机械硬盘 + 单核 CPU”。现代是”NVMe SSD + 多核 + 海量并发”。
几个过时的点:
1. read/write 是同步、拷贝式的:每次都要用户态/内核态拷贝
→ 现代用 mmap、sendfile、io_uring(第 10 章)
2. 目录是线性的:几十万文件就卡
→ 现代文件系统加了 B 树,但 POSIX 语义没跟上
3. 单文件锁(flock/fcntl)粒度太粗:锁整个文件
→ 现代需要字节级锁、或者直接上数据库
4. 元数据操作慢:stat、open 每次都要查 dentry/inode
→ 这是为什么数据库要缓存元数据、为什么"冷启动"慢
问题五:抽象的”层次税”(layer tax)。
每一层抽象(应用 → VFS → 文件系统 → 块设备 → 磁盘)都抽一点税:
应用要读 4KB:
1. 应用层:函数调用
2. VFS 层:查找 dentry、inode、加锁
3. 文件系统层:查块映射、分配、日志
4. 块设备层:合并 I/O、调度
5. 磁盘:真正读
每一层都有开销。对 99% 的场景,这些开销值得(换来通用性)。
但对极致性能场景(数据库、对象存储),这些开销是"税"。
所以高手的选择从来不是”用文件系统”还是”不用文件系统”,而是”我在哪一层工作”。
- 普通业务:用文件系统(省心)
- 高性能存储:绕过 page cache(O_DIRECT)
- 极致性能:绕过文件系统,直接操作块设备(RocksDB 的某些模式)
- 数据库内核:自己实现存储引擎,文件系统只是”块分配器”
每一层抽象都是”用通用性换性能”,你越往下钻,越要自己承担上层帮你的东西。
思考题
思考题 1
你的服务要写一个”关键数据文件”,要求:任何时刻(包括崩溃、掉电),读取这个文件的人要么读到完整的旧数据,要么读到完整的新数据,绝不能读到一半。请设计完整的写入方案,并说明每一步在防什么。
参考答案
这是 Q10 的完整展开。方案就是”写临时文件 + fsync + 原子 rename + fsync 目录”。
完整方案(Go):
package main
import (
"os"
"path/filepath"
)
// SafeWriteFile 原子地写入文件:保证读者永远读到完整内容
func SafeWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
tmp := filepath.Join(dir, ".tmp-"+filepath.Base(path))
// 第一步:写临时文件(用 O_EXCL 防止并发冲突)
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
return err
}
// 第二步:写入全部数据
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
// 第三步:fsync 数据(确保数据真正落盘)
// 关键!没有这步,掉电后临时文件可能是空的
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
// 第四步:原子替换(rename 是原子的)
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
// 第五步:fsync 目录(确保 rename 本身落盘)
// 关键!没有这步,掉电后 rename 可能"回滚",path 指向旧文件或不存在
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}每一步在防什么:
| 步骤 | 防什么 | 缺了会怎样 |
|---|---|---|
| 写临时文件 | 写一半崩溃污染正式文件 | 正式文件被写了一半,读者读到半成品 |
f.Write 完整写入 | 数据不全 | 同上 |
f.Sync | 数据只在内核 page cache,掉电丢失 | 临时文件是空的/半成品,rename 后正式文件损坏 |
f.Close | fd 泄漏、资源不释放 | 轻微,但应该关闭 |
os.Rename | 新旧切换的中间态 | 不用 rename 而是直接写,就有”写一半”的窗口 |
d.Sync | rename 操作本身没落盘 | 掉电后 rename 丢失,path 指向旧文件(可能还是半成品) |
为什么 rename 能保证原子性?
因为 rename 是内核里的单个元数据操作,它在文件系统层面要么完成要么不完成,不存在”做了一半”的状态(这是日志文件系统保证的)。读者在任何时刻看到的 path,要么指向旧 inode,要么指向新 inode。
一个容易忽略的细节:并发写。
如果两个 goroutine/进程同时写同一个文件,上面的方案会冲突(O_EXCL 保证临时文件名不冲突,但两个都 rename 到同一个 path,最后一个 wins)。如果业务上需要,要加文件锁或应用层锁。
更进一步的追问:如果读者也要”读到最新”,怎么办?
上面的方案只保证”读到完整”,不保证”读到最新”。读者可能读到旧数据(因为 rename 之前读者已经打开了旧文件)。
如果需要”读写一致”,要引入:
- 版本号:文件名带版本
data.v1、data.v2,读者通过一个”指针文件”找当前版本 - 文件锁:读者和写者用
flock协调 - 换用数据库:如果一致性要求高,文件系统本来就不该是答案
这道题的深层考点:
不只是”会不会写这段代码”,而是:
- 知不知道 fsync 的必要性:90% 的人会漏掉
f.Sync,他们以为write就落盘了 - 知不知道 fsync 目录:95% 的人会漏掉,他们不知道 rename 本身也要落盘
- 知不知道 rename 的原子性来源:不是”rename 快”,是”它是单个元数据操作”
- 有没有考虑并发:生产环境的写入几乎都有并发
思考题 2
你的服务每秒要写 1000 条日志(每条 100 字节),要求”日志一条都不能丢”(比如审计日志)。你会怎么设计?如果要求放宽到”允许丢最后 1 秒”,方案又有什么变化?
参考答案
这道题的核心是:在”不丢”和”快”之间,找到正确的工程位置。
先算账:
要求"一条不丢" = 每条日志都要落盘 = 每条都要 fsync
fsync 的成本:
机械硬盘:~10ms/次 → 每秒最多 100 次 → 达不到 1000 条/秒
SATA SSD:~1ms/次 → 每秒最多 1000 次 → 刚好卡线
NVMe SSD:~0.1ms/次 → 每秒最多 10000 次 → 可以
所以第一条结论:能否”每条都 fsync”,取决于硬件。
在机械硬盘上,“每条日志都 fsync”物理上做不到 1000 条/秒(单盘)。你必须批量 fsync。
方案一:严格不丢(每条 fsync),但靠批量摊薄。
核心思想:“每条日志落盘” ≠ “每条日志单独 fsync”。
可以用组提交(group commit):
多个写日志的请求,攒一小批(比如 10 条或 10ms 内的),
一起写 + 一次 fsync。
这样:
10 条日志,1 次 fsync → fsync 成本摊薄 10 倍
1000 条/秒,100 次 fsync/秒 → 机械盘勉强能扛,SSD 轻松
代价:延迟增加。每条日志的”落盘时间”从”立刻”变成”最多等一批”(比如 10ms)。
这其实就是数据库的组提交、Kafka 的批量刷盘、以及第 12 章要讲的”摊销”思想。
方案二:WAL(先写日志,再合并)。
不直接写日志文件,而是:
1. 日志先写到一个"追加写"的 WAL 文件(顺序写,快)
2. 定期(比如 1 秒)fsync 这个 WAL
3. 后台把 WAL 合并、归档到正式日志文件
追加写 + 顺序写是最快的磁盘写模式(没有寻道),所以 WAL 能支撑很高的吞吐。
方案三:异步 + 有界缓冲(允许丢最后一点)。
如果允许”丢最后 1 秒”,那就简单多了:
1. 日志写到一个内存环形缓冲区(channel + buffer)
2. 一个后台 goroutine 每秒批量刷盘 + fsync
3. 掉电/崩溃:丢最多 1 秒(缓冲区里的)日志
这就是 Redis AOF appendfsync everysec 的思路。
关键设计:缓冲要”有界”。
type AsyncLogger struct {
ch chan []byte
mu sync.Mutex
dropped uint64 // 统计丢了多少
}
func NewAsyncLogger(path string) *AsyncLogger {
l := &AsyncLogger{ch: make(chan []byte, 10000)}
go l.writer(path) // 后台刷盘
return l
}
func (l *AsyncLogger) Write(entry []byte) {
select {
case l.ch <- entry: // 缓冲没满,入队
default: // 缓冲满了
atomic.AddUint64(&l.dropped, 1)
// 关键决策:是"丢弃新日志"还是"阻塞等待"?
// 审计日志:阻塞等待(宁可慢,不能丢)
// 普通日志:丢弃 + 告警(宁可丢,不能拖慢主流程)
}
}
func (l *AsyncLogger) writer(path string) {
var buf []byte
ticker := time.NewTicker(time.Second)
for {
select {
case entry := <-l.ch:
buf = append(buf, entry...)
if len(buf) > 64*1024 { // 攒够 64KB 就刷
l.flush(buf)
buf = buf[:0]
}
case <-ticker.C:
if len(buf) > 0 { // 每秒兜底刷一次
l.flush(buf)
buf = buf[:0]
}
}
}
}
func (l *AsyncLogger) flush(buf []byte) {
f, _ := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0644)
f.Write(buf)
f.Sync() // fsync,落盘
f.Close()
}“缓冲满了”怎么办?这是必答题:
| 场景 | 策略 | 理由 |
|---|---|---|
| 审计日志(法律要求) | 阻塞等待,或者直接失败 | 不能丢,宁可慢 |
| 交易日志 | 阻塞 + 告警 | 丢了是事故 |
| 普通业务日志 | 丢弃 + 告警 | 不能因为日志拖垮主流程 |
| 监控指标 | 采样丢弃 | 丢了无妨 |
对比两种要求的方案差异:
| 严格不丢 | 允许丢 1 秒 | |
|---|---|---|
| 方案 | 组提交 fsync / WAL | 异步 + 有界缓冲 + 每秒 fsync |
| 吞吐 | 受 fsync 次数限制 | 高(批量化) |
| 延迟 | 每条要等组提交 | 低(写内存即返回) |
| 掉电损失 | 0 | 最多 1 秒 |
| 复杂度 | 中 | 低 |
这道题想让你意识到:
- “不丢”是有物理成本的(fsync 次数),不是喊口号
- 组提交是”不丢”和”快”之间的桥梁(第 12 章重点)
- 缓冲必须”有界”,否则内存无限增长(第 16 章背压)
- “满了怎么办”必须提前想清楚,不能等出事故了才想
思考题 3
为什么数据库要用 O_DIRECT 绕过 page cache,而一般的文件读写(比如 cp 命令)不要?如果你给一个 Go 服务写”文件缓存”,你会自己实现缓存还是依赖 page cache?
参考答案
先回答”为什么数据库要 O_DIRECT”:
数据库(MySQL InnoDB、PostgreSQL、RocksDB)有一个共同点:它们自己就有一层精密的缓存(buffer pool)。
数据库的双重缓存问题:
┌─────────────────────────────┐
│ 数据库 buffer pool(应用缓存)│ ← 数据库自己管,LRU,精确知道冷热
└──────────────┬──────────────┘
│ 读/写
┌──────────────▼──────────────┐
│ page cache(内核缓存) │ ← 内核管,伪 LRU,粗略
└──────────────┬──────────────┘
│
磁盘
问题在哪:
-
内存浪费:同一份数据(比如一个 16KB 的页),buffer pool 里一份,page cache 里又一份。存了两遍。
-
缓存决策冲突:数据库的 buffer pool 用精确的 LRU/LFU 决定”谁该留在内存”,但 page cache 用粗略的双链表 LRU。内核可能会把数据库刚淘汰的页又缓存起来(因为内核不知道数据库的意图)。
-
写路径冗余:数据库写一个页,先写 buffer pool,再写 page cache(write 到文件),page cache 再落盘。写了两遍。
-
一致性负担:数据库要保证”落盘了”(比如 WAL fsync),就必须 fsync 到 page cache 以下的层次,但 page cache 是否真的落盘,数据库控制不了(受 dirty_ratio 等参数影响)。
所以数据库用 O_DIRECT:
open("data.ibd", O_RDWR | O_DIRECT);
// 直接 I/O:绕过 page cache,数据直接从 buffer pool 到磁盘好处:
- 不浪费内存(数据只有 buffer pool 一份)
- 缓存决策完全由数据库控制
- 写路径直接,fsync 语义清晰
代价:
- 放弃 page cache 的预读(readahead)→ 数据库要自己实现预读
- 放弃 page cache 的合并写 → 数据库要自己合并
- 放弃 page cache 的缓存命中 → 数据库靠 buffer pool 自己扛
- O_DIRECT 有对齐要求(地址、大小要对齐 512 字节或 4KB)
那为什么 cp 不要 O_DIRECT?
因为 cp 没有自己的缓存,它完全依赖 page cache:
cp 读文件 → page cache(顺便缓存了,下次读快)
cp 写文件 → page cache(writeback 落盘)
对 cp 这种”一次性”操作,page cache 是纯收益:它没有”双重缓存”的问题,因为它自己根本没有缓存。
回到你的问题:Go 服务写”文件缓存”,自己实现还是依赖 page cache?
决策树:
你的服务有"领域知识"吗(知道哪些数据热、哪些冷、访问模式)?
├─ 没有 → 依赖 page cache
│ 理由:内核做了二十年,比你靠谱
│ 适用:日志、配置文件、简单的数据文件
│
├─ 有,但很弱 → 依赖 page cache + 少量 madvise 提示
│ 用 madvise(MADV_WILLNEED) 预读、MADV_DONTNEED 释放
│ 适用:知道"接下来要读哪块"的场景
│
└─ 有,且很强 → 自己实现缓存 + O_DIRECT
理由:你的精确知识能打败内核的通用策略
适用:数据库、存储引擎、有明确冷热的数据
一个关键的判断标准(这比技术细节重要):
你的缓存决策,能否比内核的”伪 LRU”更准确?
- 如果只是”最近访问的缓存起来” → 你实现的和 page cache 一样,甚至更差,别自己写
- 如果知道”这份数据 30 秒内一定还会被访问”(比如正在处理的请求) → 你可以用更精确的策略,值得自己写
- 如果知道”这是用户 A 的数据,A 是 VIP,不能淘汰” → 内核不知道 VIP,你必须自己写
总结成一句话:
通用缓存(page cache)适合”没有领域知识的通用场景”,专用缓存(buffer pool)适合”有精确领域知识的专业场景”。
而”自己实现”还是”依赖内核”,取决于你有没有内核没有的知识。没有,就别重复造轮子;有,就自己造。
思考题 4(开放题)
“文件系统本质上就是一个数据库,数据库本质上就是一个文件系统。“这句话你认同吗?它们的分界线到底在哪?什么时候你该用文件系统,什么时候该用数据库?
参考答案
开放题,答案见仁见智,但我给你一个能立住的框架。
先拆这句话:
“文件系统是一个数据库”:对。文件系统有存储(数据块)、索引(inode/dentry/目录)、事务(日志 journaling)、并发控制(锁)。
“数据库是一个文件系统”:也对。数据库最终把数据存在文件里(或者裸设备上),它自己管理”如何组织这些文件”。
它们的共同点:
都是"在持久化存储上,组织、索引、检索数据"
文件系统: 数据单元是"文件",索引是"路径 → inode",查询是"按路径找文件"
数据库: 数据单元是"记录",索引是"字段 → 记录",查询是"按条件找记录"
它们的分界线(这才是关键):
| 维度 | 文件系统 | 数据库 |
|---|---|---|
| 数据单元 | 文件(不透明的字节流) | 记录(有结构、有字段) |
| 查询方式 | 按路径(精确) | 按条件(范围、模糊、聚合) |
| 一致性保证 | 弱(文件级原子性) | 强(事务、ACID) |
| 并发控制 | 粗(文件锁) | 细(行锁、MVCC) |
| 数据关系 | 无(文件间无关系) | 有(外键、join、约束) |
| 适用数据 | 非结构化(文档、图片、代码) | 结构化(订单、用户、商品) |
一句话分界:
当你需要”按内容查数据”时,用数据库;当你只需要”按名字存取不透明数据”时,用文件系统。
展开说:
用文件系统,如果:
- 数据是"文件"(图片、视频、PDF、代码)
- 按"名字/路径"访问就够了(不需要按内容查询)
- 一次读写整个文件(不需要更新文件中间的某条记录)
- 没有跨文件的事务需求
用数据库,如果:
- 数据是"记录"(用户、订单、日志条目)
- 要按条件查询("查所有金额>100的订单")
- 要更新单条记录("把订单 123 的状态改成已支付")
- 需要跨记录/跨表的事务
中间地带(这是最值得思考的部分):
现实中很多场景在两者之间,选型就变得微妙:
场景一:海量图片/视频存储。
数据是"文件"(图片),但数量巨大(亿级),还要按多种条件查(时间、用户、标签)
方案 A:文件系统存文件 + 数据库存元数据(标准做法)
文件存磁盘/对象存储,路径 = hash(内容) 或 时间/用户分层
数据库(或 ES)存"图片ID → 路径、标签、时间"的索引
查询:先查数据库拿到路径,再读文件
方案 B:全放数据库(BLOB)
简单,但数据库会变得巨大,备份、迁移都慢
一般只在"图片小、数量不多"时用
场景二:日志系统。
日志是"记录"(要按时间、关键字查询),但量巨大(TB 级)
方案 A:文件系统存日志文件(按时间切分)+ 日志分析工具(ELK)
写快(追加写),但查询要用专门工具
方案 B:时序数据库(ClickHouse、InfluxDB)
结构化、可查询、压缩好,但写入路径复杂
场景三:配置管理。
配置文件(JSON/YAML)是"文件",但可能有大量配置项、要按项查询更新
小规模:文件(简单直接)
大规模:配置中心(etcd/Consul/Nacos,本质是数据库)
一个深刻的观察:
“文件系统 vs 数据库”的边界,正在被”对象存储”和”NoSQL”模糊。
- 对象存储(S3):像文件系统(按 key 存对象),但没目录、没层次,本质是个巨大的 key-value 数据库
- NoSQL(如 MongoDB):像数据库,但数据模型是”文档”,接近”文件”
这说明:文件系统和数据库不是两个截然不同的东西,而是一个光谱的两端。 中间有无数种”类文件数据库”和”类数据库文件系统”。
那工程上怎么选?给你一个决策顺序:
1. 数据是"不透明字节流"还是"有结构记录"?
├─ 字节流 → 文件系统
└─ 有结构 → 数据库
2. 访问是"按名字"还是"按内容"?
├─ 按名字 → 文件系统
└─ 按内容 → 数据库
3. 需要"跨数据的事务/一致性"吗?
├─ 不需要 → 文件系统
└─ 需要 → 数据库
4. 数据量和访问频率如何?
├─ 量小、访问少 → 文件系统(简单)
└─ 量大、访问频繁 → 数据库(需要索引、缓存、并发控制)
5. 团队和运维成本?
├─ 文件系统:零运维(就是磁盘)
└─ 数据库:要维护、备份、调优(有运维成本)
最终答案:
这句话”半对”——文件系统和数据库本质都是”持久化数据的组织方式”,在这个抽象层次上它们是一回事。
但它们在不同层次上工作:文件系统管”字节流”(不关心内容),数据库管”记录”(关心内容结构)。
所以不是”用哪个”,而是”你的数据在哪个层次上被理解”:
- 内容对系统是”黑盒”(图片、视频)→ 文件系统
- 内容对系统是”可解析的结构”(订单、用户)→ 数据库
而真正的高手,会在合适的场景,把文件系统当数据库用(比如用文件名编码元数据、用目录层级做索引),或者把数据库当文件系统用(比如把 BLOB 存进数据库、用数据库存配置)。理解了本质,边界就不再是约束,而是工具。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 03 章 · 文件系统:一切皆文件的野心与代价 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【核心抽象】 │
│ 一切资源 → open/read/write/close 统一接口 │
│ 统一的是"调用方式",不是"语义"(这是抽象泄漏的根源) │
│ │
│ 【三层结构】 │
│ inode = 文件的"本体"(元数据,无文件名) │
│ dentry = 路径解析缓存(纯内存,像 TLB) │
│ page cache = 文件内容缓存(用内存换速度) │
│ │
│ 【链接的真相】 │
│ 硬链接 = 同一 inode 的多个名字(引用计数) │
│ 软链接 = 指向"名字"的名字(可能悬空) │
│ 删除文件 = 引用计数减一,不是抹数据 │
│ │
│ 【持久化的铁律】 │
│ write() 只保证"到了内存"(page cache) │
│ fsync() 才保证"到了磁盘" │
│ 新建文件还要 fsync 父目录 │
│ 日志(journaling) 保护"文件系统不崩",不保护"你的数据不丢" │
│ │
│ 【原子更新模式】 │
│ 写 .tmp → fsync → rename(原子)→ fsync 目录 │
│ = "准备可失败 + 提交原子"(数据库事务的雏形) │
│ │
│ 【三个可迁移的原则】 │
│ 1. 统一的签名 ≠ 统一的语义(别被接口骗了) │
│ 2. 优化改变语义时,要想到谁依赖旧语义 │
│ 3. 有领域知识才自己实现缓存,否则依赖通用层 │
│ │
│ 【一句话】 │
│ 文件系统用缓存和日志,在又慢又易坏的磁盘上伪装"又快又可靠"; │
│ 理解它的裂缝(缓存丢失、抽象泄漏),才算真正会用文件。 │
│ │
│ 【下一章预告】 │
│ 文件搞清楚了,但它只是"设备"的一种—— │
│ 下一章讲设备与 I/O,以及为什么"统一接口"在硬件面前总会破功。 │
└──────────────────────────────────────────────────────────────┘