页缓存机制:从结构到策略的深度拆解

页缓存(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%,虽然写入峰值有所下降,但整体延迟稳定多了。

回写触发机制

内核的回写不是简单的定时任务,它有三种触发方式:

  1. 比例触发:脏页比例超过阈值,后台线程自动唤醒
  2. 过期触发:脏页存在时间超过 dirty_expire_centisecs
  3. 同步触发:进程调用 fsync() 或 sync() 强制回写

你想想看,如果所有写操作都同步落盘,那性能会惨不忍睹。但完全依赖后台回写,又可能在掉电时丢数据。这就是个 trade-off。

我个人习惯根据业务场景来调优:

  • 数据库类应用:降低脏页阈值,配合 fsync() 保证事务持久性
  • 日志类应用:适当提高阈值,减少回写频率,但要做好掉电保护
  • 文件服务器:保持默认值,重点关注脏页过期时间

页缓存整体架构图

下面这张图展示了页缓存从文件 I/O 到物理内存的完整路径:

页缓存架构与数据流 用户空间(进程读写文件) 系统调用层(read / write / mmap) VFS 层(虚拟文件系统) address_space(每个 inode 一个) i_pages(xarray) | nrpages | a_ops xarray 索引树 脏页 / 干净页链表 物理页(struct page) 回写线程(pdflush / flusher) 定时扫描脏页链表,触发回写 磁盘

从这张图你能看到,页缓存的核心是 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 已启用(默认就是),不需要额外配置