提到 pagecache 引起的生产速度变慢,很多人第一反应是不信:pagecache 是内核拿来加速文件读写的缓存,不给你减速就不错了,怎么还可能反过来拖垮生产吞吐。可我前阵子排查一条批处理链路时,生产速度从单机每秒 1300 条掉到 400 条,free一查内存里全是 pagecache,CPU 却持续高位空转,日志里全是阻塞等待。这篇文章把当时的排查路径、pagecache 背后的内核机制、以及真正落地的调优手段完整拆开讲,适合后端开发、运维和正在读监控数据但不知道下一步该做什么的同行。
1. pagecache 的基本工作原理:它为什么“看起来慢”
1.1 pagecache 到底是加速还是减速
pagecache 是操作系统为了保护磁盘而生的一层缓冲。当你读取一个文件时,内核会把读出来的磁盘块放在内存里;下一次再读同一块数据,直接命中内存,不再碰磁盘。写入也是同样思路:应用write()的数据先落进 pagecache,标记为“脏页”,由内核里的回写线程在后台慢慢刷到磁盘,这样应用不必每次write()都等物理硬盘写完。
所以 pagecache 本身是实打实的加速器。但生产速度变慢时,监控图上第一眼看到往往是“内存的 used 不高,buff/cache 占了几十个 G”,于是大家就把锅扣到了 pagecache 头上。其实这个判断方向是错的:pagecache 占内存不等于它会拖慢系统,真正拖速度的是 pagecache “被回收”“被回写”“被反复换入换出”这些动态行为,而不是它静态占用了多少内存。
1.2 什么时候 pagecache 会引起生产速度变慢
pagecache 产生性能问题的典型阶段有三个:
- 内存水位紧张阶段:系统内存不足,内核的回收机制开始扫描 LRU,把 pagecache 里的干净页释放,把脏页先回写再释放。这个过程如果持续发生,会消耗大量 CPU 和磁盘 IO,也会让分配内存的进程进入同步等待。
- 脏页回写阶段:应用写了很多数据进 pagecache,当脏页数量超过
/proc/sys/vm/dirty_ratio时,write()会从“异步写完就走”变成“同步等刷盘”,这个等待直接落在生产线程上,吞吐立刻掉一个台阶。 - 缓存抖动阶段:如果你手动清理 pagecache,或者内存不足时 pagecache 被大量回收,随后生产任务再次读到同样的热数据,就得重新从磁盘加载。内存空出来很多,但磁盘读 IO 变成了新瓶颈,整体速度比保留缓存时更差。
这也是为什么“pagecache 引起生产变慢”这个问题,排查重点不在于“要不要清理缓存”,而在于“内核在什么条件下动了缓存,动缓存时有没有影响生产线程”。
2. 一整套可以复现的排查流程:怎么坐实是 pagecache 的事
2.1 从 free 和 vmstat 开始
接到生产变慢的告警,我一般先开两个窗口:一个跑free -h,一个跑vmstat 1 5。
free主要看 available 是否缩水。注意这里的 available 不等于 free,它已经把可回收的 pagecache 算进去了。如果 available 还有 20G 以上,应用却报内存不足,那大概率不是内存不够,而是有别的资源卡住了。
vmstat主要看这几列:
| 参数 | 含义 | 异常时代表什么 |
|---|---|---|
si/so | 换入换出 4KB 页数量 | 持续非零,说明系统已经在做 swap 或反复换页 |
bo | 块设备写入速度 | 很高时脏页回写可能成为瓶颈 |
wa | 等待 IO 的时间占比 | pagecache 回收或回写抢占了磁盘 |
sy | 内核态时间占比 | 回收、内存扫描、Copy-on-Write 都可能拉高它 |
如果vmstat看到的现象是:us不高、sy和wa都不低、bo经常有突发性的高峰,同时si/so偶发出现,基本可以锁定是内存子系统在做回收和回写,下一步直接看更细的内核计量。
2.2 追到根源:内存状态 + PSI + 进程 IO
只看free和vmstat还不够,还需要确认是不是 pagecache 占大头。用下面命令看一组关键字段:
grep -E '^(MemTotal|MemFree|MemAvailable|Cached|Dirty|Writeback|Shmem|SwapTotal|SwapFree)' /proc/meminfo我重点看Dirty和Writeback两个值。如果Dirty长时间超过内存的 5%,而且Writeback经常非零,大概率是回写路径出了问题。Cached本身大并不说明问题,因为 pagecache 就是设计来占内存的,它占得越多,反而说明热数据命中越多。
为了判断生产进程到底有没有被内存阻塞,Linux 4.14 之后提供了 PSI(Pressure Stall Information),直接看文件:
cat /proc/pressure/memory some avg10=30.00 avg60=10.00 avg300=5.00 total=123456 full avg10=0.00 avg60=0.00 avg300=0.00 total=0some表示至少有一个任务因为等待内存而卡住的时间占比,full表示所有任务都被内存卡住的时间占比。如果some长期超过 20%,说明生产线程的调度已经被内存回收影响,哪怕free看起来还有富余。
再进一步确认是谁在承受 IO 压力,用pidstat或iotop定位具体进程:
pidstat -d 1 5 iotop -o -P生产速度变慢时,进程的KB_rd/s如果突然增高,而KB_wr/s不高,大概率是 pagecache 丢了,应用在重新读磁盘换出。这个信号比内存占用百分比更实在。
2.3 翻内核日志和容器级证据
dmesg -T也是必看项,主要找有没有下面这些痕迹:
Out of memory:内存确实耗尽,pagecache 回收都救不了;kswapd0: 0 pages released之类日志:回收线程在空转,但系统仍然卡;- block 层的限流或 IO 调度报错:说明磁盘侧已经被写回任务打满。
如果生产环境用的是容器或 K8s,还要看 cgroup 的内存统计。很多模块跑在容器里,容器的 memory limit 不仅限制进程级匿名内存,也把 pagecache 算在内。也就是说,容器里读了一堆大文件,pagecache 涨到接近 limit,内核就会在这个容器内做回收,而这个回收动作是同步的,会直接卡住容器里所有任务的运行。
# 容器内或 host 上指定 cgroup 路径 cat /sys/fs/cgroup/memory.current cat /sys/fs/cgroup/memory.stat cat /sys/fs/cgroup/memory.events对比memory.events里的high、max、oom计数器,如果high事件在增长,说明容器已经频繁越过软限制,内核正在回收内存,这里边很大一部分就是在回收 pagecache。
3. 底层机制拆解:让 pagecache 拖速度的三种典型姿势
3.1 内存回收风暴:kswapd 替应用扛了所有错
pagecache 虽然在free里显示为可用内存,但它并不是“免费劳动力”。当一个进程需要比较大的物理内存时,内核会先去空闲内存链表里找;如果找不到,就会先触发后台回收(kswapd),再不行就直接回收(direct reclaim)。
direct reclaim 是同步的:谁申请内存,谁就负责把内存抠出来。如果这个申请发生在生产进程里,进程要一边执行自己的逻辑,一边去把 pagecache 里的干净页摘掉、脏页刷盘,甚至把其他进程的匿名内存换到 swap。这时候 CPU 大量花在内核态,生产线程却被堵在页表操作和 LRU 扫描上。
具体表现就是:free显示还有大量 pagecache 可回收,但生产速度已经掉了。因为 pagecache 的回收成本不低,尤其是在磁盘读写压力大的场景下,脏页回收要把数据落盘,干净页回收也要做 TLB 刷新的清理工作。
这种现象在“文件大、一次读入内存、处理完就走”的批处理任务里尤其明显。任务每跑一步都需要文件块,pagecache 反复被换入换出,内存分配器和回收器在互抢时间,业务线程只能排队。
3.2 脏页回写:写入缓冲反倒是吞吐的刹车片
pagecache 引起的生产变慢,另一大来源是脏页回写。默认配置里,vm.dirty_background_ratio=10、vm.dirty_ratio=20是常见值。意思分别是:
- 脏页占可用内存 10% 时,内核后台线程开始异步刷盘;
- 脏页占可用内存 20% 时,写入进程自己会被按头刷盘,
write()调用转为同步阻塞。
如果一台机器内存 64G,那么脏页上限大约是 12G 左右。应用如果短时间写入大量数据,比如批量导入、日志盘写、消息落库,Dirty增长到上限时,后续每个write()都得等磁盘把前面的脏页刷完。磁盘吞吐成了操作系统的“红绿灯”,生产速度自然被踩住。
还有一种更隐蔽的情况:上游应用在疯狂产生脏页,但 disk 回写速度跟不上。后台刷盘线程每 5 秒(vm.dirty_writeback_centisecs=500)醒来一次,把超过 30 秒(vm.dirty_expire_centisecs=3000)的脏页落盘。一旦积压量超过上限,同步刷盘生效,不是平滑的减速,而是断崖式掉速。我见过一个模块本来每秒写 500 条,稳定得很;一次大促流量翻了三倍,脏页冲到上限,同一台机器的写入速度直接掉到每秒几十条。
3.3 重新加载冷页:drop_cache 和缓存抖动造成的惩罚
很多人看到 pagecache 占用高,第一反应是sync; echo 3 > /proc/sys/vm/drop_caches,把缓存清掉。这招在测试环境验证内核行为可以,放到生产环境就是给自己挖坑。
drop_caches只能释放干净页、目录项和 inode 缓存,脏页并不会因此消失,得先sync或者等回写。清理之后,pagecache 里的热数据全部丢掉,下次生产任务访问同样的文件,又要从磁盘读一遍。磁盘读 IO 比内存命中慢好几个数量级,刚刚“省下来”的内存,马上变成双倍 IO 开销。
更麻烦的是,很多任务不是跑一次就结束,而是循环扫描同一批文件。清掉 pagecache 后,第一轮读盘会显著拉长任务执行时间,整个生产链路都会出现“周期性踩刹车”的现象。这个状态下你用iostat -x 1会看到%util很高,但vmstat里的内存字段却很正常。如果不结合业务进程的 IO 行为,很容易误判成磁盘故障。
所以我的建议是:drop_caches只在两类场景使用,一是做内核内存回收相关的实验,二是你想验证“如果生产进程没命中 pagecache 能不能扛得住”这种极端场景。日常生产环境不要把它当作清理工具。
4. 可以用到生产环境的调优手段
4.1 用 sysctl 调整脏页行为并确认参数
pagecache 引起的慢,很多时候不需要牺牲内存去换,调整内核的脏页回写参数就能见效。核心参数就四个:
vm.dirty_background_ratio vm.dirty_ratio vm.dirty_expire_centisecs vm.dirty_writeback_centisecs你可以按下面思路做二次调优:
- 如果业务是写多且写入峰值明显,把
dirty_background_ratio调低到 2~5,让后台回写在脏页积累初期就开始刷盘,避免攒到高峰同步阻塞; dirty_ratio建议不大于 15。设太高,突发写入会攒出一大堆脏页,一旦同步刷盘,磁盘 IO 瞬间打满;设太低,则写吞吐会被磁盘极限拖死。我有一个折中习惯:先设dirty_background_ratio=3、dirty_ratio=10,压测看曲线,再微调。dirty_expire_centisecs默认 3000,表示脏页 30 秒没落盘就会被强制刷。如果你的业务对持久化实时性要求高,可以降到 1000~2000,让脏页老得没那么快。dirty_writeback_centisecs默认 500(5 秒),一般不用动。如果刷盘线程醒来太频繁反而会打高 CPU 和 IO,低于 300 就不建议了。
例如:
sysctl -w vm.dirty_background_ratio=3 sysctl -w vm.dirty_ratio=10 sysctl -w vm.dirty_expire_centisecs=1500写入/etc/sysctl.d/99-pagecache.conf,用sysctl --system生效。需要注意,这个方案只解决回写节奏问题,不解决内存回收问题。如果系统本身就处于频繁回收的状态,光调 dirty 参数效果会打折。
4.2 按容器/进程边界限制 pagecache
pagecache 是全局的,但很多生产系统已经容器化,容器的 memory limit 天然限制了 pagecache。用好这层隔离,比全局调内核参数更精准。
在 cgroup v2 环境里,给某个处理大文件的模块设置memory.max,比如设为 8G,那么该模块读入文件的 pagecache 最多占 8G;超过之后内核会优先回收这个 cgroup 里的页缓存,不会干扰其他模块,也不会让整个宿主机 kswapd 一直在跑。
# 假设 cgroup 名为 service-a echo 8G > /sys/fs/cgroup/service-a/memory.max这个方案的要点是:业务进程要注意它分配内存的峰值,不能只给应用匿名内存留总量,得把 pagecache 的余量也算进去。比如一个 Java 模块堆内存 4G,还要处理几十 G 的文件,给 8G 限制就合适,给了 6G 就会导致文件热数据存不住,反而频繁读盘。
K8s 场景同理,Pod 的memory.limit包含了 pagecache,所以给 Pod 设限时要同时估算文件读写的缓存需求。很多生产环境里 Pod 被 OOMKilled,并不是应用堆内存真的超了,而是 pagecache 把它挤到了 limit 边界,内核在回收失败后发起了 OOM。
4.3 应用层绕过或提示内核:O_DIRECT、fadvise 和批量读取
如果调内核参数和容器限制都不够,就得回到应用层解决问题。pagecache 不是免费的,对于大规模顺序扫描型任务,完全没必要让内核缓存全量文件。
先用O_DIRECT跳过 pagecache。比如数据库备份、批量导大数据、视频转码这类“一次性读取,处理完就不再碰”的场景,open()时加O_DIRECT,让数据直接从磁盘到用户态缓冲区,不污染、不依赖 pagecache。注意O_DIRECT对缓冲区对齐、块对齐有要求,写业务代码时要处理align_alloc一类细节,否则会遇到Invalid argument。
对偶尔读一次的文件,用posix_fadvise提示内核用完即弃:
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);在读完之后提醒内核可以释放这部分页。要注意不能读一遍就DONTNEED一遍,如果文件马上还要再读,这个调用反而会让缓存命中失效。它适合“读一次就不再高频访问”的临时文件。
对大文件,做分批读取而不是整个读到内存。很多数据管道代码习惯一次性把文件全量载入内存,比如file_read_all()这种封装,在小文件上没问题,在几十 G 文件上就是对 pagecache 的灾难。改成按记录流式读取,pagecache 只会留存当前处理窗口的热数据,不会被一整轮任务撑爆。
4.4 从 IO 层面反向稀释回写影响
pagecache 脏页回写的最终路径是块设备。如果磁盘本身慢,或者 IO 调度器把回写请求和生产业务请求混在一起排长队,前面所有参数调整都白搭。
排查时用:
iostat -x 1 5重点看每个磁盘的avgqu-sz(平均队列长度)和%util。当回写任务堆积时,你常会看到avgqu-sz长时间超过磁盘容量对应的合理值。比如普通 SATA 盘 16 队列,你观察到avgqu-sz=80,那瓶颈就在磁盘本身,不是在 pagecache 参数上。
这种情况的应对思路是:
- 把回写量小的路径和回写量大的路径拆分到不同磁盘,比如应用日志放一份盘、数据库数据放一份盘;
- 如果条件允许,回写最猛的目录放到 SSD 上,SSD 的随机写并发能力比机械盘强一个量级;
- 使用高频小文件写入时,可以考虑在中间层加异步队列,由少量线程集中批量写,避免生产线程直接
write()触发同步刷盘。
注意,IO 调度器参数scheduler在多数现代内核上建议保持none,不要自己改成老旧的cfq或deadline,改错反而会让回写请求在队列里绕圈。
5. 实战复盘与避坑清单
5.1 一个典型 pagecache 拖慢生产的实录
模拟一下前阵子我处理过的场景,节点代号叫“批量入库任务”。有个模块负责把上游消息落库,并生成索引文件。某个版本上线后,生产速度从稳定 1300 条/s 掉到 400 条/s,监控里 CPU 平均才 30%,看起来什么都很正常。
我用free -h看到内存 32G,buff/cache占了 23G,available只有 4G;Dirty持续在 6G 附近,Writeback偶发 1G;vmstat的si/so有频率很低的换入换出;sar -B里pgscand和pgsteal数值明显偏高。
再往细看,是模块先把上游数据暂存成几 GB 的临时文件,然后循环读取这些文件做索引。文件读完后不删,而是等任务结束才删。于是 pagecache 里全是这些临时文件的残留页。下一轮任务开始后,旧缓存还没被回收彻底,又要读新文件,内存回收线程和业务线程互相抢资源。脏页回写又因为落盘速度跟不上,把write()卡住。整个链路就变成了:任务读文件慢、写文件也慢、内核回收还占 CPU。
处理步骤很简单:
- 把
dirty_background_ratio从默认 10 调到 3,dirty_ratio从默认 20 调到 10; - 给这个模块的 systemd service 增加
MemoryMax=8G,限制容器内 pagecache 上限; - 应用代码里对临时文件改用
O_DIRECT读取,并在读完一页后调用POSIX_FADV_DONTNEED; - 临时文件按批处理窗口提前删除,不在同一轮里占用大量 pagecache。
三个小时后生产速度恢复到 1100 条/s,虽然没有完全拉回 1300 条/s,但稳定性明显好了,没有出现断崖式掉速。核心收益是,脏页不会再积压成同步阻塞,pagecache 也不会无限膨胀到触发 kswapd。
5.2 排查中常见的误判和正确解释
这段过程里,最容易出现的误判有几个。
误判一:buff/cache 高 = 内存泄漏。事实是 pagecache 本来就该占满空闲内存,内核设计如此。某个进程狂读大文件导致 pagecache 升高,不等于内存泄漏。真正要看的指标是Dirty和pgscan这一类回收计数,而不是buff/cache的绝对值。
误判二:available 低 = 内存不够。available本身就包含可回收的 pagecache。如果业务进程没分配太多内存,但是 pagecache 占了 90%,available 低并不一定代表内存不够,而是 pagecache 回收动作正在和业务线程打架。
误判三:清掉 pagecache 就能释放内存。不能。清掉之后下一轮任务会重新读磁盘,整体延迟反而变高。除非你想验证冷启动性能,否则别在生产环境乱echo 3 > /proc/sys/vm/drop_caches。
误判四:调大dirty_ratio能提升写入速度。调大dirty_ratio只是允许攒更多脏页,应用看起来写入快了,但最终这些脏页都要落到磁盘。磁盘吞吐不变,等于把“延迟”换成“最后一次性付账”。突发写入场景下,调大dirty_ratio的结果往往是磁盘队列瞬间爆炸,系统大面积卡顿。
一张速查表方便复盘:
| 现象 | 可能原因 | 先看哪个指标 |
|---|---|---|
wa高,Dirty持续增长 | 脏页回写跟不上 | Writeback、iostat队列 |
si/so非零,业务线程卡住 | 冷 pagecache + 匿名内存交换 | /proc/pressure/memory |
drop_caches后反而变慢 | 热数据丢失,冷读惩罚 | /proc/vmstat的pgfault |
| 容器 OOM 但业务堆内存不高 | cgroup 内存限制吃进 pagecache | memory.events |
5.3 可取方法:把 pagecache 当资源而不是病
排查完整个案例,我最大的体会是:pagecache 不是病,把它当成病去治才会出问题。生产速度变慢,绝对不只是“缓存占用高”这一个原因;你要做的是把内核回收、脏页回写、进程 IO 路径、容器限制四件事放在同一张图画里看。
先看/proc/pressure/memory,确认有没有内存阻塞;再看/proc/meminfo的Dirty和Writeback,确认回写节奏;最后看业务进程的读写模式,确认是不是一次读入太多文件。对症之后,再决定是调sysctl、设容器memory.max、改应用O_DIRECT还是拆磁盘。
我个人实际用下来,最推荐的组合是“容器限制 + 应用流式处理 + dirty 参数微调”。它不需要牺牲业务正确性,也不用盲目 drop cache。对于已经踩过坑、不想再来一轮的同学,建议把cat /proc/pressure/memory和grep Dirty /proc/meminfo直接加进监控告警里,生产速度掉的时候,这两个指标能让你在五分钟内判断是不是 pagecache 的锅。