write 的真实语义:成功 ≠ 落盘
应用调用 write(fd, buf, n),返回 n 不代表任何字节到达磁盘——数据只是从用户缓冲拷进了内核的 Page Cache(第 4 篇讲过它的角色),对应的内存页被打上「脏」标记(dirty page)。真正写入磁盘的是内核的 writeback 回写机制,它按自己的节奏、批量地、异步地完成。
这个设计是纯粹的吞吐优化:把随机小写聚成顺序大写,把多次写合并成一次。但它同时引入了一个持久性窗口:从 write 返回到真正落盘之间,机器断电就丢数据。整个 Linux 存储栈的工程实践——数据库的 WAL、消息队列的刷盘策略、日志系统的攒批——都是在回答同一个问题:这个窗口开多大,你能接受?
sequenceDiagram
participant App as 应用
participant PC as Page Cache
participant WB as per-BDI 回写线程
participant Disk as 块设备
App->>PC: write() 拷入并标脏,立即返回
Note over PC: 数据只是内存里的脏页
WB->>PC: 定时/水位触发扫描
PC->>Disk: 批量下发写请求
Disk-->>WB: 完成回调,页转干净
App->>PC: fsync(fd)【可选】
PC->>Disk: 强制下发该文件全部脏页
Disk-->>App: fsync 返回 = 真正落盘
回写的触发条件:时间、水位、显式
内核在三个条件下启动回写,各自的参数都暴露在 /proc/sys/vm/:
- 时间条件:脏页年龄超过
dirty_expire_centisecs(默认 3000 = 30 秒)就到期回写。这是「最多丢 30 秒」的默认承诺——注意它是「逐页年龄」,不是「每 30 秒一批」。 - 水位条件:脏页总量占可用内存的比例超过
dirty_background_ratio(默认 10%)时,后台回写线程开始工作;超过dirty_ratio(默认 20%)时,产生脏页的应用进程自己被按住做同步回写(throttle,也称 balanced dirty)——这是防止脏页无限堆积的闸门。 - 显式条件:
sync/fsync/syncfs等调用强制下发。
per-BDI(backing device info)架构让每个块设备有独立的回写队列与线程,慢盘不会拖累快盘。/proc/sys/vm/ 的参数是全局的,但对多盘机器可以按设备分别调节(/sys/class/bdi/<major:minor>/)。
调参的实际含义要读成「持久性 vs 延迟抖动」的交易:把 dirty_background_ratio 调小,落盘更频繁、断电丢失窗口更小,但回写线程更忙、应用写延迟毛刺更多;调大则相反。数据库机器的常见配置是把全局值调大、把刷盘职责完全交给数据库自己的 fsync 节奏——让专业者管理持久性,内核只管吞吐。
fsync、fdatasync、sync_file_range:三个刷盘接口的精确语义
fsync(fd):把该文件的所有脏页和元数据(大小、时间戳、间接块)一起落盘,返回即持久。代价最大但语义最完整。
fdatasync(fd):只刷数据页,元数据只在必须时刷(如文件大小变了)。省掉元数据 IO,日志追加场景(只 append、不改元数据)的标准选择——性能比 fsync 高,语义对日志刚好。
sync_file_range(fd, offset, nbytes, flags):Linux 特有的精细控制,可以「启动异步回写但不等待」(SYNC_FILE_RANGE_WRITE)或「等待已启动的回写完成」。预写场景(提前把马上要 fsync 的页异步刷下去,fsync 时只需等尾部)能显著降低 fsync 的长尾延迟。PostgreSQL 的 sync_file_range 预刷策略就是这个接口的教科书用法。
一个必须理解的陷阱:fsync 只承诺「本文件落盘」,不承诺目录项落盘。新建文件后 rename 到最终位置,如果不同时 fsync 父目录,断电后文件可能「存在但名字丢了」。数据库创建 WAL 段文件后的 fsync 目录操作,就是这个陷阱的防御代码。
数据库为什么自己管刷盘:以 WAL 为例
PostgreSQL 与 MySQL InnoDB 的持久性根基是 WAL(write-ahead log):事务提交时把日志 write 到日志文件,然后 fsync,fsync 返回才向客户端确认提交。这条路径上内核的 writeback 机制完全被绕开——脏页回写只负责数据页(checkpoint 时批量刷),持久性的承诺由 fsync 精确交付。
为什么不用「依赖 30 秒自动回写」的默认行为?三个理由:其一,事务承诺需要精确边界——「最多丢 30 秒」对银行转账是灾难,fsync 边界才能给出确定的持久点。其二,恢复需要顺序保证——WAL 必须先于数据页落盘,fsync 的顺序语义提供了这个保证。其三,组提交优化需要控制权——多个事务的 fsync 可以合并成一次盘上操作(group commit),这要求刷盘时机在数据库手里。
反例是日志系统与消息队列的取舍:Kafka 默认不 fsync(flush.messages/flush.ms 默认关),依赖副本机制而非单机持久性保证——它把「丢数据窗口」的决策上移到「多副本 ack」层。Redis 的 AOF everysec 是中间态:后台线程每秒 fsync 一次,承诺「最多丢 1 秒」。三个系统三种选择,本质都是把持久性窗口显式化,而不是交给内核默认值。
writeback 的性能病理与诊断
回写机制本身也会成为故障源,常见三种形态:
- 回写风暴:脏页水位突破
dirty_ratio,大量进程被 throttle 按住同步等待——表现为「写 IO 平时正常,某一刻全部进程卡住」。/proc/meminfo的 Dirty 数值冲顶、iostat 的 w/s 突增是现场证据。 - 慢盘拖累:单一慢设备(如日志盘)的回写队列积压,per-BDI 隔离不彻底时影响全局分配。iostat 的
w_await超过 20ms 的盘要警惕。 - fsync 长尾:fsync 必须等设备真正完成,SSD 的垃圾回收(GC)会造成偶发 fsync 抖动到几百毫秒。
blktrace/fio 可以复现;缓解手段是 sync_file_range 预刷、或换更高性能的持久层(NVMe、带电容保护的写缓存 RAID 卡)。
# 实时观察脏页水位
watch -n1 "grep -E 'Dirty|Writeback' /proc/meminfo"
# 谁在产生脏页:按进程的写回统计
grep writeback /proc/PID/status
# 块设备回写延迟
iostat -x 1 # 关注 w_await、wqu-sz
与本系列其他章节的勾连
第 4 篇(fsync 到底承诺什么)从单文件的持久性语义切入,本篇从内核机制的全局视角补齐了「谁在什么时候刷」的完整图景——两篇合起来是存储持久性的完整拼图。第 22 篇(Direct IO)将展示第三条路:完全绕过 Page Cache,让应用自管缓存——那时你会看到 writeback 与 Page Cache 的所有便利与代价同时被剥掉后的世界。第 31 篇(上下文切换)会回到「回写线程挤占 CPU」的调度面。
主流存储的刷盘参数对照:把窗口显式化的五个样本
把「显式管理持久性窗口」落到五个主流系统的参数上,是本节最实用的记忆锚。MySQL InnoDB 的 innodb_flush_log_at_trx_commit:1 是每次提交 fsync redo(最安全,默认),2 是写 OS 缓存每秒刷(宕机不丢、断电丢一秒),0 是每秒才写缓存(连宕机都可能丢)——金融场景必须 1,配合 sync_binlog=1 双保险。PostgreSQL 的 synchronous_commit:on 等本地 WAL 落盘,remote_apply 追加等待副本重放(分布式强一致),off 则只写缓存——主从架构下 off 会造成主库已确认、从库还没收到的事务回丢。Redis 的 appendfsync:always 每命令刷(吞吐掉一个量级)、everysec 每秒后台刷(默认,最多丢一秒)、no 交给内核(窗口不可控,不建议)。Kafka 干脆不提供单机 fsync 语义(默认靠 Page Cache),持久性承诺由 min.insync.replicas 与 acks=all 的多副本确认给出——窗口被上移到副本层。RocksDB 的 WAL 与列族的 bytes_per_sync 则把窗口拆到列族粒度。五个样本的共同语法:没有系统敢依赖内核默认值,持久性窗口都是显式参数、显式文档、显式运维评审的对象。新系统选型时,「窗口参数是否一等公民」是存储引擎成熟度的试金石。
刷盘策略的崩溃演练:验证比配置更重要
刷盘参数配完不算完,「断电时真的不丢」要靠演练证明。三层演练方法按成本递增。第一层:进程级崩溃(kill -9)。验证的是「应用缓冲 vs 内核 Page Cache」的边界——kill 后重启,fsync 过的数据必须还在,没 fsync 的丢了属于预期。这一层能抓到最常见的实现 bug:日志库号称异步落盘却把 fsync 与业务线程串行(性能没赚)、或双写路径漏了一处 fsync。第二层:虚拟机级断电(KVM 的 virsh destroy 或云盘的硬断电接口)。验证「Page Cache 回写与设备易失缓存」的边界——此时数据是否存活取决于回写是否完成与磁盘/RAID 卡的电容保护,这是 fsync 语义真正被检验的层。第三层:文件系统级故障注入(dm-flakey、dm-log-writes,第 23 篇)。把设备在任意写边界「掉电」,重启后校验文件系统与应用数据的一致性——ext4/XFS 上游的崩溃测试就是这么跑的。生产团队的务实版本:第一层进 CI(自动化、每次发布跑),第二层在预发环境定期做(真实断电一次的成本可承受),第三层留给存储团队或供应商的承诺。一个反直觉的演练发现很常见:kill -9 安全的路径,断电后丢——因为进程死时 Page Cache 还在,内核替你兜了底;断电时这层底就没了。所以「我们测试过崩溃安全」这句话,必须追问测的是哪一层——三层的差别就是数据丢没丢的差别。
日志类应用的特殊刷盘:append-only 场景的优化空间
日志追加(append-only)是刷盘优化的最佳场景,内核与应用层各有一档专用加速。内核侧:fdatasync 省掉元数据(对纯 append 的文件,size 变化是唯一元数据变更,ext4/XFS 会把它合并进数据 IO,成本接近纯数据刷);更大的一档是 sync_file_range 的异步预刷——日志写入时提前把「马上要 fsync 的范围」异步下发,fsync 到来时只需等尾部几个页,把 fsync 的长尾从「整段等待」压缩到「尾部等待」,PostgreSQL 的 wal_writer 就是这个模式的应用层实现。应用侧:组提交(group commit)把多个写者的 fsync 合并——写者把数据交给刷盘线程后挂起,刷盘线程攒一批(或等极短窗口)后一次 fsync 交付全部,盘上一次寻道完成 N 个承诺;Kafka 的副本拉取、MySQL 的 binlog 组提交、Redis 的 AOF 后台线程全是这个模式。两档叠加的效果量级:SSD 上单次 fsync 约 0.5-2ms,组提交把每条日志的刷盘摊薄到微秒级,预刷再把 P99 长尾砍半——「高频写日志还能保住持久性承诺」的工程预算就是这么凑出来的。设计新系统的启示:刷盘策略与写入者的并发模型要一起设计(组提交需要「挂起写者」的机制),事后往同步写路径上贴异步刷盘补丁,几乎都会引入更难缠的一致性 bug。
小结
write 返回时数据只在 Page Cache,真正的落盘由 writeback 机制按「30 秒年龄 + 20%/10% 水位 + 显式调用」三条件异步完成。持久性窗口是这个设计的固有代价,工程实践的全部要点在于显式管理窗口:数据库用 fsync 精确交付事务承诺,Kafka 把窗口上移到多副本层,Redis 用每秒 fsync 折中。fsync/fdatasync/sync_file_range 的语义差异、目录 fsync 的陷阱、throttle 风暴的病理,是把「数据丢了」「写卡住了」类故障从玄学变成工程问题的必备知识。下一篇进入「绕过这一切」的选项:O_DIRECT。
读者留言
COMMENTS 暂无还没有留言,来说第一句?