☰
深入理解Linux内核page cache回写机制:从脏页到磁盘的完整流程
2026/10/21 23:08:10 网站建设 项目流程

1. 先搞清楚一件事:page cache 的“脏页”是从哪来的

做存储、数据库或内核相关开发的人,绕不开一个话题:page cache 的回写机制。很多人在排查 IO 毛刺或者突发延迟时,翻遍各种日志,最后发现是内核的 page cache 在“偷偷”写盘。要是没吃透回写机制,遇到类似问题基本靠猜,猜错了方向就白白折腾一整天。这篇文章我从脏页的产生开始讲,一步步拆到内核的参数配置、线程调度、调优技巧和故障排查,把回写机制掰开揉碎。

1.1 写文件的时候内核在做什么

一个进程调用 write() 写文件时,数据不是直接进磁盘,而是先进了 page cache。内核把文件内容按页大小(通常是 4KB)映射到物理内存中,写入内存页后,这个页就被标记为脏页(dirty page),表示它和磁盘上的内容不一致了。write() 系统调用在这一步就可以返回,因为真实的数据转移还没有发生,这只是内存拷贝。

这种设计早期会让很多人不理解:数据明明还没落盘,write() 就返回成功,万一断电怎么办?这正是 page cache 机制的第一个核心特点——通过延迟写入换取性能。大多数场景下,应用程序后续还会继续写入同一块区域,如果每写一次都立刻落盘,IO 次数将被放大到一个不可接受的数量级。内核赌的是“你刚写的数据短时间内大概率不会再读”,所以先把数据留在内存里,攒到一定的量或时间,再一次性写回磁盘。

脏页不是无限累积的。系统里每一个脏页都对应一个 inode,内核通过 address_space 结构来跟踪这些页面的状态。每次写入脏页,内核会更新相关计数,这个计数之后会驱动回写的启停逻辑。

1.2 为什么内核不立刻写回磁盘

核心原因是两个维度的权衡:减少 IO 次数和合并相邻扇区。

从减少 IO 次数看,假设一个进程以 4KB 大小的块写一个 100MB 的文件,如果边写边落盘,至少产生 25600 次 IO,而攒在 cache 里,内核可以等数据积累到几十 MB 再触发回写,IO 次数下降几个数量级。从合并角度看,内存中同一文件相邻的页在磁盘上大概率也是相邻的,内核回写时可以按磁盘块顺序批量刷出,把随机 IO 变成顺序 IO,这对传统机械盘和部分 SSD 都是极大的性能提升。

但延迟回写的代价就是数据丢失窗口。熟悉数据库双写机制、WAL 日志的朋友应该能理解:page cache 可以看成一个“时常不老实”的写缓冲,上层应用必须自己保证关键数据的持久化语义,比如通过 fsync 或同步写模式。

1.3 脏页的完整生命周期

脏页的生命周期分四个阶段:产生、累积、回写、干净。

  • 产生:进程 write 命中 cache,页面被标记脏。
  • 累积:脏页保持在 cache 中,等待回写条件触发。
  • 回写:内核回写线程将页面内容写回磁盘,写完后清除脏标记。
  • 干净:页面留在 cache 里,成为干净的缓存页,可以被复用或回收。

其中回写触发是重点,后面详细展开。这里先记住一句话:内核不是一有脏页就写,而是按策略在“内存空间够用”和“数据不能丢太久”之间找平衡。

2. 回写触发机制:谁来唤醒写盘这件事

脏页攒到一定程度或者过了一定时间,内核会启动回写。触发的路径有好几条,搞清楚这些路径,以后遇到磁盘 IO 写入尖峰时能定位得更准。

2.1 定时回写:背后的周期与延迟

内核有两个与时间相关的参数:dirty_writeback_centisecs 和 dirty_expire_centisecs,单位都是百分之一秒。

