引言

Page Cache是Linux内核中最核心的性能加速器之一,它利用空闲内存缓存磁盘I/O数据,将绝大部分随机读操作转化为纳秒级内存访问。然而当空闲内存紧张时,内核必须精准地回收页面而不破坏系统稳定性——这套机制涉及LRU链表、匿名页交换、脏页回写、内存压缩、OOM裁决等多个子系统。本文从源码级别剖析Page Cache的构建、追踪与回收全链路,并结合生产环境调优案例,帮助工程师建立内存子系统的完整心智模型。

一、Page Cache的架构定位

1.1 虚拟文件系统层与Page Cache的交互

在Linux内核中,Page Cache是VFS层与具体文件系统之间的缓冲层。当一个用户进程发起read()系统调用时,内核首先在Page Cache中查找目标页是否已缓存;如果命中(Page Cache Hit),直接将数据从内核空间复制到用户空间,完全绕过块设备层。如果未命中,则触发缺页异常(Page Fault),从磁盘读取若干页(预读窗口通常为128KB)插入Page Cache,再完成用户请求。

这种机制的关键在于内核统一使用struct address_space来描述一个文件在内存中的缓存映射。每个打开的文件都有一个inode->i_mapping指针,指向其address_space实例。该结构维护了一棵基数树(radix tree,5.1后改为XArray),用于页号到struct page的O(log n)查找。

// include/linux/fs.h -- address_space 核心结构
struct address_space {
    struct inode        *host;          // 所属 inode
    struct xarray       i_pages;        // 页面索引树(XArray)
    gfp_t               gfp_mask;       // 页面分配约束
    unsigned long       nrpages;        // 缓存页面总数
    const struct address_space_operations *a_ops;
    // ...
};

// mm/filemap.c -- 缺页处理核心路径
static vm_fault_t filemap_fault(struct vm_fault *vmf)
{
    struct page *page;
    pgoff_t offset = vmf->pgoff;
    page = find_get_mapping_entry(mapping, offset);  // 先从缓存查找
    if (page) {
        vmf->page = page;
        return VM_FAULT_LOCKED;  // 缓存命中
    }
    // 未命中:分配新页并从磁盘读取
    return page_cache_sync_readahead(mapping, ra, file, offset);
}

1.2 匿名页 vs 文件页:LRU双轨制

内核将可回收页面分为两大类:文件页(File-backed Pages)直接关联磁盘上的文件块,回收时只需丢弃(干净页)或写回(脏页)即可;匿名页(Anonymous Pages)没有文件后备(如堆、栈、malloc大块),必须写入swap分区才能回收。

从2.6.28内核开始,LRU链表被拆分为两个独立队列:LRU_INACTIVE_FILE/LRU_ACTIVE_FILE和LRU_INACTIVE_ANON/LRU_ACTIVE_ANON。这种设计的动机是:匿名页和文件页的回收代价完全不同——文件页可能只需一次磁盘I/O即可回收干净页,而匿名页需要先写入swap再读取,代价更高。通过独立链表,内核可以更精细地控制回收比例。

// include/linux/mmzone.h -- LRU 链表枚举
enum lru_list {
    LRU_INACTIVE_ANON = LRU_BASE,
    LRU_ACTIVE_ANON,
    LRU_INACTIVE_FILE,
    LRU_ACTIVE_FILE,
    LRU_UNEVICTABLE,     // 不可回收(mlock / SHM_LOCK)
    NR_LRU_LISTS
};

// mm/vmscan.c -- 活跃/非活跃平衡计算
static void shrink_active_list(unsigned long nr_to_scan, struct lruvec *lruvec)
{
    // 当链表头部页面未被访问(referenced bit = 0)
    // 将其从 ACTIVE 移动到 INACTIVE 链表尾部
    // 这就是"第二次机会"算法的实现
    if (!PageReferenced(page)) {
        del_page_from_lru_list(page, lruvec, lru_active);
        add_page_to_lru_list_tail(page, lruvec, lru_inactive);
    }
}

