2018 年 3 月,PostgreSQL 邮件列表因为一封报告炸了锅:开发者 Craig Ringer 指出,Linux 内核在写回(writeback)失败时会把出错页从缓存里丢弃并标记为「干净」,错误就此丢失——此后的 fsync() 可能返回成功,而数据并没有落盘。内核与数据库社区就「fsync 到底承诺什么」对质了一个月,Tom Lane 的评语是「kernel brain damage」。这场后来被称为 fsyncgate 的论战,是理解文件系统与 Page Cache 的最好切口:所有关于持久化的疑惑——脏页何时回写、ext4 日志保什么、O_DIRECT 为谁而生——都能在这条线上找到答案。
一、VFS 与 Page Cache:一切文件的公共前台
Linux 支持几十种文件系统,应用却感知不到差异,因为所有文件系统都站在同一层抽象后面:VFS(虚拟文件系统),它定义了 file、inode、dentry 等统一对象,把 ext4、XFS、NFS 折算成同一套系统调用语义。VFS 的三个核心对象值得点名:file 是一次打开的会话,inode 是文件的元数据本体,dentry 是目录树里的名字——「删文件其实只是删名字,inode 引用计数归零才真正释放」,这句口诀同时解释了硬链接的存在与「删除被打开的文件、空间却不释放」的经典现象。VFS 之下最值钱的资产是 Page Cache:以 4KB 页为单位缓存文件内容,全系统共享。read() 的路径是先查页缓存——命中就直接从内存拷给用户缓冲,磁盘完全不出场;这就是为什么同一个文件读第二遍飞快。磁盘很慢、内存很快,内核的哲学是用一切空闲内存放大这份差距:空闲内存就是没用上的缓存。
顺序读还有一层隐藏福利:内核检测到顺序访问模式会自动预读(readahead)——不等应用开口,提前把后续页拉进缓存,一次读一个大块。顺序吞吐因此远超随机读,第 6 篇 Kafka 的「顺序写换天量吞吐」有一半是它的功劳。预读窗口可查可调(blockdev --getra/--setra),大文件服务器的常见微调项。
这个哲学直接解释了 free 命令的读法。buff/cache 一列是 buffers(块设备层的元数据缓冲)与 cache(文件页缓存)之和,它大不是坏事——大部分可以被随时回收给新应用;判断内存压力要看的是 available 列(「还能给新应用多少内存而不至换出」的估计),而不是 free。顺带破除一个流传甚广的迷思:手动 drop_caches 清缓存让机器「变快」毫无意义,缓存丢掉只会让下一次读变慢,生产环境没有正当理由别碰它。
二、脏页与回写:vm.dirty 的两道闸门
写路径与读对称:write() 先写进 Page Cache,被写的页成为脏页,由内核择机批量回写磁盘——这就是「写缓存」红利,也是数据丢失窗口的来源:落盘之前断电,页缓存里的数据不复存在。内核设了两道闸门控制脏页规模:vm.dirty_background_ratio(默认 10,占可用内存的百分比)触发后台异步回写,flusher 线程慢慢写,应用无感;vm.dirty_ratio(默认 20)是硬闸——脏页占到这里,正在写的应用被迫自己同步刷盘,延迟立刻飙升。监控里「写入延迟周期性尖刺」的经典病因之一,就是应用周期性地撞上这道硬闸。
两道闸门在大内存机器上有共同的坑:百分比是相对值,512GB 内存上 10% 就是 50GB 脏数据——断电一次丢 50GB,回写一次堵半天。所以数据库服务器几乎都改用字节版参数(vm.dirty_background_bytes/vm.dirty_bytes,注意两者与百分比版互斥,设一个另一个自动清零),把脏页规模钉死在几百 MB 量级。另有两个默默工作的时间参数:dirty_expire_centisecs(脏页满多少岁才允许回写)与 dirty_writeback_centisecs(回写线程唤醒周期),调优脏页行为时它们才是真正的节拍器。观测也有现成窗口:/proc/meminfo 的 Dirty 与 Writeback 两行分别是「等待回写」与「正在回写」的页量,延迟尖刺复现时盯它们,能看到闸门开合的全过程。
三、fsync 的承诺书与 ext4 日志
回到承诺本身。fsync() 的语义是把该文件已修改的数据与必要的元数据强制落盘,返回成功即承诺持久;fdatasync() 是精简版——只保证数据可取回所必需的部分,跳过 mtime 这类「不影响读回」的元数据,ext4 实测能少写约 20KiB,高频小事务场景值得顺手换上。两个容易踩空的边角:一是目录也要 fsync——新建或改名文件后必须对父目录 fsync,崩溃后文件名才算落稳(man 页白纸黑字);二是 fsync 保的是「这一个文件」,不含同盘其他文件。
ext4 的日志(journal)是另一层保护,目标不同:日志防的是崩溃一致性——机器断电重启后文件系统自身不烂。三种模式:data=journal 数据与元数据都进日志,最安全最慢;data=ordered(默认)只有元数据走日志,但保证数据块先于元数据落盘;data=writeback 数据完全不管排序,崩溃后可能看到旧数据。注意 ordered 的精确含义:它保证元数据一致、数据不落后于元数据,不保证崩溃后文件内容的完整性——「我用了默认配置所以数据安全」是把两层承诺混为一谈。fsync 管应用数据的持久性,日志管文件系统结构的一致性,两笔账分开算。
四、fsyncgate:内核与数据库的一场对质
现在可以完整还原开篇的事故了。链条是这样的:应用 write → 页缓存标记脏 → 后台回写时存储层报错(网络块设备断连、thin provisioning 满了、NFS 磁盘耗尽都是典型)→ 文件系统把出错页丢弃并标记干净(FreeBSD 的做法是保持 dirty 并在下次 fsync 重试,Linux 选择了丢弃)→ 页已经不在缓存里,此后的 fsync() 无账可查、返回成功——错误只存在于第一次 fsync 之前,而应用根本没调用过。内核维护者 Ted Ts'o 的理由也诚实:最常见的 I/O 错误是用户拔了 U 盘,保留 dirty 页会把内存拖死,于是选择了「丢页保内存」。
PostgreSQL 的架构放大了暴露面:checkpointer 依赖「fsync 返回错误就一直重试」的假设,而内核恰恰可能丢掉第一个错误。结论是 2019 年 2 月 14 日的批量小版本(11.2、10.7、9.6.12 等):PG 不再重试失败的 fsync,出错直接 PANIC 停库——既然拿不到可靠信号,就宁可壮烈也不能装没事;PG 12 起这是正式行为。内核侧的修复在 4.13:Jeff Layton 基于 errseq_t 的写回错误报告基础设施,保证每个打开的文件描述符至少能收到一次错误。论战没有输家——它把「fsync 成功 ≠ 万无一失」变成了数据库设计者的常识,也提醒所有人:存储错误的语义是文件系统栈里最幽暗的角落,换存储后端(尤其网络块设备)时值得专门复核这条链。
五、O_DIRECT:数据库为什么想绕开 Page Cache
既然 Page Cache 如此好用,为什么数据库反而拥抱 O_DIRECT?因为数据库自带缓存:PostgreSQL 的 shared_buffers、InnoDB 的 buffer pool 都是精心设计的页缓存,数据在同一块内存里存两份(双缓冲),既浪费内存又让写路径多一层拷贝。O_DIRECT(Linux 2.4.10 起)绕过 Page Cache 直达磁盘,把缓存策略权还给应用——但它是个不好伺候的选项:man 页明说「通常会降低性能」,地址与偏移要按逻辑块对齐,且 IO 本身是同步的,应用得自建异步队列。
PostgreSQL 的演进是最好的注脚:它传统上就是双缓冲(所以官方调优一直劝 shared_buffers 别太大),2025 年 9 月发布的 PG 18 终于引入异步 IO 子系统——io_method 可选 worker(默认)与 io_uring(需内核 5.1+),社区把这视为通往 Direct I/O、消除双缓冲的路线图。第 6 篇会看到 io_uring 的完整故事;这里只记结论:O_DIRECT 是给「有能力自管缓存的程序」准备的特权,普通应用用好 Page Cache 反而更快——Kafka 正是把这句反话做成招牌的产品,下一篇细讲。还要记一笔风险差异:O_DIRECT 绕过了页缓存,也就绕过了这套缓存成熟已久的错误语义,应用要自己面对对齐、长度与错误码的全部细节——特权伴生责任,古今同理。
六、工程落地清单
- 读内存看
available,不迷信free。buff/cache大是健康,回收机制在替你干活。 - 大内存数据库机用字节版脏页参数。
dirty_background_bytes/dirty_bytes钉死脏页规模,消除周期性写停顿与巨大的断电丢失窗口。 - fsync 与日志的承诺分开记账。崩溃后文件系统不烂 ≠ 你的数据完整;关键文件改名后记得对目录 fsync。
- 网络块设备、thin provisioning 环境把 fsyncgate 当设计输入。数据库升级到「fsync 失败即 PANIC」的版本,不要靠重试赌运气。
O_DIRECT只给自管缓存的应用用。普通服务默认路径即可;给数据库留足 Page Cache 往往比扩 buffer pool 更便宜。- 需要观测缓存命中时用 bcc 的 cachestat,别用 drop_caches 做实验。
结语
文件系统栈的所有争议都源于同一个事实:Page Cache 是一层共享的、异步的、会出错的缓存,而应用把持久化的期望全部押在它身上。脏页闸门决定延迟形态,日志决定崩溃形态,fsync 决定承诺边界,fsyncgate 则证明连承诺本身都有灰色地带。看清这四层,数据库调优文档里那些「迷信参数」就都有了出处。下一篇转向 IO 的另一半:当一万条连接同时抵达,select、poll、epoll 与 io_uring 是如何接力解决「谁来敲门」的问题的。
参考资料
- PostgreSQL's fsync() surprise — LWN——fsyncgate 的完整时间线与社区论战实录。
- Fsync Errors — PostgreSQL wiki——分内核版本的错误语义与 PG 修复决策的一手档案。
- PostgreSQL 11.2, 10.7, … Released — postgresql.org——2019-02-14 停止重试 fsync 的官方公告。
- The ext4 Journal — kernel.org——三种日志模式的官方定义。
- Administration guide sysctl/vm — kernel.org——dirty 系列参数与 drop_caches 的官方口径。
- fsync(2) man page — man7.org——fdatasync 差异与目录 fsync 的契约原文。
- Durability: Linux File APIs — Evan Jones——fsync/fdatasync 落盘行为的实测分析。
- PostgreSQL 18 Released — postgresql.org——PG 18 异步 IO 子系统官方公告。
读者留言
COMMENTS 暂无还没有留言,来说第一句?