引言
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 页面回收的六种状态机转换
被扫描的页面在每个优先级轮次中经历以下状态转换:
- 非活跃链表头部 → 扫描测试访问位
- 未被访问 → 从LRU摘下 → 若脏则排队到回写队列 → 干净页直接释放
- 被访问(referenced) → 移到活跃链表(给予"第二次机会")
- 活跃链表尾部再次访问 → 保持活跃(working set保护)
- 不可回收(Unevictable) → 跳过(mlock/SHM_LOCK)
- 正在回写(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 -m | VMA / Slab | 系统级内存概览,slab缓存统计 |
| sar -B 1 / sar -r 1 | Page / 内存 | 分页速率、交换速率、缓存趋势 |
| /proc/meminfo | 综合 | 各类内存分布(Cached/Swap/Dirty/Writeback/Shmem等) |
| /proc/*/smaps_rollup | 进程级 | 单进程的详细内存分布 |
| perf record -e page-faults | Page 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被挤占;看到直接回收导致的延迟毛刺时,不是盲目加内存,而是调整水位线和扫描优先级回收速度。唯有如此,才能在高密度、高波动的现代生产环境中维持系统的稳态。

发表评论 取消回复