dirty_writeback_centisecs 是回写线程的周期性唤醒间隔,默认是 500(即 5 秒)。每隔这么长时间,回写线程会主动检查一次系统状态,注意这里的“检查”并不代表一定会刷数据,更多的是一次“轮询”。dirty_expire_centisecs 是脏页的过期时间,默认 3000(即 30 秒),表示一个脏页在内存里待了超过 30 秒后,会被判定为“过期”,必须在后续的回写周期中被刷出。

真正到时间后的刷写逻辑是:内核每个周期检查所有超过 expire 时间的脏页,把它们加入回写队列。这样做能保证系统即使完全空闲,过期的脏页也会在最多一个周期内落盘,不会无限期驻留内存。

2.2 内存水位触发回写

时间维度之外,还有空间维度的触发机制。

当脏页占系统内存的比例超过 dirty_background_ratio(默认 10%)时,内核会在后台异步启动回写,这个回写不阻塞用户进程;当脏页比例继续攀升到 dirty_ratio(默认 20%)时,内核开始同步阻塞回写,此时进程写入会等待脏页被刷出,直到比例回落到安全水位以下。

两个水位条件也有对应的“字节数”版本参数,比如 dirty_background_bytes 和 dirty_bytes,它们是和 ratio 版本互斥的,设置了 bytes 版本后 ratio 版本自动失效。字节形式适合在内存非常规整的嵌入式环境或容器环境中使用,因为按比例计算在内存较小或较大时容易失真。

需要注意一个细节:dirty_ratio 触发的同步阻塞并不是阻塞整个系统的所有写请求,而是阻塞那些正在生产新脏页的进程。这个机制在源码层面通过 balance_dirty_pages 实现,它会让写进程进入限速循环,刷一批脏页再放行一批。

2.3 手动触发:sync 与 fsync 的区别

sync 系统调用不仅把所有脏页刷出,还会附带执行文件系统层的屏障操作,确保数据完整落盘。fsync 则是针对单个文件描述符做数据落盘,它需要把该文件对应的所有脏页回写,并且等待磁盘 IO 完成后才返回。

很多人分不清 fsync 和 fdatasync 的区别,fsync 除了刷数据页,还要刷 inode 元数据(比如文件大小、修改时间),而 fdatasync 只刷文件的数据部分,在某些场景下性能会更好。数据库在 commit 时通常用 fsync 或者 fdatasync 来做持久化点,代价是每一次 commit 都会带来一次完整的刷盘等待。

2.4 回写线程体系:从 pdflush 到 flusher threads

老内核(2.6.32 之前)用的是 pdflush 线程池,全局共享一组线程。后来发现这种设计在大量磁盘或复杂拓扑下有瓶颈,内核改成了 per-backing-dev_info 机制的 flusher threads,也就是块设备层每个 backing_dev_info 都有自己专属的回写线程。这个设计的核心思路是让不同设备的回写互不影响:如果系统里挂了一块慢速机械盘和一块高速 SSD,慢盘的刷写不能阻塞 SSD 上的脏页写出。

每个 flusher 线程会周期性唤醒,检查该设备上所有 inode 的脏页总量和最长脏龄,按需构造回写队列。通过查看系统的 /proc/fs/jbd2 等路径,能看到不少文件系统层回写线程的存在,它们和内存层的 flusher 线程协同完成整个落盘流程。一开始觉得这套结构复杂,但理解了“设备级隔离”这个出发点,回看代码里对 workqueue 的封装逻辑就有种豁然开朗感。

提示:回写是异步机制,但最终落盘的顺序和 IO 调度器有关。即便 flusher 线程决定回写某些页,实际磁盘写入顺序还会经过 IO 调度算法的重新编排。这也是为何有时候 flusher 线程看起来发起了回写,实际磁盘延迟却延后的原因。

3. 核心参数与调优细节:这些 sysctl 到底怎么用

网上一搜 page cache 回写调优,满屏都是 dirty_ratio、dirty_background_ratio 几个参数,但真正能把这些参数用好的人不多。我用一个生产场景逐步说明这些参数怎么配合、改哪些、改完有什么效果。

