页缓存回写的本质:从“先放内存里”到“脏页落盘”的全过程
搞存储和内核的同学应该都清楚,page cache 是 Linux 文件系统性能的基石。但很多人对它的认知停留在“读缓存、写缓冲”的层面,一旦遇到写数据丢失、I/O 尖刺、内存回收卡顿这类问题时,才发现对回写机制的理解不够深。
回写机制(writeback,也常叫回写或刷新)是 page cache 最容易被忽视、但实际踩坑最多的一环。它决定了两件大事:一是数据什么时候真正落到磁盘,二是系统卡顿到底是在等谁。这篇文章我会结合自己维护服务器和调优存储栈的实操经验,把 page cache 的回写机制从头到尾拆一遍,尽量用大白话讲清楚背后的原理,再给出实际可用的排查和调优方法。无论你是做应用开发、数据库运维,还是搞内核和虚拟化,这部分内容都值得花时间读透。
1. 先搞清楚“写文件”这条路:page cache 在哪一环
1.1 一次 write() 调用到底做了什么
用一个最简单的场景开始。你写了一个 Python 脚本,或者直接用 echo 往文件里写了一段内容:
echo "hello writeback" > /tmp/test.txt这一行命令走完,数据真的到硬盘上吗?答案是不一定。在绝大多数情况下,数据只是写到了内存里的 page cache,然后立刻返回了成功。这个“写内存就返回”的设计,是回写机制存在的根本原因。
具体过程是这样的:write() 系统调用进入内核后,VFS(虚拟文件系统)层会根据文件的偏移量计算需要操作的文件页,然后调用实际文件系统(比如 ext4、xfs)的地址空间操作集。如果内存中已经有了对应的缓存页,就直接在这个页上修改数据;如果还没缓存页,就从 page cache(也叫 page allocator 中可回收的那部分内存)申请一个新页,把文件内容读到这个页里,再把你写入的数据合并进去。
这里有几个关键点。第一,这个页是物理内存页,属于 page cache 的一部分,而不是直接分配一块用户态内存把数据复制到磁盘。第二,写完之后,内核会把这个页标记为脏页(dirty page),意思是“内存中的内容和磁盘上的内容不一样了”。第三,write() 返回成功只代表数据写到了内核的 page cache,绝不等同于落盘成功。
这个设计与 CPU 的 cache 设计有异曲同工之处。CPU 缓存了内存的数据,通过写回(write back)策略推迟写主存,以提高性能。page cache 对磁盘也是同样的逻辑:把随机小写入变成内存里的合并操作,把大量小 I/O 积攒成后续的大块顺序写。可以说,没有回写机制,几乎所有的写操作都会变成一次磁盘寻道,性能差到没法用。
1.2 脏页是怎么累积起来的
既然 write() 只是把数据写进 page cache 并标记脏页,那就出现了一个问题:脏页不会无限制累积,否则内存会被写满,掉电时丢失的数据也会更多。回写机制要做的事,就是在“尽量延迟写盘以获得合并性能”和“不让脏页无限积压导致内存耗尽/数据安全受损”之间找一个平衡点。
脏页的标记和统计在内核里有专门的数据结构,比如每个地址空间(inode 对应的)都有自己的脏页链表,而全局则有 per-BDI(Backing Device Info,后端设备信息)的脏页统计。这些统计值在很多整机层面可以看到,后面我会详细说。
在实际运行中,脏页累积速度取决于两个因素:应用的写速度,以及后台回写线程处理脏页的速度。如果应用疯狂写,回写线程来不及处理,脏页数量就会持续涨高;涨到水位线之后,内核就会触发更激进的处理方式,这时候你就看到应用变卡了。
1.3 谁来负责把脏页刷下去:“回写线程”家族
脏页不能自动落盘,得有人去推动。内核里有几个角色负责这件事:
- 周期性回写线程(flusher thread):每个 BDI 都有一个或多个 flusher 线程,也叫 writeback worker。它们会周期性地被唤醒,把过期超过一定时间的脏页刷下去。
- 后台回写(background writeback):当脏页比例超过
dirty_background_ratio阈值时,内核会唤醒 flusher 线程执行后台回写。这个回写是异步的,不阻塞应用继续写入。 - 主动回写(direct/foreground writeback):当脏页比例超过更严格的
dirty_ratio阈值时,任何继续执行的 write() 都会被阻塞,同步等待脏页被刷下去,直到比例降回安全线以下。这就是大家经常遇到的“写文件突然卡住”的原因。 - 用户态主动刷盘(sync/fsync/fdatasync):应用自己调用 fsync() 等接口时,会把特定文件或所有文件的数据强制同步落盘,不管脏页比例是否超标。这类操作往往是最消耗时间的。
这几个角色你理解成“保洁队”就行:平时有固定巡逻扫地上的垃圾(周期性回写),垃圾多了喊保洁来清理(后台回写),垃圾堆到快影响走路了就直接拦住行人先清垃圾(主动回写),主人特意要求立刻打扫房间(sync/fsync)。接下来我逐个展开。
2. 回写机制的触发条件与方法论
2.1 “多长时间没写”和“攒了多少量”两个维度
回写行为受两个维度控制:时间和量。
时间维度的控制参数是dirty_expire_centisecs,单位是百分之一秒。这个参数定义了脏页最长可以“存活”多久。默认值是 3000,也就是 30 秒。如果某页脏了超过 30 秒,即使脏页总量很少,flusher 线程也会把它刷下去。
量维度的控制就是我们前面提到的两个水位:dirty_background_ratio和dirty_ratio。默认情况下,前者是 10(百分比),后者是 20(百分比)。意思是:当脏页占系统总内存的 10% 时,后台回写线程启动;超过 20% 时,写入操作会阻塞等待。
这个“时间和量”的设计非常精妙。如果只看时间,那小写入场景下磁盘会不断被唤醒,能量和性能都浪费;如果只看量,那长时间空闲后再突然大写入,内存会被快速占满。同时从两个维度控制,可以做到低负载下不打扰、高负载下不失控。
2.2 后台回写(background writeback)的执行过程
当脏页数量超过了dirty_background_ratio对应的水位,后台回写机制就被激活。注意这里说的是超过了,而不是等于,内核里的判断有严格的小于/大于逻辑,具体代码路径大致是:
balance_dirty_pages() -> 判断当前脏页数量 如果脏页比例 > dirty_background_ratio bdi_start_background_writeback()bdi_start_background_writeback()会唤醒对应的 flusher 线程,让它去扫描并回写脏页。后台回写的主要特点是不阻塞应用,应用可以继续执行 write(),系统整体的写入性能不会下降,只是在后台多占一些磁盘带宽。
这里有一个很多人忽略的细节:后台回写时会设置回写范围(writeback range),它通常会挑选那些最老(超过 expire 时间)的脏页优先处理,而不是简单地从头到尾刷。这样做的目的是保证过期的脏页尽快落盘,同时避免把刚写入的、还没累积成大片连续区的页也一并刷掉,浪费磁盘寻道。
2.3 主动回写(等待式回写)为什么会卡住你的进程
这是运维过程中最常见的“灵异现象”:某台服务器上业务的写入延迟偶尔飙到几百毫秒甚至几秒,看磁盘 I/O 又不算高。查来查去,最后发现是脏页比例触顶。
当脏页比例超过dirty_ratio时,内核会进入主动回写流程。执行路径是:
- 应用线程在
balance_dirty_pages()中检测到脏页比例可能超过上限。 - 该线程会参与回写工作,自己也去做一部分脏页刷盘操作,同时等待其他回写线程把水位降下来。
- 直到估算的脏页比例降到目标值(通常是
dirty_ratio减去一个安全余量)以下,等待才会结束,write() 才返回。
这种机制有个很直观的类比:当洗手间排队人数实在太多时,新来的人不是直接进洗手间,而是在门口等着,并且有的等着的人还会被拉去帮忙打扫卫生,打扫干净一批才放一批人进来。
所以,为什么写入会变慢?因为内存里的脏页已经“堆不下”了,系统进入了强制清理模式。之前攒下来的脏页,此刻成了悬在头上的压力。
2.4 周期性回写与 flusher 线程的工作节奏
除了水位触发,flusher 还会周期性启动。dirty_writeback_centisecs参数控制这个周期,默认是 500,也就是 5 秒。每隔 5 秒,flusher 线程会被唤醒一次,检查是否有脏页到期需要刷写。这里的“到期”是根据dirty_expire_centisecs来判断的。
这里有个容易混淆的点:dirty_writeback_centisecs是“多久检查一次”,dirty_expire_centisecs是“脏页多久必须被刷”。这两个参数经常被人混在一起。我用一句话区分:前者是巡逻频率,后者是垃圾存放期限。
dirty_writeback_centisecs调小,意味着检查更频繁,内存中的脏页驻留时间更短,数据安全性更高,但磁盘被唤醒的次数更多;调大则相反,脏页可以更长时间留在内存里,磁盘工作更集中,但掉电丢失数据的风险窗口也更长。
3. 关键参数与内核配置,别只记默认值
3.1 那些可以调的参数,每一个背后的取舍
和 page cache 回写直接相关的核心参数主要在/proc/sys/vm/下。我整理了一张表,方便边看边对照:
| 参数名 | 默认值 | 作用 | 调节倾向 |
|---|---|---|---|
dirty_background_ratio | 10 | 后台回写启动阈值(占总内存百分比) | 调小则脏页更早被刷,内存压力小,但磁盘唤醒多 |
dirty_ratio | 20 | 主动回写/阻塞阈值 | 调小则提前阻塞,减少单次积压量 |
dirty_background_bytes | 0 | 以字节数表示的后台回写阈值,与 ratio 互斥 | 对内存很大的机器更直观、更精确 |
dirty_bytes | 0 | 以字节数表示的阻塞阈值 | 同上 |
dirty_expire_centisecs | 3000 | 脏页最长驻留时间(百分之一秒) | 调小加速刷盘,增加数据安全性 |
dirty_writeback_centisecs | 500 | 回写线程巡检间隔(百分之一秒) | 调小更频繁巡检,更及时清理到期页 |
dirtytime_expire_centisecs | 43200000 | 脏时间标记的过期时间(与 inode timestamps 相关) | 涉及 lazytime 特性,普通场景不用动 |
关于dirty_background_bytes和dirty_background_ratio,内核的规则是两者谁被显式设置(非 0)就用谁,如果同时设置了,以 bytes 版本为准。bytes 模式适合超大规模内存的机器,因为总内存很大时,比如 512GB,10% 就是 51GB 的脏页上限,刷起来是相当大的工作量,用字节数可以设成比如 8GB、16GB,心里更有数。
调节思路因人而异,但一个基本逻辑是:如果服务器内存大但磁盘慢,宁可让后台回写提前启动,也不能等到脏页积压太多才处理。比如一台 256GB 内存、磁盘是普通机械盘或低端 SSD 的机器,把dirty_background_ratio从 10 调到 5,dirty_ratio从 20 调到 10,能让脏页数量峰值减半,写阻塞幅度大幅降低。
3.2 如何观察脏页数量和回写行为的“水位线”
调参之前先要会看现状。内核通过/proc/vmstat暴露了脏页和回写相关的计数器,其中有几个字段值得关注:
nr_dirty:当前脏页的总数,单位是页(通常 4KB)。nr_writeback:正在被写回到磁盘的页数。nr_writeback_temp:临时写回状态,很少用到。pg_written:累计写回的页数。pg_writeback:累计触发写回的次数。pg_activate、pg_deactivate等回收相关指标也能侧面反映内存压力。
比如你想看一下当前脏页有多少 GB:
grep -E 'nr_dirty|nr_writeback' /proc/vmstat # 输出示例: # nr_dirty 24576 # nr_writeback 128 # 换算成 MB(假设页大小 4KB): # 24576 * 4 / 1024 = 96 MB更直观的方式是用top或htop看内存那一栏的 cached 值,但这只能看出 page cache 整体大小,看不出脏页有多少。/proc/vmstat是最精确的脏页数量来源。
另外,通过vmstat 1也可以观察到bo字段(块设备每秒写入量)和wa字段(等待 I/O 的时间占比)。如果wa高,再结合nr_dirty突然高,基本可以判断是回写引起的心跳变尖。
3.3 从内核源码路径看回写的完整生命周期
为了加深理解,梳理一下一次脏页回写的完整生命周期。这个生命周期从应用调用 write() 开始,到数据真正落盘结束:
- 应用调用 write(),数据进入 page cache,对应的页被标记为脏页。
- 如果脏页比例超过
dirty_background_ratio,内核唤醒 flusher 线程。 - flusher 线程扫描目标 inode 或整个 BDI 的脏页链表。
- 对每个要刷的页,调用文件系统的
writepage或writepages方法。 - 文件系统将页内容生成 BIO(Block I/O),提交到块设备层。
- 块设备层经过 I/O 调度器排队,最终通过驱动写入磁盘。
- 写盘完成后,页被标记为干净页,并从脏页队列中移除。
- 如果此时有进程正在等待回写完成(如主动回写或 fsync),它们会被唤醒。
整个链路里,第 5 步到第 7 步耗时最长,因为涉及真正的磁盘 I/O。如果磁盘性能差或者 I/O 调度器排队拥堵,回写周期就会拉长,脏页水位下降慢,后续写入的阻塞时间就会被放大。
顺带说一个比较偏的细节:在 ext4 上,文件系统还维护了一个 journal(日志)机制,数据本身先写入 journal,然后再写入文件系统的数据区。这就意味着在回写路径上,可能还有一次“写两次”的开销。xfs 也有类似但实现略有不同。这种文件系统层面的追加开销,往往比回写策略本身更容易成为性能瓶颈,排查时需要留意。
4. 实操经验:调优、排查与避坑记录
4.1 回写机制与 fsync/sync 之间的关系,你要重新认识
很多应用开发者把 fsync 简单地理解为“保存文件”,但 fsync 的内部成本一直被低估。一次 fsync 不仅要把当前文件的脏页刷下去,还要等待文件系统的日志提交完成,整个过程可能涉及多条 I/O 写入。对机械盘来说,一次 fsync 可能意味着一次寻道加写入,如果数据库每秒做几十次 fsync,磁盘的工作量会相当大。
这就引出一个调优实践中常见的矛盾:page cache 回写机制本身追求“延迟写盘 + 批量合并”,但 fsync 追求“立即落盘”。两者天然冲突。最典型的场景是数据库的 redo log。为了数据安全,数据库每提交一个事务就 fsync 一次日志文件;而 page cache 的批量合并能力在这个场景下完全发挥不出来,因为每次 fsync 都强制清空脏页。
所以你看回写问题时,不能只盯着内核水位参数。如果应用层大量使用 fsync,那么优化方向应该是减少 fsync 频率,或者使用批量提交(group commit)等方式。我处理过一个数据库写入延迟的案例,当时脏页比例并不高,但每次到达提交点就要 fsync 两次,最后优化方向是合并提交周期,显著缓解了延迟尖刺。
4.2 排查“写入卡顿”时的黄金检查顺序
服务器出现写入延迟高,很多人第一反应是磁盘坏了或者 I/O 跑满。但通常回写机制引起的卡顿和磁盘硬件故障表现类似,需要系统化排查。
我在定位这类问题时,一般按下面的顺序来:
第一步,先看当前脏页水位:
cat /proc/vmstat | grep -E 'nr_dirty|nr_writeback'如果nr_dirty对应的 MB 数大于总内存的 20%,且nr_writeback也高,那么很可能已经触发了主动回写。
第二步,看 I/O 等待和磁盘利用率:
iostat -x 1重点看%util、await和w_await。如果%util接近 100%,说明磁盘本身已经饱和。如果%util不高但await很高,需要考虑 I/O 调度器排队或者磁盘自身的问题。
第三步,看回写线程是否在干活、进程处于什么状态:
ps -eo pid,comm,stat,wchan | grep -E 'writeback|flusher'如果看到进程状态为 D(不可中断睡眠),大概率是在等 I/O。还可以用perf或strace跟踪是哪个系统调用卡住、卡在哪一层的函数上。
第四步,如果发现是脏页触顶引起的,就要判断应用磁盘写模式是不是偏顺序大块写。如果是,可以适当调大dirty_background_bytes,让后台刷盘更激进;如果写模式是散乱小写,那调大水位没有意义,反而会更糟。
这个检查顺序我总结成一句顺口溜:先看水位、再看磁盘、再看进程、最后调参。
4.3 针对不同业务场景的参数配置参考
不同业务对回写的敏感点完全不同,照搬别人调好的参数往往会帮倒忙。我列几个常见场景的配置倾向,方便对照实验:
| 业务类型 | 痛点 | 推荐调整方向 | 说明 |
|---|---|---|---|
| 数据库(高一致性要求) | 数据安全、事务提交延迟 | 调大dirty_background_ratio的相反方向——调小,让脏页快速落盘;同时应用层减少 fsync 频率 | 数据安全优先于内存缓存效率 |
| 大数据写入型业务(日志、批处理) | 积压大量顺序写 | dirty_background_ratio调到 5~10,dirty_ratio调到 15~20,同时考虑增加 flusher 线程数量 | 瓶颈在磁盘带宽,而非内存 |
| 虚拟机/容器宿主机 | 多虚拟机竞争磁盘 I/O | 调小dirty_background_ratio,防止某个虚机拖垮整体 | 单一虚机的脏页暴增会抢占宿主机回写带宽 |
| 内存超大容量机器 | 大内存导致百分比水位太高 | 用dirty_background_bytes和dirty_bytes替换 ratio,设成合理绝对字节数 | 大内存下 ratio 计算出的量不直观且偏大 |
这里强调一下,上面给的只是倾向,不是标准答案。每套系统都要结合实际的写入模型来压测调整。调参之后,要持续观察压测期间的nr_dirty变化和写入延迟分位数,而不是只看平均延迟。平均延迟会掩盖尖刺问题。
4.4 掉电、断电与“脏页不丢”的错觉
回写机制带来的最大风险是数据丢失。如果系统突然断电,所有仍在 page cache 中未落盘的脏页都会消失。对普通文件来说,可能只是丢几秒的写入;对数据库来说,如果没有主动 fsync 保证日志落盘,丢失的可能是整个事务。
Linux 提供了几个机制来缓解这个问题。UPS(不间断电源)是物理层面的第一道防线,不是内核能解决的。内核层面,sync命令可以强制刷盘,但依赖人工在断电前执行;systemd 和相关服务在关机时会执行 sync,但如果直接断电就没办法了。
这里特别提一下 syncfs() 和 sync() 的区别。sync() 会把系统中的所有文件系统都刷一遍;syncfs() 只刷指定的文件系统。在容器或者虚拟化环境中,应用往往只关联部分文件系统,使用 syncfs() 可以减少不必要的等待。
另一个容易被忽视的点是文件系统本身的日志模式。ext4 有三种日志模式:journal、ordered、writeback。默认 ordered 模式保证数据先于元数据落盘,但数据块仍然可能延迟写入。把 mount 选项改成 data=journal 模式,可以提高崩盘一致性,但性能代价高。对不需要极高一致性的场景,没必要这么做。
4.5 我踩过的坑和记下的教训
踩坑一:某次压测时,我把dirty_background_ratio调大以提升写入吞吐,结果系统 OOM。原因是脏页占用内存过多,reclaim 机制为了释放内存,把大量干净页回收,但脏页必须先写盘才能回收,于是形成内存压力不断上升的死循环。那次之后我养成了调大水位前先确认系统是否有足够可回收内存的习惯。
踩坑二:有一台机器频繁出现 30 秒左右的周期性写放大,查了很久发现是dirty_expire_centisecs默认 30 秒到期的集中刷盘效应。很多页是分散写入的,到达 30 秒后同时被刷盘,形成 I/O 叠加峰值。解决办法是把 expire 时间调小到 10 秒,让刷盘动作提前并分散开。这个现象在大量小文件写入的环境里很常见。
踩坑三:在容器环境中,我曾经只调了宿主机的回写参数,忽略了容器自身的限制。容器内的 page cache 是由宿主机统一管理的,但内存限制(cgroup)会限制容器可用的脏页量。如果容器内存 cgroup 比较小,脏页可能很快触顶,表现为容器内写入卡顿,而宿主机全局水位并不高。排查时千万不要只看宿主机全局参数,要结合 cgroup 的内存限制一起分析。
这些教训说到底都是对回写机制的理解不够全面造成的。不要只看某个参数的表面含义,要理解它在整个内存、存储、I/O 栈中的位置。
5. 实战复盘:一次典型的“写入尖刺”定位全过程
为了让你更直观地看到上面的方法怎么用,我把之前处理过的一个模拟项目案例完整复盘一遍。项目代号叫“某数据采集平台”,症状是每过几分钟就会出现一次写入延迟尖刺,持续时间几百毫秒到 1 秒不等。
处理过程如下:
第一步,用sar -B和sar -d查看系统级的内存和磁盘指标,发现 page 相关的 pgpgin/pgpgout 并没有异常增长,但磁盘 await 周期性飙高。
第二步,用cat /proc/vmstat盯住nr_dirty和nr_writeback的变化,发现每当延迟尖刺出现前,nr_dirty都会快速积累到总内存的 18% 左右,随后触发主动回写。
第三步,用iostat -x 1确认磁盘的写等待时间和利用率。结果%util只有 60%~70%,不算饱和,说明问题不在磁盘极限能力,而是回写策略引起的“阶段性集中写”。
第四步,用perf top和ps查看进程状态,确认阻塞点在balance_dirty_pages相关路径上。
第五步,观察到这个采集平台的写入模型是周期性批量写日志,每次写大量数据但间隔较久。于是把dirty_background_ratio从默认的 10 调到 5,让后台回写更早介入;把dirty_ratio从 20 调到 12,避免脏页积累过多才触发阻塞式回写。
第六步,调整完成后重新压测,nr_dirty的峰值从 18% 左右降到了 8% 以下,延迟尖刺基本消失,await曲线也变得平稳。
这个过程其实并不复杂,但如果你一开始不去看脏页水位,很容易被“磁盘 I/O 高”带偏方向,折腾半天换硬件或调 I/O 调度器,结果问题还在。回写机制这块的知识,能帮你少走很多弯路。
6. 一些值得记住的通用原则
回写机制虽在内核,但和上层应用的设计息息相关。结合自己的经验,我认为有几条通用原则值得你在规划系统和应用时牢记。
第一条原则:内存不是磁盘的替代品,只是缓冲。无论 page cache 多大,脏页总有落盘的时候。设计应用时,不要假设写入后数据一定安全;关键数据要主动落盘或交给我们前面说的 fsync 机制处理。
第二条原则:调优回写参数不是调得越大越好,也不是越小越好。它是“数据安全性、内存消耗、磁盘带宽、延迟尖刺”四者之间的平衡。任何只看单一指标的调优,都可能引发新的问题。比如为了提升吞吐而把 dirty_ratio 调到 50%,一旦磁盘跟不上,阻塞时间会成倍放大,这是很多性能事故的根源。
第三条原则:压测和监控要关注分位数,而不是平均值。回写机制引发的延迟通常是尖刺型而非持续型,平均延迟看不出来,但 p99、p999 会非常明显地恶化。监控nr_dirty、nr_writeback和历史 I/O 延迟能快速暴露问题。
第四条原则:内核参数只是最后一道阀,不是第一道优化方向。如果写入模型本身散乱、fsync 过多、数据没有合理分组,调整内核参数只是勉强兜底。更好的做法是先在应用层做好写缓冲、批量提交和 I/O 分组,让内核回写机制在半空闲的状态下工作,而不是天天被逼到触顶。
最后再分享一个小技巧。如果业务允许短时间的数据丢失风险,你可以把vm.dirty_writeback_centisecs调大一些,比如从 500 调到 1000,减少巡检频率。但这只适合非关键日志或缓存类数据场景。关键业务数据上面,我从来都是反着调,把回写周期压短,宁可多写几次,也不愿冒丢失窗口扩大的风险。
回写机制并不是一个“配好就不管”的黑盒。理解它背后的触发条件和工作流程,你才能在遇到写入卡顿时从容排查,在规划架构时做出合理取舍。希望这篇文章能帮你在 page cache 这条路上少踩一些我踩过的坑。