1. 脏页从哪来:写操作背后的内存账本
很长一段时间里,我在排查服务器性能问题时,总是先看 CPU、再看磁盘 IO,直到被一次“负载不高但写入极慢”的故障折磨了一整天才反应过来——真正拖慢系统的是内存里的脏页,而不是磁盘本身。从那以后,我养成了一个习惯:遇到写入性能异常,先打开/proc/meminfo看脏页水位,再决定要不要怀疑磁盘。
如果你也是第一次接触这个概念,可以从一个生活场景切入。想象你在一家餐厅后厨工作,客人点了菜,你不会每炒一道菜就往洗碗间跑一趟,而是攒够几盘或者等洗碗工催促了再一起送过去。Linux 的写操作也是同样的逻辑:应用程序调用write()时,数据并不会立刻落到磁盘,而是先写进内存中的页缓存(page cache),并给这些页面打上“已修改”的标记。这个“已修改但还没写回磁盘”的页面,就是脏页(dirty page)。
这里要区分两个概念:页缓存和脏页。页缓存是内核用来缓存文件数据的通用内存池,任何读过的文件内容都可能留在里面,方便下次命中。只有那些被修改过的页缓存页面,才会从普通缓存状态变成脏页状态。换句话说,脏页是页缓存的一个子集,是被写过但尚未落盘的页面。
脏页的产生看似简单,实际上有三个必要条件。第一,应用程序对文件发起写操作,比如echo重定向、数据库的 redo log 写入、日志文件的追加等。第二,数据落在页缓存里,而不是直接通过 O_DIRECT 绕过缓存。第三,内核还没来得及把这块数据写回磁盘。第二点很重要,因为 O_DIRECT 模式会绕过页缓存,这种情况下根本不会有队列化的回写问题,但代价是每一笔写入都要同步等待磁盘完成。
既然脏页最终总归要写回磁盘,为什么内核不立刻执行呢?这就涉及性能与安全性的权衡。如果每一笔写操作都同步刷盘,应用进程会被磁盘延迟卡死,批量写入场景的吞吐量会断崖式下降。但反过来,如果脏页无限堆积,万一机器断电或者内核崩溃,内存里的修改就全丢了。所以内核设计了一套回写机制,既允许脏页在内存里攒一会儿,又通过多种条件保证它们不会无限期滞留。这也是我写这篇东西最想讲清楚的部分:回写不是“想起来就写”,而是有一套完整的触发逻辑。
2. 脏页回写的四种触发方式:定时、水位、同步与全局压力
内核里和脏页回写相关的核心代码在fs/fs-writeback.c,日常使用中我们不需要读源码,但需要理解它的四种触发路径。这四种路径互不排斥,可能同时生效,而且不同路径的优先级和写入方式也不一样。
第一种是定时回写。内核有一个周期性任务,每隔dirty_writeback_centisecs的时间(默认值是 500,单位是百分之一秒,也就是 5 秒)唤醒一次,检查是否有脏页需要处理。如果系统里存在超过dirty_expire_centisecs(默认 3000,即 30 秒)还没有被写回的脏页,就会触发一次过期回写。简单说,即使系统非常空闲,脏页在内存里待满 30 秒后也会被强制写盘。这个机制保证任何脏页都不会滞留太久。
第二种是比例水位触发。内核设置了两个阈值:dirty_background_ratio和dirty_ratio。当脏页占内存的比例超过dirty_background_ratio(默认 10%)时,内核会启动后台回收线程(flush 线程)开始写回,此时应用进程不会被阻塞。当脏页比例继续攀升,超过dirty_ratio(默认 20%)时,内核就会强制让发起写操作的进程同步等待,一边写一边刷盘,直到脏页比例降回安全线以下。这个过程在业务侧的表现就是:写入突然变慢,iostat里await升高,但 CPU 和磁盘使用率都不高。
第三种是同步写回。当某个进程执行fsync()、fdatasync()或者文件关闭时,内核会直接把该文件关联的脏页同步写盘,等待完成才返回。数据库场景尤其依赖这层保证,MySQL 的innodb_flush_log_at_trx_commit=1就是靠fsync确保 redo log 不丢的。需要特别注意的是,fsync触发的不只是当前进程的脏页,而是整个文件的所有脏页,如果多个进程同时写同一个文件,其中一个调用fsync,其他进程的数据也可能被跟着刷下去。
第四种是内存压力触发。当系统内存紧张时,内核要回收页缓存来满足其他内存请求,这时候脏页就不能直接丢弃了,必须先写回磁盘再释放。这个路径最容易引发性能抖动,因为它同时涉及内存回收和磁盘写入两条链路。我在实际排查中见过不少这类案例:系统内存只剩 200MB,kjournald或者kswapd进程突然占满 CPU,磁盘写入开始飙升,实际上就是在处理内存压力触发的脏页写回。
四种触发方式的侧重点完全不同。定时回写负责最终一致性,比例水位负责平滑控制节奏,同步写回负责数据安全,内存压力回写负责应急。现实中你看到的写入波动,往往是好几种机制叠加的结果,很少是单一因素。这一点在排查时尤为重要:光看脏页比例还不够,还要确认是哪个触发源在主导。
3. 内存页交换:物理内存不够时的腾挪术
聊完脏页,必须接着聊另一块内容:内存页交换(swap)。脏页回写解决的是“数据往磁盘同步”的问题,而页交换解决的是“物理内存不够用时,把哪些页挪到磁盘”的问题。两条链路都会产生磁盘 IO,也都受内存水位影响,但它们的对象和处理逻辑很不一样。
swap 的基本原理并不复杂:当物理内存不足时,内核把一部分内存页写到磁盘的 swap 分区或 swap 文件中,释放物理内存给更需要的进程;当进程再次访问这些页面时,内核通过缺页异常(page fault)把数据从 swap 里读回内存。整个过程对应用是透明的,但代价是 swap 的读写速度比物理内存慢几个数量级,所以 swap 用得多通常意味着性能在恶化。
这里有个关键参数叫swappiness,取值范围是 0 到 200(新版内核放宽到 200,旧版常见范围是 0 到 100)。它控制的是内核在内存回收时倾向于回收匿名页(进入 swap)还是文件页(直接丢弃或回写)。swappiness值越高,内核越积极地把不常用的匿名页换出;值越低,内核越倾向于回收文件页。默认为 60,表示两种回收的倾向相对平衡,实际效果是“文件页优先,但匿名页也有可能被换出”。很多运维喜欢把vm.swappiness调到 0 或 1,目的就是尽量不用 swap,这在内存足够时是合理的,但在内存真的不够时反而会让系统陷入更深的危机,后面我会细说。
页交换的执行主体是内核线程kswapd和直接回收路径。kswapd是一个后台守护线程,它监控内存水位线,当空闲内存低于某个阈值时开始回收内存页。回收的目标内存页分两类:文件页和匿名页。文件页如果干净(不是脏页),直接丢弃就行,磁盘上还有副本;如果是脏页,需要先写回再丢弃;匿名页则只能先换出到 swap。所以你会看到内存紧张时,脏页回写和 swap 几乎总是同时发生——因为kswapd既要处理文件页的脏页写回,又要处理匿名页的换出。
和脏页回写类似,页交换也分轻量级和重量级路径。轻量级是kswapd在后台慢慢回收,不阻塞应用;重量级是当内存水位跌到极致时,进程在自己执行内存分配时同步参与回收,也就是“直接回收”(direct reclaim)。直接回收是性能杀手,它在进程申请内存的路径上同步执行回收操作,进程会卡住,系统整体吞吐量骤降。判断系统是否进入了直接回收状态,可以看/proc/vmstat里的pgscan_direct和pgsteal_direct字段,这两个值在持续快速增长时,基本可以断定内存压力已经非常严重了。
还有一个值得注意的现象:文件页在内存压力下被丢弃后,如果进程再次读这个文件,还会从磁盘重新加载到页缓存。这就会造成一种奇怪的现象——内存明明很紧张,swap 的占用却不高,而磁盘读入量却很大。很多同事看到si(swap in)不高就认为内存没问题,其实真正的瓶颈是页缓存的往复颠簸。
4. 关键参数与水位线:怎么调才不踩坑
每次有人问我 Linux 内存相关的调优怎么入门,我都会先让他记住一句话:先看水位线,再看参数。水位线(watermark)是内核内存管理的核心状态指示;参数只是控制水位线附近的决策行为。如果不理解水位线,直接调参数就是盲人摸象。
内存水位线在/proc/zoneinfo里可以看到,主要分三种:pages_min(最低水位)、pages_low(低水位)、pages_high(高水位)。当空闲内存低于pages_low时,kswapd开始后台回收;低于pages_min时,内核会启动直接回收,同时可能触发 OOM killer。了解这个机制后,再看vm.min_free_kbytes就豁然开朗了:这个参数可以直接拉高pages_min的值,让内核更早开始回收,而不是等到内存快耗尽才手忙脚乱。不过调大min_free_kbytes意味着内核会保留更多空闲内存,可用内存变少,过度设置反而会降低缓存利用率。
再回到脏页参数。vm.dirty_background_ratio和vm.dirty_ratio控制脏页水位的上限,它们本身是比例值,但也有对应的绝对值形式:dirty_background_bytes和dirty_bytes。这两组参数是互斥的,内核文档明确说明不能同时设置比例和字节数,实际配置时也只能选一种。在内存很大的机器上(比如 512GB),比例值的颗粒度会显得太粗:dirty_ratio默认 20%,意味着脏页堆积到 100GB 才会触发同步等待,这个量级对磁盘来说几乎是灾难。所以大内存机器上我一般建议用字节数限制,比如把dirty_bytes设置成 8GB 到 16GB,让回写节奏更平滑。
说到调优经验,有几点是踩过坑之后才总结出来的。
第一,数据库类应用不建议把swappiness调到 0。数据库通常自己管理缓存和缓冲区,操作系统层面的 swap 对数据库性能是负面的,这一点没错。但如果swappiness=0导致内核在内存紧张时完全不换出匿名页,就只能疯狂回收文件页,数据库的共享内存和页缓存会被反复颠簸,结果反而是更差的性能和更高的延迟。折中方案是把swappiness调到 10 左右,让匿名页有可能被换出但不至于太积极。需要注意的是,swappiness=0并不是绝对禁用 swap,只是“尽可能不换出匿名页”,真要到了极端内存压力下,内核照样会换出。
第二,脏页比例参数要看磁盘的实际写能力来定。如果磁盘是很慢的机械盘,dirty_ratio设得太高会让同步等待失控;反之,如果后端是 NVMe SSD,适当调高脏页比例反而能提升写入合并效率。我曾在一台全闪存储节点上测试,把dirty_background_ratio从默认 10 调到 5,延迟稳定了但吞吐下降约 12%;调到 15,吞吐上去了,但峰值延迟偶尔会飙升到原来的 5 倍。最终选在 8,属于折中。这个例子说明:不要盲目照搬默认值或网上的推荐值,你的磁盘性能和业务写入模式才是指挥棒。
第三,修改这些参数时要注意持久化。sysctl -w只是临时生效,重启就丢了。正确做法是把配置写入/etc/sysctl.conf或/etc/sysctl.d/下的独立文件,比如/etc/sysctl.d/99-custom-memory.conf。下面是一个我常用的基础配置模板:
# 脏页回写相关 vm.dirty_background_ratio = 5 vm.dirty_ratio = 10 # 让kswapd更早介入内存回收 vm.min_free_kbytes = 1048576 # 内存足够时降低换出倾向,但不完全禁用 vm.swappiness = 10 # 提高缓存回收的效率(可选) vm.vfs_cache_pressure = 200这个模板适合 64GB 到 128GB 内存的通用服务器,只是起点,不是标准答案。每台机器的负载特征不一样,必须结合监控数据调。
5. 实际排查:用工具撕开脏页与交换的真相
理论讲了这么多,落地的时候还是要靠工具。这里分享一套我自己常用的排查链路,每次遇到内存相关的性能问题都先按这个顺序走一遍,能少走很多弯路。
第一步,看整体内存态势。执行free -h和cat /proc/meminfo,重点关注这几个字段:Dirty(当前脏页总量)、Writeback(正在写回的脏页量)、SwapTotal和SwapFree(swap 总容量和剩余)、MemAvailable(可用内存的大致估算值)。Dirty的值会波动,但如果长时间维持在高位不降,说明回写可能遇到了瓶颈。
第二步,看历史趋势。用sar -r查看历史内存使用和 swap 活动,用sar -B查看页交换活动(pgpgin和pgpgout表示换入换出的页数),用sar -d查看磁盘活动。历史数据的价值在于对比:你可以知道这个问题是偶发还是持续,和业务高峰是否对应,修改参数前后有没有改善。我遇到过一例脏页堆积故障,靠sar -B看到pgpgout在每日凌晨的备份任务期间暴涨,才定位到是备份脚本触发了大面积文件写操作,和内核配置无关。
第三步,定位实时进程。top里按P排序看 CPU,注意有没有kswapd刷到列表前面;按M排序看内存,看哪个进程的 RES 特别大。如果kswapdCPU 占用高,但没有任何进程的 RES 离谱,那大概率是页缓存回收压力大,而不是某个具体应用的锅。再配合pidstat -r看每个进程的缺页异常次数,指标是majflt(主缺页,需要访问磁盘)和minflt(次缺页,内存内就能解决)。majflt飙升意味着进程在不断从磁盘加载页面,典型的内存不足信号。
第四步,验证水位。查看/proc/zoneinfo里每个 zone 的pages_low、pages_high和当前nr_free_pages。如果nr_free_pages长期低于pages_low,说明kswapd一直在后台回收,系统处于慢性内存压力状态。这种情况下即使业务没有报错,也应该警惕了,因为一旦遇到突发内存请求,直接回收会在所难免。
我自己完整走一遍大约五分钟,比起盲调参数要高效得多。有一次在客户环境遇到应用写入延迟抖动,第一反应是磁盘坏了,但iostat显示磁盘利用率不到 30%,await却高达 300 毫秒。按上面流程走下来发现Dirty持续在 6GB 以上,nr_free_pages低于pages_low,majflt频繁出现——是内存压力导致回收线程和业务写线程互相争抢磁盘 IO,所以延迟才会被放大。后来把内存从 16GB 扩到 32GB,问题直接消失。
6. 常见误区与面试常见考点:顺带把知识补完整
这些机制也是 Linux 面试的常客,毕竟内核知识是后端工程师和运维工程师的硬功夫。结合常见误区和考点一起讲,相当于把零散的知识点串成系统框架。
第一个高频误区是“swap 用得多就代表性能差”。这句话在大方向上没错,但换个角度,swap 用得恰到好处其实是内存资源池化的体现:内核把不太活跃的匿名页放到磁盘,把内存留给活跃的页缓存,整体命中率反而更高。真正需要警惕的是持续的 swap in 和 swap out,也就是内存颠簸。判断是否颠簸不要只看free里的 swap used,要看/proc/vmstat的pswpin和pswpout是否持续增长。
第二个误区是“脏页比例高就一定要调低”。脏页比例高有两种情况:一种是回写能力不足导致堆积,这时调低dirty_background_ratio让回收更早开始是有意义的;另一种是写入吞吐本身很高,脏页生成速度快,而回写也在持续进行,此时脏页比例维持在一个较高但稳定的水平,并不是故障信号。区别在于趋势:稳定的高位是稳态,持续攀升才是危险信号。所以我说调参之前一定要有监控数据支撑,不能凭感觉动参数。
第三个误区是“swappiness=0等于禁掉 swap”。前面已经说过,这只是降低内核换出匿名页的意愿,真正的硬性禁用是去掉 swap 设备或swapoff。而且在实际的 Linux 内核实现中,即使把swappiness设为 0,在内存严重不足时内核依然会通过 OOM killer 来释放内存,而不是直接换出匿名页——这就意味着进程可能被莫名杀掉。生产环境我见过不止一次因为过度调低swappiness导致 OOM 误杀的案例,代价非常沉重。
面试题方面,有几个经典问题可以覆盖大部分相关考点。
- 脏页回写有哪几种触发方式?除了定时、阈值、内存压力,别忘了同步调用(
fsync/sync)。 dirty_background_ratio与dirty_ratio的区别是什么?一个是后台异步回收不阻塞,一个是同步阻塞应用。- 什么是内存水位线?
pages_min、pages_low、pages_high各代表什么状态? kswapd的作用是什么?它和直接回收有什么差异?- 文件页与匿名页的回收有什么区别?文件页干净时直接丢弃,脏时需要先写回;匿名页必须换出到 swap。
- OOM 的触发时机是什么?通常是在内存分配时无法满足且回收无效,内核调用 OOM killer 选择进程终止。
这些知识点如果只看结论而不理解机制,很容易在具体场景中卡壳。比如有人问我“为什么/proc/meminfo里的MemAvailable和free命令的 available 不太一样”,这就涉及到页缓存可回收性的估算逻辑,free命令显示的是内核导出的估算值,不同版本内核的计算方式有差异。这类问题没有源码级知识也能答对,但知道了脏页和水位的联动机制后,解释起来会自然很多。
7. 写在最后:把内核机制当作性能优化的地图
从脏页回写,到 swap 页交换,再到水位线和kswapd,这些知识点单个拆开都不太难,难点在于它们会互相影响。内存紧张时kswapd既回收文件页又换出匿名页,文件页中的脏页触发回写,回写占用磁盘 IO 导致业务读写延迟上升,延迟上升又让内存分配变慢,形成恶性循环。理解了这条链路,Linux 内存和 IO 的大半问题都能顺藤摸瓜找到根因。
我在实际排查中最大的体会是:内核参数本身没有绝对的对错,只有适合不适合。默认参数照顾的是通用场景,生产环境必须根据自己的业务写入模式、内存容量、磁盘性能去调。调优过程中,监控是底线,小步调整是方法,校验对比是验证手段。
再分享一个实用技巧:每次调整内核参数后,我建议记录三样东西——调整前的监控截图、调整后的监控截图、变更说明。这三样东西在问题复发时是排查的关键凭证。有一次我在群里看到一个同行发了一个性能问题,底下的讨论从内核配置扯到了文件系统,最后发现根因是备份脚本把目录权限改了导致缓存失效,如果没有变更记录,这种问题排查起来非常折磨。
最后想说的是,Linux 内核机制的核心文档其实就摆在那里:Documentation/admin-guide/sysctl/vm.rst和Documentation/admin-guide/sysctl/fs.rst。遇到参数不懂时,先翻文档,再看实际效果,最后综合判断。这套流程听起来朴素,但远比网上东拼西凑的“推荐配置”可靠得多。