Page Cache 与脏页回写:为什么磁盘没满,服务却突然卡了

举报
yd_254019812 发表于 2026/11/08 10:01:49 2026/11/08
【摘要】 free 里 buff/cache 占了 90% 不是内存泄漏;iowait 突然飙高也不是磁盘坏了。 多数"莫名其妙变慢"的线上问题,最后一查都落在同一件事上:脏页攒太多,写进程被同步阻塞了。 结论先说 | 现象 | 真实原因 | |---|---| | free 显示可用内存很少,但服务正常 | page cache 是可回收的,看 available 而不是 free | | 写入大文...

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:

  1. 写快是因为写进了内存,不是因为磁盘快;可靠性要靠 fsync。
  2. 脏页攒到 dirty_ratio 会阻塞写进程,这是"卡一下"的最常见原因。
  3. 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
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。