二、脏页生命周期与回写机制

2.1 脏页的产生与标记

当一个进程执行write()时,内核并不会立即将数据写入磁盘,而是:1) 在Page Cache中找到或分配目标页;2) 将数据从用户空间复制到页缓存;3) 将页标记为脏(SetPageDirty);4) 返回用户态。真正对磁盘的I/O由内核的回写线程(flush threads)异步完成。

脏页状态通过struct page中的PG_dirty标志位跟踪,并进一步通过inode->i_dirty链表和bdi_writeback结构进行管理。每个后备设备(Backing Device Info, BDI)都维护着自己的脏页队列,这样当一个设备出现写入错误或拥塞时,不会影响其他设备的脏页回写。

2.2 flusher线程与回写策略

内核通过两种flusher线程管理脏页回写:

  • dirty page writeback(wb_workfn):定期检查各BDI上的脏页比例,当脏页占总内存的比例超过dirty_bg_ratio(默认10%)时启动后台回写;超过dirty_ratio(默认20%)时,进程的write()会阻塞等待刷盘("写限流")。
  • expire dirty pages:脏页驻留时间超过dirty_expire_centisecs(默认3000厘秒 = 30秒)后,无论比例如何都必须回写。
// mm/page-writeback.c -- 脏页比例检查
static unsigned long node_dirty_limit(struct pglist_data *pgdat)
{
    // 脏页上限 = 总可用内存 × dirty_ratio / 100
    unsigned long dirty = (vm_dirty_ratio * available_memory) / 100;
    // 同时考虑脏页背景阈值
    unsigned long bg = (dirty_background_ratio * available_memory) / 100;
    return dirty > bg ? dirty : bg;
}

// fs/fs-writeback.c -- 单次回写工作项
static long wb_workfn(struct work_struct *work)
{
    // 遍历 bdi_writeback 的 dirty/list/io 三个链表
    // 将过期脏页通过 a_ops->writepages() 写回磁盘
    // 控制单次回写的页数避免 I/O 卡顿
    writeback_inodes_wb(&wb, WB_REASON_PERIODIC, &work);
}

2.3 F2FS/ext4/XFS的写回差异

不同文件系统的脏页回写性能差异显著:

  • ext4:使用jbd2日志层,小文件写入时journal提交与数据回写合并,在高比例脏页时可能触发journal checkpoint,导致写回毛刺。
  • XFS:基于allocation groups的日志设计,吞吐量在并发大文件下优于ext4,但元数据操作延迟波动较大。
  • F2FS:日志结构文件系统(LFS),天然将随机写转为随机写;通过checkpoint机制统一刷盘,特别适合NAND flash介质。

三、页面回收核心路径:kswapd与直接回收

3.1 回收触发水位线

每个内存节点(NUMA node)维护三个水位线:WMARK_MIN(min)、WMARK_LOW(low)、WMARK_HIGH(high)。kswapd是常驻内核线程,平时在WMARK_HIGH睡眠;当空闲页降至WMARK_LOW时被唤醒,开始异步回收直到回升至WMARK_HIGH。如果分配压力过大,进程在执行分配的路径上同步触发直接回收(Direct Reclaim),这会导致调用进程阻塞,对延迟极其敏感。

