从 read() 到磁盘的一路
你写了一行 read(fd, buf, 4096)。它有时候花 1 微秒,有时候花 3 毫秒——同一行代码,差三个数量级。这一章走完这条路上的每一层,看看那三个数量级藏在哪一步。
四层,一层一个抽象
从你的 read() 到磁盘上的磁性颗粒或闪存单元,中间隔着四层,每一层解决一个具体问题:
| 层 | 它解决什么问题 | 它对上面隐藏了什么 |
|---|---|---|
| VFS(虚拟文件系统) | 让 ext4、XFS、NFS、tmpfs、procfs 用同一套接口 | 你在用哪种文件系统 |
| page cache | 把读过的数据留在内存里,下次不用读盘 | ★ 这次到底有没有真的读盘 |
| 块层 | 把请求排队、合并、重排,让磁盘少动 | 请求的实际顺序和数量 |
| 驱动 + 硬件 | 真的跟设备说话 | 它是 NVMe、SATA SSD 还是机械盘 |
第二层是那三个数量级的来源。
下面这台机器把整条路径画在那道边界的两侧。关键操作:在「page cache 命中」和「未命中」之间来回切,看总耗时跳几个数量级。然后打开 fsync 看看它有多贵。
page cache:你机器上最大的一块内存
这是本章最重要的一层。规则很简单:
不是「可以配置的缓存」,是默认行为。所有空闲内存都会被拿来做 page cache,直到有人真的需要内存为止。
于是:
- 第一次读一个文件:真的去读盘,约 100 μs(NVMe)到 8 ms(机械盘)。
- 第二次读同一个文件:直接从内存拷给你,约 1 μs。根本没碰磁盘。
这就是「第二次跑同一个命令快得多」的全部原因——不是磁盘变快了,是压根没读磁盘。
$ free -m
total used free shared buff/cache available
Mem: 15884 2103 412 235 13368 13201
↑ ↑ ↑
只剩 412 MB? 这一大块 真正可用的
是 page cache 13.2 GB
free 那一列显示只剩 412 MB,看起来快没内存了。但那 13 GB 的 buff/cache 是 page cache——它随时可以被回收。
要看的是最后那列 available。它是内核估算的「不引发换页的前提下还能给你多少」,这才是你真正关心的数字。
老一辈的口号是 "Linux ate my RAM"——空闲内存被拿去做缓存不是浪费,让内存闲着才是浪费。这个设计一直很正确,只是那个 free 列的名字起得太糟糕了。
写:默认是骗你的
读的路径讲完了,写更值得警惕。
当你 write() 成功返回时,数据在哪里?
write() 返回只意味着:数据已经拷进了 page cache,并且那些页被标成了「脏」。
此刻如果掉电,你的数据全没了。而 write() 明明返回了成功。
真正的落盘是由内核的回写线程(pdflush / flusher)在后台异步完成的,触发条件是:脏页超过阈值(vm.dirty_ratio),或者脏页够老了(vm.dirty_expire_centisecs,默认 30 秒)。
所以默认情况下,你的数据最多可能有 30 秒是「写成功了但没落盘」的状态。
这就是 fsync() 存在的理由:它逼着内核把这个文件的脏页立刻写下去,并且等硬件确认。
| 只 write | write + fsync | |
|---|---|---|
| 返回时数据在 | page cache(内存) | 磁盘 |
| 掉电会丢吗 | ★ 会 | 不会 |
| 耗时 | 约 1 μs | 约 1 ms——慢 1000 倍 |
一千倍。这就是「持久化」的标价。
数据库每提交一个事务,都必须至少付一次这样的钱——不然「提交成功」就是一句谎话,ACID 里的 D(Durability)直接失效。
所以数据库把大量精力花在怎么少付、怎么摊薄这笔钱上:
- WAL(预写日志):只 fsync 一个顺序追加的日志文件,而不是散落各处的数据页。顺序写比随机写便宜得多。
- 组提交(group commit):攒一批事务,一次 fsync 一起落盘。100 个事务共付一次 1 ms,每个摊到 10 μs。
《当真》那本书里 PostgreSQL 的 WAL、synchronous_commit 那些参数,调的就是这一行价目表——把 synchronous_commit 关掉,性能立刻起飞,代价是崩溃时可能丢最后几百毫秒的事务。这不是 bug,是明码标价的交易。
坑一:fsync 文件还不够,目录也要 fsync。
你创建了一个新文件、写入、fsync。掉电后重启——文件可能不存在。因为「这个目录里多了一个文件」这条目录项的修改(第 17 章)也是脏数据,也需要落盘:
int fd = open("data.tmp", O_WRONLY | O_CREAT, 0644);
write(fd, buf, len);
fsync(fd); // 文件内容落盘了
close(fd);
rename("data.tmp", "data"); // 原子替换
int dfd = open(".", O_RDONLY);
fsync(dfd); // ★ 这一步最容易漏:让 rename 也落盘
close(dfd);
坑二:fsync 失败之后,不能重试。
2018 年爆出的所谓 fsyncgate:Linux 上 fsync 返回错误后,内核已经把那些脏页的错误标记清掉了。你再调一次 fsync,它会返回成功——但数据其实没写下去。
PostgreSQL 因此改成了遇到 fsync 失败直接 panic 重启,从 WAL 恢复。这件事当年在数据库圈引起了不小的震动,因为大家发现自己依赖了三十年的一个保证,其语义从来没被真正说清楚过。
拷贝了几次
一次普通的「读文件、发到网络」,数据被拷贝了四次:
磁盘 ──DMA──> page cache ──拷贝 1──> 你的 buf
│
拷贝 2
↓
socket 缓冲区 <──────────┘
│
DMA
↓
网卡
中间那两次拷贝(拷贝 1 和 2)完全是多余的——数据只是从内核内存搬到用户内存,再搬回内核内存,内容一个字节都没变。
这就是零拷贝要省的东西:
| 做法 | 拷贝次数 | 过线次数 | 适用 |
|---|---|---|---|
read + write | 4 | 4 | 你需要处理数据 |
mmap + write | 3 | 4 | 要读但不改 |
★ sendfile | 2(全是 DMA) | 2 | 原样转发文件 |
splice | 2 | 2 | 在两个 fd 之间倒数据 |
Nginx 发静态文件、Kafka 把日志段发给消费者,用的都是 sendfile。但注意前提:你不能碰数据。要压缩、要加密、要改内容,就必须把它拷到用户态——零拷贝就用不上了。这也是为什么 Kafka 一开启 SSL,吞吐会明显下降。
- page cache 命中的 read:约 1 μs
- NVMe 随机读 4 KB:约 100 μs——比命中慢 100 倍
- 机械盘一次寻道:约 8 ms——比 NVMe 慢 80 倍
- 一次 fsync:约 1 ms——比普通 write 慢 1000 倍
注意机械盘那一行是随机访问的代价。顺序读机械盘能到 100+ MB/s,随机读只有几百 IOPS——差三个数量级。这就是为什么「顺序写日志」这个模式在数据库、文件系统、消息队列里无处不在。SSD 缩小了这个差距,但没有消除它。
「Android 上 SharedPreferences 的 apply() 和 commit() 差在哪?」
正好就是这一章的分界线:
commit():同步写盘并 fsync,返回值告诉你成没成功。阻塞调用线程约 1 ms 甚至更多。在主线程上调它,就是在给自己制造掉帧(第 4 章的 16.6 ms 预算)。apply():立刻更新内存里的值并返回,落盘扔给后台线程。不阻塞,没有返回值——因为它没法告诉你成没成。
所以默认用 apply()。但要知道你买的是什么:进程如果在落盘前被杀(第 15 章说了随时可能),这次修改就丢了。
还有个更隐蔽的坑:apply() 虽然不阻塞你,但它的写入任务会被加入一个队列,并在 Activity onPause 或 Service 生命周期切换时被强制等待完成(QueuedWork.waitToFinish())。所以如果你在 onPause 前连续 apply 了几百次,那个等待会实打实地卡在主线程上——ANR 就是这么来的。
这也是 Google 推荐迁移到 DataStore 的原因之一:它基于协程和 Flow,把这件事明确地变成了异步的、有错误处理的操作,而不是一个假装无痛的 apply()。
这一章的一句话
一次 read 要穿过 VFS、page cache、块层和驱动,而 page cache 决定了这次是 1 微秒还是 3 毫秒——同一行代码,差三个数量级。写则默认是骗你的:write 返回只代表进了内存,真正的持久化要 fsync,而那要多付一千倍的钱。
下一章:如果一个进程要同时盯着一万个 fd,怎么办?这个问题在 2000 年前后卡住了整个行业,而它的解法改变了服务器的写法。