3.1 参数速查表

以下是六个核心参数的默认值和作用速查:

参数名默认值单位/含义触发方向
dirty_ratio20占可用内存百分比,达到后同步阻塞写进程上限硬水位
dirty_background_ratio10占可用内存百分比,达到后后台异步回写下限软水位
dirty_expire_centisecs3000脏页最长驻留时间(30秒)时间维度触发
dirty_writeback_centisecs500回写线程唤醒检查周期(5秒)轮询周期
dirty_background_bytes0字节形式的后台阈值,设置后 ratio 失效替代口径
dirty_bytes0字节形式的硬水位阈值替代口径

修改方式通过 sysctl 命令,或者直接写 /proc/sys/vm/ 目录下的对应文件。比如:

sysctl -w vm.dirty_background_ratio=5 sysctl -w vm.dirty_ratio=15

永久生效则写入 /etc/sysctl.conf。

3.2 参数组合与动态调节逻辑

光记住默认值没有意义,要理解触发后的行为差异。dirty_background_ratio 触发的回写完全在后台,不对业务进程造成可见延迟;dirty_ratio 触发的回写是同步的,存在明显的业务卡顿。

两个水位之间的距离(差值)很关键。差值大,系统能在后台逐渐处理,大部分情况碰不到硬水位;差值小,系统会很快撞上同步回写,写放大明显。一般生产环境建议差值为 10 个点左右,比值设 20/10 是一个平衡的默认组合,但在内存很大的机器上按比例计算可能有偏差,需要结合业务负载微调。

举个例子:某台机器内存 512GB,业务是大量顺序写日志。默认 ratio 20/10 意味着后台在脏页达到 51GB 时启动回写,前台在脏页达到 102GB 时阻塞写。对大数据量的顺序写场景,51GB 的阈值偏大,日志落盘时间被拉得很长,一旦掉电,数据窗口太大。此时常见做法是把 dirty_background_ratio 调低到 5,dirty_ratio 到 10,这样脏页主动累积能力削弱,换取更小的丢失窗口和更平滑的 IO 曲线。

3.3 生产环境调优场景举例

顺序写多的小文件场景,比如消息队列或日志采集服务,建议调低 background 阈值,让回写早发生、平稳发生。但注意别调成 0,否则几乎每时每刻都在回写,磨损 SSD 寿命且引起频繁 IO。

随机写多的数据库场景,通常依赖 fsync 来保证持久化,page cache 延迟回写反而干扰数据库的刷盘节奏。可以考虑提高 dirty_background_ratio,让数据库自行控制刷盘时机,减少内核后台回写与数据库自身刷盘之间的竞争。

容器环境中要注意 ratio 参数统计的是整个宿主机的内存,而不是容器 cgroup 的内存限制。这一点经常导致容器内看到内存充足,实际触发的行为却基于宿主机整体水位判断。必要时用 dirty_background_bytes 按字节控制,并配合 cgroup 的 io 限制一起设计。

4. 实操记录:按数据观察一次回写全过程

理论讲完,来点实战。以下操作我建议你在测试机器上试一遍,整个过程大约半小时,能建立起对回写机制的直观认知。

4.1 模拟脏页生成

先准备一个足够大的内存盘,或者直接在普通磁盘上写一个大文件。我常用 dd 来生成脏页:

# 先查看当前脏页量 cat /proc/meminfo | grep -E "Dirty|Writeback" # 写入 1GB 数据,不落盘,只看 page cache 的变化 dd if=/dev/zero of=/tmp/testfile bs=1M count=1024 conv=fdatasync

注意:dd 命令最后的 conv=fdatasync 会在结束时刷盘一次,干扰观察。改成先写入不看落盘,用另一个终端观察:

