Page Cache 与脏页回写:为什么磁盘没满,服务却突然卡了
free里buff/cache占了 90% 不是内存泄漏;iowait突然飙高也不是磁盘坏了。
多数"莫名其妙变慢"的线上问题,最后一查都落在同一件事上:脏页攒太多,写进程被同步阻塞了。
结论先说
| 现象 | 真实原因 |
|---|---|
| free 显示可用内存很少,但服务正常 | page cache 是可回收的,看 available 而不是 free |
| 写入大文件时,读请求突然变慢 | 脏页回写占满了磁盘队列 |
| 每隔几十秒卡一次 | dirty_expire_centisecs 周期性回写,撞上了同步阈值 |
| 日志文件写满磁盘,服务没报错但慢了 | 写成功只代表进了 page cache,不代表落盘 |
| 容器里 free 显示宿主机内存 | /proc/meminfo 未做 namespace 隔离 |
一、page cache 是什么
Linux 把文件内容缓存在内存里,单位是页(通常 4 KB),这套机制叫 page cache(也统称 buffer cache,2.6 之后两者合并)。
应用程序 read(fd, buf, 4096)
↓
┌──────────────────────────┐
│ page cache (按 inode 组织) │ ← 命中就直接 memcpy 到用户 buffer
└──────────────────────────┘
↓ 未命中(缺页)
块设备层 → 磁盘驱动 → 磁盘
读和写都走它:
- 读:命中 → 直接从内存拷贝,零 IO;未命中 → 发起 IO,等数据回来,同时把页留在 cache 里给下次用。
- 写:
write()只是修改 page cache 里的页并标记为 dirty,然后立刻返回。真正的落盘由内核的回写线程异步完成。
这就是"写完就返回"的由来——write() 成功 ≠ 数据在磁盘上。要确保落盘必须 fsync()。
write(fd, buf, len); // 快,只写内存
fsync(fd); // 慢,等真正落盘
组织方式
每个文件(inode)有一个 address_space,里面用一棵基数树/ xarray 按页索引存缓存页:
inode → address_space → xarray[page_index] → struct page
├─ 页内容
├─ dirty 标志
└─ 回写状态
所以 page cache 是按文件偏移索引的,天然支持随机读写,也天然支持"只缓存热点区段"。
二、脏页回写:三个参数决定一切
脏页不能无限攒——内存有限,且宕机会丢数据。内核用一组参数控制回写节奏:
| 参数 | 默认值 | 含义 |
|---|---|---|
| vm.dirty_background_ratio | 10 | 脏页占总内存 10% 时,后台开始回写(不阻塞应用) |
| vm.dirty_ratio | 20 | 脏页占总内存 20% 时,写进程被同步阻塞,直到降到阈值以下 |
| vm.dirty_expire_centisecs | 3000 | 脏页超过 30 秒必须回写 |
| vm.dirty_writeback_centisecs | 500 | 回写线程每 5 秒醒一次 |
两个阈值的作用完全不同,这是理解抖动的关键:
脏页占比
0% ──────────── 10% ──────────────── 20% ──────→ 100%
正常写 后台回写中 应用被阻塞
(应用不受影响) (write 卡住!)
当脏页超过 dirty_ratio 时,正在执行 write() 的进程会被强制拉去同步回写——这就是"磁盘没满、CPU 不高,但接口 RT 从 5 ms 涨到 500 ms"的直接原因。
一个具体的计算
一台 64 GB 内存的机器,dirty_ratio = 20%:
64 GB × 20% = 12.8 GB
也就是说,最多可以攒 12.8 GB 的脏数据。如果业务在 1 分钟内写了 20 GB 日志,前 12.8 GB 飞快(写内存),后面就开始卡——而且是所有写同一个磁盘的进程一起卡。
对数据库和日志密集的服务,这个默认值太大。建议:
# 大内存机器(> 32 GB):改成绝对值,更可控
sysctl -w vm.dirty_ratio=10
sysctl -w vm.dirty_background_ratio=5
# 更精确的做法:直接设字节数
sysctl -w vm.dirty_bytes=2147483648 # 2 GB
sysctl -w vm.dirty_background_bytes=536870912 # 512 MB
用 dirty_bytes 比 dirty_ratio 好,因为它不随内存变化——加内存不会让抖动变得更严重。
三、脏页和内存回收的关系
内存紧张时,内核要回收页。回收有两条路:
| 页类型 | 回收方式 | 代价 |
|---|---|---|
| 文件页(干净) | 直接丢弃,下次再从磁盘读 | 几乎为零 |
| 文件页(脏) | 必须先回写,才能丢弃 | 一次磁盘写 |
| 匿名页(堆、栈) | 写到 swap | 极慢,通常禁用 |
这解释了两个经典现象:
现象一:free 快见底了,但服务没 OOM。
因为 page cache 是可回收的,available 才是"真正能用的内存"。
$ free -h
total used free shared buff/cache available
Mem: 62Gi 20Gi 1.2Gi 512Mi 41Gi 41Gi
↑ 看着吓人 ↑ 真实可用
现象二:内存压力大时,磁盘写突然变多。
因为 kswapd 要回收脏页,回收脏页就得先写盘。所以 iowait 高不一定是业务在写,可能是内核在腾内存。
# 看 kswapd 是否在干活(数值持续增长 = 内存压力)
grep -E "pgscan|pgsteal" /proc/vmstat
# 或
sar -B 1 5 # pgscank/s、pgscand/s、pgsteal/s
四、什么时候该绕过 page cache
page cache 对"读多写少、重复访问"极友好,但对一次写入、永不再读的大文件是纯负担——它挤占了给热数据的内存,还要多一次内存拷贝。
| 方式 | 写法 | 适用 |
|---|---|---|
| 普通 IO | read/write | 大多数场景 |
| 直接 IO(O_DIRECT) | open(..., O_DIRECT) | 数据库(MySQL innodb_flush_method=O_DIRECT)、大文件顺序写 |
| mmap | mmap() + 直接访问内存 | 随机读小文件(ES 的索引文件) |
| sendfile | FileChannel.transferTo | 文件直接发网络(Kafka、Nginx) |
MySQL 用 O_DIRECT 的原因很实在:
innodb_flush_method = O_DIRECT # Linux 推荐
- 不用
O_DIRECT:InnoDB 自己有一份 Buffer Pool,OS 又缓存一份 page cache,同一份数据在内存里存了两遍,还多一次拷贝; - 用
O_DIRECT:写入直接落盘(绕过 page cache),读也绕开,内存只留 Buffer Pool 一份。
代价是失去了 OS 的预读和写合并,所以 InnoDB 要自己做这些事(innodb_read_ahead_threshold、change buffer)。
注意:
O_DIRECT要求缓冲区按块对齐(通常是 512 B 或 4 KB),Java 里直接操作需要ByteBuffer.allocateDirect且地址对齐,一般由框架处理。
五、定位步骤:从现象到根因
5.1 先分清是"写太多"还是"回收太多"
# 1) 看整体内存与缓存
free -h
# 2) 看脏页与回写中
grep -E "^(Dirty|Writeback|WritebackTmp|Cached|MemAvailable)" /proc/meminfo
# 3) 看块设备吞吐(bi/bo,单位 KB/s)
vmstat 1 5
# bi: 从块设备读入
# bo: 写出到块设备
# wa: iowait 占比
# 4) 看磁盘队列与延迟
iostat -x 1 5
# r/w await:平均等待(含排队)
# aqu-sz:队列深度,持续 > 1 说明打满了
# %util:接近 100% 就是饱和
判读表:
| 组合 | 结论 |
|---|---|
| bo 高 + Dirty 高 | 业务在猛写,脏页积压 |
| bi 高 + Cached 下降 | 内存不足,热数据被挤出去了 |
| bo 高 + Dirty 不高 | 可能是 fsync 频繁(数据库)或直接 IO |
| wa 高 + bo 低 + pgscank 高 | 内核在回收内存,不是业务写 |
5.2 找出是谁在写
# 按进程看 IO(需要 root)
iotop -oP
# 按文件看谁被写(需要先 mount debugfs 或用 bpftrace)
# 简单办法:看 /proc/<pid>/io
for p in $(pgrep -f java); do
echo "$p $(awk '/^write_bytes/{print $2}' /proc/$p/io)"
done
/proc/<pid>/io 的 write_bytes 是真正发给块设备的字节数,不含 page cache 写入——它能直接告诉你"谁把盘写满了"。
5.3 一个真实案例的排查路径
现象:订单服务每 40 秒出现一次 P99 尖刺(800 ms),持续 2~3 秒。
① free -h → buff/cache 48 G / 64 G,Dirty 常在 6~10 G
② vmstat 1 → 尖刺期间 bo 从 20 MB/s 冲到 900 MB/s,wa 60%
③ iostat -x → sdb 的 aqu-sz 到 30,await 400 ms
④ iotop → 日志采集进程 filebeat 周期性刷大文件
⑤ 定位 → filebeat 的 registry 与日志轮转叠加,一次回写 10 GB
处理:
# 1) 限制 filebeat 的写入速率(应用层)
# 2) 降低脏页上限,避免攒出 10 GB 一次性回写
sysctl -w vm.dirty_bytes=1073741824
sysctl -w vm.dirty_background_bytes=268435456
改完后尖刺消失,bo 从"锯齿状"变成"平缓直线"。
关键洞察:抖动往往不是"总量大",而是"节奏不对"——攒着不写、然后一次性写完,中间那几秒整个磁盘都被占满。
六、fsync 的代价:数据库为什么怕它
fsync(fd); // 等数据和元数据都落盘
fdatasync(fd); // 只等数据,元数据(mtime 等)可以不落
一次 fsync 在 NVMe 上是 0.1~0.5 ms,在云盘(网络存储)上可能是 2~10 ms,机械盘 10~20 ms。
这直接决定了数据库的提交性能:
MySQL 双 1 配置:每次事务提交 2 次 fsync
NVMe:0.3 ms → 3000 TPS/连接
云盘:6 ms → 160 TPS/连接
所以"同样的代码,换到云上 TPS 掉了 20 倍"——不是代码问题,是 fsync 的物理延迟变了。这也是为什么云数据库要提供"本地 NVMe + 三副本"的规格,而不是普通云盘。
另外注意:fsync 会刷整个文件的所有脏页,不是只刷你刚写的那一段。一个大文件上频繁 fsync,会把别人写的脏页也一起刷下去。
七、调参建议
| 场景 | 配置 |
|---|---|
| 通用服务器 | dirty_bytes=2G、dirty_background_bytes=512M |
| 日志密集(每台都在写日志) | dirty_bytes=1G,避免峰值集中 |
| 数据库主机 | 保持默认或用绝对值;innodb_flush_method=O_DIRECT |
| 大文件顺序写(备份/转码) | 应用层用 O_DIRECT 或 posix_fadvise(DONTNEED) |
| 内存 < 8 G 的小机器 | 用 dirty_ratio 更合适(dirty_bytes 可能超过总内存) |
持久化:
# /etc/sysctl.d/90-dirty.conf
vm.dirty_bytes = 2147483648
vm.dirty_background_bytes = 536870912
vm.dirty_expire_centisecs = 1500
vm.dirty_writeback_centisecs = 300
sysctl -p /etc/sysctl.d/90-dirty.conf
容器里注意:这些是宿主机级参数,容器内改不了(除非 privileged + 共享 net/pid 之外的内核参数权限)。K8s 上要靠节点配置或 DaemonSet 统一设。
八、小结
三句话记住 page cache:
- 写快是因为写进了内存,不是因为磁盘快;可靠性要靠
fsync。 - 脏页攒到
dirty_ratio会阻塞写进程,这是"卡一下"的最常见原因。 free少了不代表内存不够,available才是真实可用;内存紧张时内核回收脏页会带来额外 IO。
调优的本质不是"加大缓存",而是让回写节奏平滑——攒着不写再一次性刷,永远比匀速写更伤。
系列内链
- 前置:[09-Linux基础/01-文件系统与目录结构](01-文件系统与目录结构.md)
- 关联:[10-Linux运维与性能/02-内存free与OOM-Killer](../10-Linux运维与性能/02-内存free与OOM%20Killer.md)——
available与 OOM 的判定 - 关联:[10-Linux运维与性能/03-磁盘IO分析iostat与iotop](../10-Linux运维与性能/03-磁盘IO分析iostat与iotop.md)——
await、aqu-sz的读法 - 关联:[05-数据库与MySQL/01-InnoDB存储引擎与BufferPool](../05-数据库与MySQL/01-InnoDB存储引擎与BufferPool.md)——为什么数据库宁愿绕过 page cache
- 点赞
- 收藏
- 关注作者
评论(0)