// mm/page_alloc.c -- 水位判断核心
static bool zone_watermark_ok(struct zone *zone, unsigned int order, ...)
{
    // 分配时:如果空闲页 < WMARK xss=removed>

3.2 页面扫描器scan平衡算法

kswapd调用shrink_node()时执行多轮扫描,每轮通过get_scan_count()计算各类LRU链表的扫描页数。扫描优先级(priority,默认12→0)控制回收激进程度:优先级越低,每次扫描越多页面,越易触发匿名页swap。

对于匿名页与文件页的平衡,内核使用swappiness参数(默认60)。数值越大,回收时越倾向于swap匿名页而不是丢弃文件页缓存。在数据库等重度依赖文件缓存的场景,通常将swappiness设为1甚至0,尽可能保留Page Cache,代价是OOM时被迫回收匿名页。

// mm/vmscan.c -- 扫描数量分配
static void get_scan_count(struct lruvec *lruvec, struct scan_control *sc,
                           unsigned long *nr)
{
    // 计算 swappiness 权重
    // anon_prio = swappiness; file_prio = 200 - swappiness
    // 各 LRU 链表的扫描权重 = 链表大小 / 2^(priority+1) * prio_ratio
    unsigned long anon = lruvec_lru_size(lruvec, LRU_ACTIVE_ANON);
    unsigned long file = lruvec_lru_size(lruvec, LRU_ACTIVE_FILE);

    if (sc->swappiness) {
        // swappiness < 200 xss=removed xss=removed>

3.3 页面回收的六种状态机转换

被扫描的页面在每个优先级轮次中经历以下状态转换:

  1. 非活跃链表头部 → 扫描测试访问位
  2. 未被访问 → 从LRU摘下 → 若脏则排队到回写队列 → 干净页直接释放
  3. 被访问(referenced) → 移到活跃链表(给予"第二次机会")
  4. 活跃链表尾部再次访问 → 保持活跃(working set保护)
  5. 不可回收(Unevictable) → 跳过(mlock/SHM_LOCK)
  6. 正在回写(Writeback) → 跳过(避免重复I/O)

四、高级内存回收机制

4.1 KSM(Kernel Same-page Merging)

KSM通过扫描内存中的页面,发现内容相同的页后合并为一份只读副本(COW共享),释放多余物理页。主要应用于VDI/KVM等虚拟机密集场景。KSM守护进程ksmd以pages_to_scan(默认100页/轮)扫描候选页面,通过红黑树哈希查找重复项。合理使用KSM可让VM密度提升2-3倍,但持续扫描消耗CPU,需在密度与CPU间权衡。

// mm/ksm.c -- KSM 合并检查
static int memcmp_pages(struct page *page, struct page *tree_page)
{
    char *addr1, *addr2;
    addr1 = kmap_atomic(page);
    addr2 = kmap_atomic(tree_page);
    int ret = memcmp(addr1, addr2, PAGE_SIZE);
    kunmap_atomic(addr1);
    kunmap_atomic(addr2);
    return ret;
}

static int try_to_merge_with_ksm_page(struct page *page, struct page *kpage)
{
    // 将两个页面合并为 COW 共享页
    // 原 page 释放,引用指向 kpage
    int err = try_to_unmap_one(page, ...);
    if (!err)
        break_cow(current, page);
}

4.2 zswap:内存中的压缩交换缓存

传统swap直接写入磁盘,随机读延迟在HDD上达到ms级(寻道+旋转latency)。zswap在内存中维护一个压缩缓存区(zpool),匿名页换出时先通过z3fold/zbud压缩器压缩存入zpool,只有zpool满时才后写到磁盘swap。这样高频换出的热匿名页在内存中快速换入,速度比磁盘swap快10-100倍。

从内核5.19开始,zswap支持"store only"模式:不保留磁盘swap,完全依赖内存压缩缓存。这对于低延迟容器场景(如实时推理服务)极为友好——即使短暂内存压力也不会让I/O子系统承压。

4.3 内存压缩(zRAM)

zRAM是内核内置的RAM块设备,通过LZ4/ZSTD/LZO等算法将内存页压缩存入"虚拟磁盘",再作为其上的swap使用。完全避免磁盘I/O,在内存带宽富余、计算密集度低的机器上性能远优于磁盘swap。zRAM压缩比通常在2:1到3:1之间(代码/字符串/0-padding等可压缩数据占比高时更高)。

常见的zRAM配置方案:

# 启用 zRAM(以 16GB 机器分配 4GB zRAM 为例)
modprobe zram num_devices=1
echo lzo-rle > /sys/block/zram0/comp_algorithm
echo 4G > /sys/block/zram0/disksize
mkswap /dev/zram0
swapon /dev/zram0 -p 100  # 优先级高于磁盘 swap

五、OOM Killer:最后的防线

5.1 评分机制:oom_score的计算

当kswapd、直接回收、OOM-special路径都无法挤出足够内存时,out_of_memory()被调用,OOM Killer选择进程终止。评分机制并非简单的"谁用内存多杀谁",而是综合以下因素:

  • 内存驻留集(RSS)占比
  • 运行时长(长时间运行的进程受保护)
  • oom_score_adj(用户态可调整,范围 -1000~1000)
  • 特权进程(root进程有3%的bonus保护)
  • CPU使用率(低CPU使用的进程更易被选中,因为"空闲")
// mm/oom_kill.c -- 进程评分
static unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
    long points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS);
    points = points * 1000 / totalpages;
    
    // 运行时长保护:每秒衰减 1/1000 的初始 oom_score_adj
    points += p->signal->oom_score_adj * totalpages / 1000;
    
    // 特权与root保护
    if (has_capability_noaudit(p, CAP_SYS_ADMIN))
        points -= (points * 3) / 100;
    
    // 不可杀(init / 内核线程 / OOM_DISABLE)
    if (oom_unkillable_task(p))
        points = 0;
    
    return points > 0 ? points : 1;
}

5.2 systemd-oomd 与 earlyoom:用户态 OOM

内核OOM Killer常被诟病为"反应迟钝、目标选择反直觉"。现代发行版广泛部署用户态OOM监控:

  • earlyoom:轻量级C程序,轮询/proc/*/oom_score和内存使用率,在可用内存低于阈值时选择评分最高的进程kill -KILL。响应延迟可控制在毫秒级。
  • systemd-oomd:systemd集成版本,通过cgroup v2的memory.pressure信号感知内存压力,在压力升高的周期之前就提前终止压力大的cgroup,避免触发内核OOM。

六、NUMA感知的回收策略

6.1 NUMA节点水位独立管理

在NUMA架构服务器(如双路EPYC 9754共256核/通道)上,每个内存节点维护独立的LRU链表和本地分配策略。如果本地节点内存不足但远程节点的WMARK_HIGH以上空闲,默认行为是跨节点访问(延迟比本地高约30-50%);开启numa_zonelist_order=node后优先远程节点,避免本地OOM。

6.2 AutoNUMA Balancing

内核4.1引入的AutoNUMA特性,由内核线程khugepaged的兄弟线程knumad负责:它会周期扫描进程访问过的页面,判断其访问线程是否在远端NUMA节点,若是则触发页面迁移(do_migrate_pages())将数据移到本地节点。这对于跨socket内存访问频繁的数据库(如MySQL with large buffer pool)能带来15-30%延迟改善。

七、生产场景调优实战

7.1 数据库场景:保留Page Cache

MySQL/PostgreSQL/Redis等场景的核心诉求是尽可能保留热数据在Page Cache中,避免磁盘I/O。典型调优参数:

# /etc/sysctl.d/99-db.conf
# 减少 swappiness,优先回收文件缓存
vm.swappiness = 1

# 放宽脏页回写阈值,减少小写入合并压力
vm.dirty_background_ratio = 5     # 默认10,降低后台回写频率
vm.dirty_ratio = 85               # 默认20,允许更多脏页驻留
vm.dirty_expire_centisecs = 6000  # 默认3000,让脏页停留更久

# 增大 VFS 缓存压力阈值(内核默认100,较低值保护directory inode缓存)
vm.vfs_cache_pressure = 50

# 关闭 NUMA 平衡(数据库buffer pool通常预分配,跨节点访问少)
kernel.numa_balancing = 0

7.2 容器/Serverless场景:快速回收、避免抖动

容器化场景内存压力多变,目标是在压力上升时快速回收,避免直接回收导致的请求延迟毛刺:

# /etc/sysctl.d/99-container.conf
# 适度swappiness:匿名页(JVM/C++堆)占多数,不允许完全不swap
vm.swappiness = 30

# 更频繁的脏页回写,防止脏页积压造成回写风暴
vm.dirty_background_ratio = 3
vm.dirty_ratio = 10
vm.dirty_expire_centisecs = 1000  # 10秒即刷盘

# 开启 zRAM swap(提前消化内存尖峰)
# 配合 memory.low / memory.min cgroup 保护关键容器

# 加快页回收:降低 scan_balance 激进程度
vm.page_cluster = 3  # swap时一次读取 2^3=8 页

7.3 观测工具链

内存子系统的观测需要覆盖多个层次:

工具层用途
vmstat -w / vmstat -mVMA / Slab系统级内存概览,slab缓存统计
sar -B 1 / sar -r 1Page / 内存分页速率、交换速率、缓存趋势
/proc/meminfo综合各类内存分布(Cached/Swap/Dirty/Writeback/Shmem等)
/proc/*/smaps_rollup进程级单进程的详细内存分布
perf record -e page-faultsPage Fault追踪缺页异常热点
bcc/dirtystat.py脏页找出脏页产生最频繁的文件/进程
bpftrace页回收跟踪 kswapd/shrink_page 路径

八、2026年内存回收新方向

8.1 MGLRU(Multi-Gen LRU)

2022年合并入主线内核的MGLRU是近年来最大的LRU范式变革。它不再只依赖单一位的accessed flag,而是将页面按驻留时间分为多个"世代"(Generation),通过lru_gen框架追踪页面在不同时间窗的访问模式,区分"热/温/冷"页面。相比传统LRU,MGLRU对"突发污染型"工作集(如一次性大文件扫描瞬时被缓入内存,挤占真正热缓存)的恢复速度比传统LRU快一个数量级。对默认开启的Ubuntu 24.04+服务器,绝大多数场景已能获得开箱即用的改善。

8.2 DAMON(Data Access MONitor)

DAMON是一个硬件PMU无关的内存访问采样框架,以DAMON区域管理器和dfs回收插件为代表。它能按mm_struct / address_space粒度实时采样访问模式,并将结果直接驱动内存回收(damon_reclaim)或NUMA平衡(damon_numa)。与kswapd盲目扫描整条LRU链表相比,DAMON的精准采样大幅降低了CPU开销,正在成为云厂商内部的大杀器。

8.3 cgroup v2 PSI驱动的提前回收

cgroup v2的Pressure Stall Information(PSI)接口为每个cgroup暴露cpu/memory/io的"阻塞时间占比",精度达到厘秒级。结合memory.pressure的"有压力就提前回收"闭环机制,系统能在内存紧张、直接回收触发之前,就通过systemd/kubelet终止或扩容cgroup,将峰值延迟从毫秒级压缩到亚毫秒级。在Kubernetes体系中,这已经演化成memory-pressure-eviction准入控制器和VerticalPodAutoscaler的核心输入信号。

总结

Page Cache与内存回收是一个精密的平衡系统——它需要在纳秒级加速与微秒级抖动之间、在磁盘I/O代价与CPU压缩代价之间、在进程隔离与全局共享之间持续做trade-off。从LRU链表的世代更替到KSM的页面合并,从zswap的压缩缓存到OOM Killer的终极裁决,每一层设计都在回应同一个问题:如何让有限的物理内存以最小的代价承载变化莫测的访问模式。

对于工程师来说,理解这个子系统的关键不在于记住所有参数,而在于建立"现象→工具→机制→定位"的思维链路:看到swap使用上升时,不应急于关掉swap,而是用vmstat/sar判断是swappiness过高还是Page Cache被挤占;看到直接回收导致的延迟毛刺时,不是盲目加内存,而是调整水位线和扫描优先级回收速度。唯有如此,才能在高密度、高波动的现代生产环境中维持系统的稳态。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
0.415772s