页缓存机制:从结构到策略的深度拆解
页缓存(Page Cache)是 Linux 内存子系统中我最常打交道的模块之一。说白了,它就是内核用物理内存来缓存磁盘文件数据的那部分空间。你每次 read() 一个文件,内核先看页缓存里有没有,有就直接返回,没有才去磁盘读——这个机制让 I/O 性能提升了几个数量级。
我记得刚接手一个存储项目时,业务方抱怨读写延迟忽高忽低。我第一反应就是去看页缓存的命中率和回写行为。嗯,今天我们就从三个角度把页缓存彻底讲透:它的数据结构、核心索引机制、以及回写策略。
页缓存的核心结构
页缓存不是一块连续的内存区域,它是由一个个 页(通常 4KB)组成的集合。每个页对应磁盘上的一个文件块。内核用 struct page 来描述每个物理页,而页缓存的管理则围绕 address_space 展开。
关键数据结构:
struct address_space—— 每个文件 inode 对应一个,管理该文件的所有缓存页struct page—— 描述单个物理页,包含 flags、mapping、index 等字段struct radix_tree_root—— 早期内核用于索引页的树结构struct xarray—— 新版内核替代 radix tree 的索引结构
每个文件被打开后,内核会为其 inode 分配一个 address_space。这个结构体里最重要的两个成员:一个是 i_pages(以前叫 page_tree),用来快速查找某个文件偏移对应的缓存页;另一个是 nrpages,记录当前缓存了多少页。
我在一次性能分析中遇到过这样的情况:一个进程频繁读取大文件的随机位置,页缓存命中率却很低。排查后发现是文件被多个进程以不同偏移反复读写,导致缓存页频繁换入换出。这就是典型的缓存颠簸,根源在于索引结构的查找效率。
从 radix tree 到 xarray
页缓存的核心索引机制经历了一次重大演进。早期内核(5.0 之前)使用 radix tree,后来被 xarray 取代。为什么要换?说白了,radix tree 在某些场景下有锁竞争问题,而且代码维护成本高。
radix tree 的工作原理
radix tree 是一种压缩的基数树,它把文件偏移(page index)作为 key,把 struct page * 作为 value。树的高度取决于地址空间的规模,通常 2-4 层就能覆盖几十 GB 的文件。
// radix tree 查找示例(内核源码风格)
struct page *find_get_page(struct address_space *mapping, pgoff_t offset)
{
struct page *page;
rcu_read_lock();
page = radix_tree_lookup(&mapping->i_pages, offset);
if (page)
get_page(page);
rcu_read_unlock();
return page;
}
radix tree 的优点是查找速度快,O(log n) 复杂度。但问题在于:
- 插入和删除操作需要全局锁保护
- 多线程并发访问时,锁竞争严重
- 代码实现复杂,容易引入 bug
我曾经在一个 128 核的机器上做压测,发现页缓存操作成了瓶颈。perf top 一看,radix_tree_node_alloc 和 radix_tree_delete 占了大量 CPU 时间。这就是 radix tree 在高并发下的软肋。
xarray 的改进
xarray 是 Matthew Wilcox 在 2018 年提出的替代方案,从 Linux 5.0 开始逐步替换 radix tree。它本质上是一个更通用的稀疏数组实现,但针对页缓存的场景做了大量优化。
xarray 的核心改进:
- 使用 RCU(Read-Copy-Update)机制,读操作完全无锁
- 内部节点更紧凑,缓存友好性更好
- 支持批量操作,比如一次查找多个连续的页
- API 更简洁,减少了内核代码的复杂度
// xarray 查找示例(新版内核)
struct page *xas_find(struct xa_state *xas, pgoff_t max)
{
struct page *page;
rcu_read_lock();
do {
page = xas_load(xas);
if (xa_is_value(page))
page = NULL;
} while (xas_retry(xas, page));
rcu_read_unlock();
return page;
}
我建议你在做内核版本升级时,重点关注这个变化。如果你的业务对文件 I/O 并发要求很高,xarray 带来的性能提升是实打实的。我在一个数据库项目中,从 4.19 升级到 5.10 后,页缓存相关的锁竞争下降了约 40%。
页缓存回写策略
页缓存解决了读性能问题,但写操作呢?你写文件时,数据先写到页缓存,标记为脏页,然后内核在合适的时机回写到磁盘。这个回写策略直接影响系统的 I/O 行为和可靠性。
脏页的生命周期
一个页从被写入到最终落盘,经历三个阶段:
| 阶段 | 状态 | 说明 |
|---|---|---|
| 写入 | 脏页 | 数据已修改,但未同步到磁盘 |
| 回写 | 回写中 | 内核正在将数据写入磁盘 |
| 完成 | 干净页 | 数据已落盘,可被回收 |
内核通过几个关键参数控制回写行为:
dirty_background_ratio—— 脏页占内存比例超过此值,后台线程开始回写dirty_ratio—— 脏页比例达到此值,进程写操作会被阻塞dirty_expire_centisecs—— 脏页超过此时间(百分秒),必须回写dirty_writeback_centisecs—— 回写线程的唤醒间隔
我曾经踩过的坑: 在一个日志采集系统中,为了追求写入速度,把 dirty_background_ratio 调到了 20%,dirty_ratio 调到了 40%。结果内存压力大时,系统突然开始大量回写,磁盘 I/O 瞬间打满,导致日志写入延迟飙升到秒级。后来我改成了 5% 和 10%,虽然写入峰值有所下降,但整体延迟稳定多了。
回写触发机制
内核的回写不是简单的定时任务,它有三种触发方式:
- 比例触发:脏页比例超过阈值,后台线程自动唤醒
- 过期触发:脏页存在时间超过
dirty_expire_centisecs - 同步触发:进程调用
fsync()或sync()强制回写
你想想看,如果所有写操作都同步落盘,那性能会惨不忍睹。但完全依赖后台回写,又可能在掉电时丢数据。这就是个 trade-off。
我个人习惯根据业务场景来调优:
- 数据库类应用:降低脏页阈值,配合
fsync()保证事务持久性 - 日志类应用:适当提高阈值,减少回写频率,但要做好掉电保护
- 文件服务器:保持默认值,重点关注脏页过期时间
页缓存整体架构图
下面这张图展示了页缓存从文件 I/O 到物理内存的完整路径:
从这张图你能看到,页缓存的核心是 address_space,它通过 xarray 索引到具体的物理页。脏页被挂在专门的链表上,由回写线程定期扫描并落盘。整个路径上,任何一个环节出问题,都会影响 I/O 性能。
调优实践建议
最后,我总结几条实战中验证过的调优建议:
页缓存调优 checklist:
- 监控
/proc/meminfo中的Dirty和Writeback字段,了解脏页积压情况 - 使用
sar -B查看页缓存命中率,命中率低于 90% 说明内存不足或访问模式有问题 - 调整
dirty_background_ratio和dirty_ratio时,先做压力测试,避免回写风暴 - 对于 SSD,可以适当降低
dirty_expire_centisecs,减少脏页滞留时间 - 如果使用 5.0+ 内核,确认 xarray 已启用(默认就是),不需要额外配置