# 终端A:持续写入 dd if=/dev/zero of=/tmp/testfile bs=1M count=1024 # 终端B:观察内存状态变化 while true; do grep -E "^(Dirty|Writeback):" /proc/meminfo; sleep 0.5; done

写入结束后,会看到 Dirty 数值短期内保持在高位,然后周期性下降。这个下降过程就是回写线程在刷脏页。如果观察不到明显下降,检查 dirty_expire_centisecs 是否设置得过大,或者系统本身处于空闲状态迟迟没有触发周期回写。

4.2 观察回写落盘

想要确认脏页真的写到了磁盘,可以用 iostat 看设备写入速率:

iostat -dxm 1

写入完成后,正常情况下每隔 5 秒左右,磁盘的 wMB/s 会有一波小幅跳动,这就是 flusher 线程在做周期回写。如果 dirty_background_ratio 设置得比较低,跳动的频率会变高但单次量变小。

更直观的方法是打开内核的脏页跟踪节点。内核提供了 debugfs 的接口,挂载后可以观测每个回写线程的状态。不过在正规生产环境,挂 debugfs 需要谨慎,建议只在测试机操作。

4.3 参数调整验证

把 dirty_background_ratio 改成 1,dirty_ratio 改成 2,再执行之前的写文件操作。你会看到 Dirty 的峰值大幅减小,回写发生的频率明显变快。此时 iostat 里的 wMB/s 曲线会从“低频大幅”变成“高频小幅”。

这个实验的价值不是给出一个“最优参数”,而是让你看清参数对回写行为的真实影响路径。一些系统设计文档里写得云里雾里的“watermark”概念,在这组对照实验中会变得非常直观。

4.4 用 bpftrace 观察 flusher 线程

Linux 内核自带的 ftrace 和 bpftrace 工具可以用来观察内核函数调用。我常用一个简单探针查看回写线程的活动:

bpftrace -e 'kprobe:balance_dirty_pages { @[comm] = count(); }'

如果当前系统里有进程大量写文件,这个探针会统计出所有进入限速逻辑的进程名。当看到某个业务进程频繁触发 balance_dirty_pages,说明它的写速率超过了后台回写能力,正在被内核限速。此时优化方向通常不是调 IO 调度器,而是检查磁盘本身的写入吞吐和 dirty_background_ratio 是否匹配。

注意:bpftrace 需要 root 权限,并且内核开启了 kprobe 支持。某些云环境内核默认禁用了相关 BPF 功能,需要先确认可用性,再去拉具体的回写函数。

5. 常见问题与排查技巧实录

回写机制相关的问题排查,靠经验判断往往能找到方向,但很多场景真正常踩的坑比预期要多。我整理了近几年遇到过最多的几类问题。

5.1 掉电丢数据:延迟回写的代价

最经典的教训:一个应用只依赖 write() 返回成功,就认为数据已经保存。某次一场意外掉电,大量业务日志丢失,业务方第一反应是磁盘损坏了,查了半天发现磁盘没坏,原因是日志文件的数据还在 page cache 里,根本没落盘。解决办法是在日志库或业务应用的写入路径上增加 fsync 或 fdatasync 刷盘,或者调整写文件的打开方式为 O_SYNC。

这里也提醒一个取舍:全量加 fsync 会让每个写请求都等待磁盘 IO,性能暴跌。常见的折中方案是定时批量 fsync,比如每 100 条日志做一次落盘,既保证数据窗口可控,又不至于让每条日志都变成一次独立刷盘。

5.2 写放大与 IO 抖动

后台回写触发得太频繁,会引发不必要的小 IO 持续占用磁盘,造成明显的 IO 抖动。这种情况常见于把 dirty_background_ratio 调得过低的环境。比如为了“保险”把 ratio 设为 0,内核会随时启动回写线程,大量的小写入请求频繁刷入磁盘,不仅拖慢业务的主 IO,对 SSD 的闪存磨损也不友好。

实际调优时,需要核算业务对数据丢失窗口的可接受程度。以内核默认 30 秒过期时间为例,假设业务能容忍丢失 10 秒的数据,那么 dirty_background_ratio 并不是越极端越好。可以计算单位时间写入量,例如每秒写入 200MB,10 秒也就是 2GB;此时把 background 阈值设为比该值略低,能保证在正常峰值下后台回写已启动,同时又不至于过早频繁刷盘。

5.3 缓存失效与页回写失败的排查

脏页长时间写不出去,可能的原因包括:磁盘 IO 错误、文件系统只读切换、或者 flusher 线程阻塞。排查时先看系统日志,确认是否存在 IO error;再用 dmesg 确认文件系统是否因为错误被强制 remount 为只读。如果写失败的根因在页回写,Dirty 值会持续不降,同时 Writeback 也可能卡住。

有一种常见现象:用 sync 命令强制刷盘时,命令长时间卡住不返回。这时候不要简单认为系统“假死”,要检查是否有掉盘的磁盘,或者是否有文件系统在等待某个慢 IO 完成。查看进程状态可以发现 sync 被阻塞在 D 状态(不可中断睡眠),这是典型的 IO 子系统问题。

5.4 常见问题速查表

现象可能原因建议排查/行为
Dirty 值居高不下,无回写dirty_background_ratio 设置过大,或磁盘突然变慢检查 iostat、dmesg
写文件卡顿严重达到 dirty_ratio 触发了前台限速增大两档水位差值
sync 命令长期不返回flusher 线程或 fs 阻塞,等待慢盘 IO检查 IO_ERR 和进程 D 状态
小文件写入 IO 抖动background 回写阈值过低适度上调 background 比例
容器内内存充足但频繁刷盘参数基于宿主机统计考虑使用 bytes 版本按绝对值控制
掉电后丢大量数据数据滞留 cache 未落盘业务层加 fsync/fdatasync

5.5 一个容易忽略的细节:文件系统层的协同

page cache 回写并不只涉及内存层的 flusher 线程。ext4、xfs 等文件系统部分日志模式会在事务提交时请求内存层回写相关页,比如 ext4 的 data=ordered 模式,文件数据在元数据提交前必须落盘。这个协同逻辑会导致即使内存层参数没有触发回写,文件系统自身的日志提交也会把脏页拉到磁盘。排查问题时,如果发现实际回写行为与参数预期不符,把文件系统的挂载参数(比如 data 模式、barrier 开关)也纳入排查范围,往往能找到真正的原因。

6. 一些没写进文档的操作建议

最后分享几点我自己实践后的体会,不一定适用于所有环境,但值得作为参考。

首先,把 dirty_background_ratio 和 dirty_ratio 看作系统的性格参数,而不是固定的调优终点。它们表达的其实是系统愿意为写吞吐承担多大的内存风险。刚入门调优时,我倾向于把参数往“小”了调,觉得数据落盘快点总是安全的,结果频繁小 IO 让 SSD 的写放大比预期高了不少。后来改成结合实际写入速率计算合适的阈值,效果好了很多。

其次,调完参数一定要做异常场景验证,尤其是断电或进程被杀掉的测试。很多问题在业务高峰期不会暴露,反而是在这类异常情况下才露出真面目。我一般会在测试环境用 fio 或自研脚本压出稳定的写流量,再随机触发 kill -9 或拔电测试,观察恢复后文件系统的一致性。如果这一轮测试大量丢数据,说明不只是 page cache 参数问题,业务层持久化设计大概率也需要补强。

回写机制还有一些有意思的扩展方向,比如 mmap 私有映射的 writeback 语义、tmpfs 的页回收行为、cgroup 写回隔离、以及块层 io throttle 的协同,都值得进一步深挖。内核这块内容的技术纵深极大,参数只是最表层的入口,真正深入之后会发现,整个 writeback 子系统是存储栈里设计约束最复杂的部分之一,理解它的演进历史,对理解现代存储系统也很有帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询