1. Refault Distance是什么,为什么内核内存管理需要它
做Linux性能调优和内核存储相关工作的人,这两年应该经常在内存管理相关的讨论里撞见"Refault Distance"这个词。尤其是遇到过page cache大量命中率波动、容器内存回收导致业务抖动、或者btrfs等文件系统在内存压力下表现不正常的情况,最后定位问题的时候,十有八九会绕回到这个名字上。
先说它是什么。Refault Distance,中文直译是"再失效距离",更准确的说法是"页面换出后再次因缺页而重新加载的间隔距离"。它描述的是一个被回收掉的页,在被重新访问之前,虚拟地址空间里发生了多少次内存回收活动。这个概念不是简单的内核统计项,而是一套判断机制的输入参数。内核根据它来决定当一个页面在换出后再次被访问时,到底是应该把它重新放回活跃链表,还是认为它本来就不该占着内存,继续留在非活跃链表里等下一次回收。
它解决的核心问题,是内存回收中的"误杀"和"抖动"。传统LRU回收策略只按访问新旧程度排序,但一个页面被换出之后再次被访问,不代表它真的是热数据。如果每次"二次访问"都激活页面,那么工作集比较大的场景下,内存会被大量只偶尔碰一下的数据占据,真正频繁访问的数据反而被挤出去,形成反复缺页、反复回收的抖动循环。Refault Distance的价值,就是给内核一把尺子,去量"这次重新加载的页面,到底值不值得为它保留内存"。
这篇文章适合三类人看:一是做内核存储和文件系统开发的人,btrfs的缓存在很多场景下直接受益于这套机制;二是做容器云原生基础设施、处理内存超卖和混部的运维工程师,很多内存抖动和RT抖动问题,根源就在refault判定上;三是系统级性能优化、对Linux内存管理子系统有兴趣的开发者,理解这套逻辑,对阅读vmscan.c、workingset.c会顺畅很多。我接下来会先从页面回收这条主链路讲起,再到Refault Distance怎么被算出来,最后用实际可复现的实验来讲它在什么场景下有效、什么场景下需要人为干预。
2. 从页面回收到working set:Refault Distance到底量的是什么
2.1 内存回收的基本决策困境
要理解Refault Distance,得先回到内存回收的原始问题。Linux内核的内存回收机制,每次在内存紧张时,会扫描LRU链表,把一些页面换出。每个页面从"被访问"状态跌落到"可以被回收"状态,在虚拟地址空间里其实经历了一个过程:先是CPU访问它,内核在页表里设置accessed标志位,然后页面在active链表和inactive链表之间挪动,最终被清理出来。
这里有个巨大的决策困境:到底哪些页面该被回收?如果回收少了,内存压力没缓解;如果回收多了,把刚被访问过、马上还要用的页面换出去了,那就得走一遍磁盘读回,性能损失是数量级的。
传统的做法是LRU的直觉——访问时间越久远的页面,越可能以后用不上,优先回收。这在访问模式相对均匀的场景下表现很好,但在重度文件缓存场景下很痛苦。比如数据库这种有大量随机读、频繁触碰大量不同数据的应用,页面访问模式是"看起来很多东西都在用",但实际工作集里高频命中的也就那么几个G。如果内核按LRU顺序一路回收下去,很容易把那些"偶尔会读回来,但其实可以接受重新读一遍"的数据,和"每次读回来都产生大量后续访问"的数据混在一起处理。
这时候内核对单个页面的下一次访问时间根本无法预测。它只能猜测:这个页面最近才被访问过,那以后也可能被访问。但从统计数据上看,单页的访问历史预测能力非常有限,真正有区分度的,是一批页面构成的访问模式。于是就有了working set这个概念,也就是进程在一定时间窗口内实际会访问的页面集合。如果内核估算出的working set大小超过了可用内存,那无论如何都会抖动,因为内存装不下需求;但如果working set其实比可用内存小,内核本可以更聪明地保留这些页面,只是当前策略没做到。Refault Distance就是用来做这个"更聪明"的决策的。
2.2 refault、distance和reclaim的三个关键定义
先把三个容易混淆又互相纠缠的概念拆开。
refault,指的是一个页面被回收之后,又被某个进程访问,从而触发缺页重新读回。这个事件本身在每个内存紧张的系统里都在高频发生,本身不是问题。问题是"refault的频率"和"refault页面后续被继续访问的可能性"。
distance,从字面上理解是距离。在原始论文和内核实现里,它指的是一个页面从被回收,到再次因为缺页被读回之间,系统一共回收了多少个页面。为什么用"回收页数"而不是"时间"来度量?因为时间是相对的,在不同CPU频率、不同IO延迟的机器上没有可比性;而回收页数在系统内部是有统一尺度的,它直接对应着内存的周转率。一个页面在100毫秒内被refault,和另一个页面在10万个页面被回收之后被refault,后者说明系统内存周转剧烈。
reclaim,除了表示回收动作本身,在Refault Distance的语境里更强调"回收距离"。内核的working set算法里有一个基本假设:如果系统回收的页面总数,小于工作集的大小,那么被回收的页面会再次被访问。反过来,如果回收的页面数已经超过工作集大小,说明工作集已经缩小了,或者回收的页面本身就不属于真正活跃访问的部分,那后续再访问它的概率就很低。
2.3 working set大小如何识别:Refault Distance是尺子
working set的识别,如果从虚拟机监控的角度来看,可以通过采样页表项来实现。但Linux内核没法为每个进程、每个内存区域做全量页表采样,那样CPU开销不可接受。Refault Distance提供了一种轻量化的替代方案:利用页面换出后重新进入的往返过程,来自动标记哪些页面属于当前工作集。
这里有个非常妙的逻辑。当一个页面被回收后,如果它距离上一次访问已经经历了很多轮的回收活动,那么,当它再次被访问时,它大概率已经不属于当前工作集,内核不应将其激活回active链表,而应任其待在inactive链表的尾部,等待后续被回收。反之,如果它被回收后很快就因为再次访问而refault,说明这个页面仍然处在工作集内部,那就应该被激活,并且激活行为本身可以用来更新系统对工作集大小的估算。
换句话说,Refault Distance是一把尺子。尺子的刻度,不是年月日时分秒,而是系统回收页面的数量。通过这把尺子,内核避免了每次refault都盲目激活页面的问题,把"这个页面值不值得留在内存里"的判断,从单页信息拓展到了整个内存回收过程的信息。
3. 算法核心拆解:shadow node、访问距离与激活判定
3.1 shadow entry与shadow node的数据结构设计
Refault Distance的实现依赖一套隐藏在radix tree / xarray里的数据结构,叫shadow entry和shadow node。这个机制的背景是:页面被回收时,page cache的radix tree项会被移除,但内核并不是完全丢掉这个页面的线索,而是保留上一个被换出页面的访问时间戳和回收信息,存在一个轻量的节点里,也就是shadow entry。
shadow node是这些entry的容器。在Linux 4.x以上的内核里,xarray取代了radix tree,shadow entry的实现也做了相应调整,但核心思路是一样的。每个shadow node保存着一组指针,指向被回收页面的信息,包括回收时的时间戳,准确说是jiffies时间戳。当一个缺页发生时,如果对应的radix tree/xarray项已经不在,但shadow entry还在,内核就能知道"啊,这个位置之前有个页面,走了,现在又被访问了"。
shadow node本身占用的内存是受控的。Linux使用workingset_node_shadows、workingset_node_pages等计数来跟踪节点状态,当node内部的shadow entry数降到零时,node本身也会被回收。这避免了因为缓存回收元数据而导致内存膨胀的尴尬。
不过shadow entry只是拿到了"发生过refault"的信号。真正要做激活判定,还差一个关键信息,就是这个页面被回收前,在内存里已经存活了多久。这就是接下来要说的激活距离。本质上,shadow entry里的时间戳,和当前缺页时间之间的差值,就是这个页面在内存中被换出后到再次被换入时经历的"时间",配合回收活动计数,就可以计算出refault distance。
3.2 activation distance的计算方式与关键参数
从实现层面看,页面被回收前会记录一个"访问时间",这个时间被存在struct page的某些字段(在page cache场景下,可以复用page->private等字段)中。当页面被重新读入并触发refault时,内核拿当前时间减去旧时间,再把这个差值跟一个"系统级阈值"对比。这个阈值就是跟工作集大小直接相关的值。
论文和内核里常用的简化公式是这样的:
activation_distance ≈ (refault_time - last_accessed_time) / sampling_period
但实际在Linux workloads环境下,计算方式更朴素。被回收时,页面的激活信息被记录下来;refault时,内核从shadow entry读回这个信息。然后计算:
refault_distance = refault_distance_from_shadow(entry)
再把这个距离与active list长度和非活跃链表长度之间的关系做比较,判断是否执行activate。
这里有一个非常重要的内在关系:refault distance和系统当前工作集大小之间的差值决定了激活行为。如果refault距离比较小,说明这个页面离开后系统没经历太多reclaim,工作集还在;如果refault距离比较长,说明已经发生了很多回收活动,页面大概率已经不在工作集内。内核只需要维护一个全局的"当前回收页面总数"计数器,再在每个shadow entry里存回收时的计数快照,就能算出refault distance。
参数上,内核里定义了WORKINGSET_ACTIVATE_THRESHOLD这样的概念,老版本内核里甚至有一个叫pagecache_limit的路径,但现代内核的核心逻辑,已经收敛到了workingset.c中的workingset_refault()函数。它接收一个shadow entry,根据当前node状态返回一个布尔值,决定是否对被refault的页面执行activate。
3.3 workingset_refault的判定流程
workingset_refault的函数实现里有几个核心步骤。
首先,从shadow entry中解析出旧的时间戳信息。内核用pack_shadow/unpack_shadow这两个宏来打包解包shadow entry,因为一个xarray项里不止有时间戳,还包括zone id、generation id等信息,这些字段打包在一个unsigned long里。
然后计算refault distance的真实值。距离的基本单位是内存回收页面的数量,为了避免频繁访问node中所有shadow entry,workingset.c使用了一种"压缩采样"的思路,把每个节点的refault信息和全局回收活动做换算。
关键的判定逻辑大致是:
if (refault_distance < active_list_size + inactive_list_size) 则激活。
为什么用active_list_size + inactive_list_size?因为这是当前系统里所有LRU页面的总数,说白了就是内存里能装下的页面总量。如果一个页面在被回收后、再次被访问前,系统回收了比所有LRU页面数量还多的页面,说明它被换出之后内存已经完完整整地周转了一圈,工作集已经换血,这个页面不该被当作热数据。反过来,如果refault distance小于这个值,说明页面在被换出后,系统还没来得及完成一轮完整LRU周转,那么它很可能是无辜被换出的热页面,应该被激活。
这个判断在page cache场景下非常有效,但在anon内存场景下有特殊情况需要处理,后面我会专门讲。
3.4 为什么LRU链表长度成为比较基准
很多人会问,为什么偏偏拿LRU链表长度当阈值,而不是拿一个固定的时间值,比如5秒、10秒?原因是固定的时间值对负载变化不敏感。比如系统在疯狂读文件,每分钟回收100万个页面,和系统基本空闲,每分钟回收100个页面,你对一个页面做5秒的容忍度,前者会导致大量refault被误判为热页面,不断激活,反而放大内存压力;后者会导致真正热的数据在5秒后被误杀。
而LRU链表长度是动态的。它直接反映着"系统当前以多大速度在周转内存"。当系统内存压力大,LRU链表变短,阈值变小,refault被激活的门槛变高,更多的页面会被拒之门外,这反过来抑制了内存压力的进一步恶化。当系统内存充裕,LRU链表变长,阈值变大,稍微有一点refault的页面就会被激活,避免频繁读盘。
这是一种自适应的反馈调节,也是Refault Distance这套机制最有价值的工程点。它不是静态规则,而是基于系统实时状态的动态决策。这也是它能在生产环境里真正起效的原因。
4. 实操:在内核源码里追踪Refault Distance的完整脉络
4.1 内核版本与关键代码位置
如果你现在的内核版本是Linux 4.20以后,建议直接看mm/workingset.c和mm/vmscan.c。Refault Distance的核心实现在workingset.c里,大概400行左右,函数列表非常清晰。
我以Linux 5.15 LTS内核为例来梳理,这个版本是当前生产环境用得最多的长期稳定版之一,代码逻辑也最典型。
关键位置一:include/linux/mmzone.h,这里定义了workingset相关的zone状态、LRU链表状态。要注意page->pgmap和page->shadow这些字段在不同场景下的复用,注释里有明确说明。关键位置二:mm/workingset.c的workingset_refault()函数,这是整个算法的决策核心。关键位置三:mm/vmscan.c的shrink_page_list(),页面被回收时,如果发现是page cache页面,就调用workingset_eviction()来记住回收信息。
4.2 页面回收路径:workingset_eviction
在vmscan.c的shrink_page_list()里,当一个page cache页面被回收时,会走到一个分支:
if (PageWorkingset(page)) { workingset_eviction(page, target_memcg); }
这里的PageWorkingset标志,表示这个页面曾经被识别为工作集的一部分。workingset_eviction做的事情,就是把这个页面的回收信息打包进shadow entry。具体包含:当前的workingset代际、回收时的时间戳、zone信息。打包后存入page cache对应的xarray项中,后续如果页面被重新读回,xarray项查不到真实页面,但能查到shadow entry,就能触发refault路径。
有个细节值得注意,shadow entry的存续时间长度,受内存回收活动影响。如果系统一直在回收内存,被替代的shadow entry也会被清理。这保证了shadow节点不会无限累积。
4.3 缺页路径:workingset_refault的完整执行
页面被重新读入时,缺页处理函数会调用find_get_entry(),此时发现xarray项是shadow entry,就会调用workingset_refault()。
我来贴一段关键逻辑的伪代码,帮助理解:
static bool workingset_refault(void *shadow) { unsigned long refault_distance; struct pglist_data *pgdat; unsigned long active_file; unsigned long inactive_file; unsigned long active_anon; unsigned long inactive_anon; /* 从shadow entry中解包出本次refault的时间戳信息 */ refault_time = unpack_shadow(shadow, &zone_id, &memcgid, &workingset_id); /* 计算refault distance——从shadow entry记录时刻到现在的内存回收量 */ refault_distance = atomic_long_read(&node->inactive_age) - refault_time; /* 获取当前内存节点的各类链表长度 */ active_file = lruvec_page_state(lruvec, NR_ACTIVE_FILE); inactive_file = lruvec_page_state(lruvec, NR_INACTIVE_FILE); active_anon = lruvec_page_state(lruvec, NR_ACTIVE_ANON); inactive_anon = lruvec_page_state(lruvec, NR_INACTIVE_ANON); /* * refault distance小于所有可回收页面总数时,认为该页面仍然属于工作集 * 这个"所有可回收页面"就是active_file + inactive_file + active_anon + inactive_anon */ if (refault_distance <= active_file + inactive_file + active_anon + inactive_anon) return true; return false; }这个函数返回true,缺页路径就会对刚读回的新页面设置PageActive标志,并把它放到active链表;返回false,新页面就放在inactive链表尾部,随时可以被回收。
注意,这里有一个非常关键的细节:refault_distance使用了一个叫inactive_age的计数器。这个计数器会随着每次内存回收而递增。把"时间"转化为"年龄"的思路,是整个算法里最精妙的地方。它不关心页面在物理时间上离开了多久,只关心在回收活动中经历了多少"岁月"。这天然地适应了不同负载特征的系统,且不依赖于一个全系统统一的时钟精度。
4.4 使用tracepoint观测refault行为
实际调试时,直接读源码容易一头雾水,最好的办法是用内核自带的tracepoint。Linux 4.19之后,mm/workingset.c里增加了几个tracepoint,配合ftrace可以直接观测refault的实时行为。
常用的tracepoint有:
- workingset_refault:页面发生refault
- workingset_activate:页面被判定为需要激活
- workingset_restore:页面在anon内存场景被恢复
追踪命令:
cd /sys/kernel/debug/tracing echo 'workingset_refault' > set_event echo 1 > tracing_on cat trace_pipe这样能看到每个refault事件的时间戳、进程名、页面地址。再配合perf的pagefault事件统计,就能定量分析系统的refault频率,验证算法是否在正常工作。
我个人习惯还会配上一份cgroup的内存压力监控:
cat /sys/fs/cgroup/memory/memory.pressure或者新版cgroup v2下的:
cat /sys/fs/cgroup/memory.pressure4.5 用bpftrace观测内核函数的判定结果
如果tracepoint不够满足你,可以用bpftrace挂到workingset_refault函数上,直接看内核判定结果分布。
kprobe:workingset_refault { @refault_total = count(); } kretprobe:workingset_refault { if (retval == 1) { @activate_total = count(); } }跑一段时间后,如果@activate_total和@refault_total的比例明显偏高,比如激活率达到80%以上,说明系统里大量refault页面都被判定为热数据;如果比例极低,说明工作集已经超出内存能力,系统正在持续抖动。这两种状态需要不同的优化策略。
通过这样的观测,你能直观地看到算法运行的最终效果,而不是停留在源码分析层面。这套观测方法对排查生产环境的内存问题非常实用。
5. 实操:模拟refault场景与验证算法效果
5.1 构造一个可重复的实验环境
要真正理解Refault Distance,光看代码和tracepoint还不够,最好自己动手构造一个refault场景,观察算法在不同参数下的行为。
我推荐用两种方式构造:
方式一:受限内存容器跑文件读循环。用docker或cgroup v2限制一个容器内存为256MB,然后在里面执行一个循环读文件的程序,读取的数据集大小为1GB。此时page cache容量远小于数据集,必然出现大量refault。在这个场景下,可以观察Refault Distance算法对页面激活的抑制效果。
方式二:用fio加内存压力。先用一个大文件读取把page cache填满,再用另一个进程不断写匿名内存制造内存压力,触发page cache的回收。然后继续读文件,观察refault行为。
实验环境建议使用一台独立虚拟机或容器,内核版本5.15以上,内存不少于4GB,避免宿主机的其他负载干扰观测结果。
5.2 实验步骤与内核参数
我以方式一为例,给出完整步骤。
# 创建一个内存受限的cgroup mkdir /sys/fs/cgroup/memory/refault_test echo 268435456 > /sys/fs/cgroup/memory/refault_test/memory.limit_in_bytes # 把当前shell放入cgroup echo $$ > /sys/fs/cgroup/memory/refault_test/tasks # 生成1GB的测试文件 dd if=/dev/urandom of=/tmp/testfile bs=1M count=1024 # 循环读文件 for i in $(seq 1 20); do dd if=/tmp/testfile of=/dev/null bs=1M count=1024 2>/dev/null done执行完循环后,回头查看cgroup的内存统计:
cat /sys/fs/cgroup/memory/refault_test/memory.stat重点关注pgpgin、pgpgout、pgmajfault这几个字段。pgmajfault如果居高不下,说明系统在频繁缺页,并且这些缺页不是completed从page cache里直接命中的。
同时用前面提到的tracepoint观察:
cd /sys/kernel/debug/tracing echo 'workingset_refault' > set_event echo 'workingset_activate' > set_event echo 1 > tracing_on sleep 10 cat trace | head -100注意观察activate事件的比例。如果refault很多但activate很少,说明算法在正确压制非工作集页面的激活。
5.3 不同内存限制下的行为对比
为了看出Refault Distance的差异化决策,建议对比三组实验:
第一组,内存限制1GB,数据集1GB,这种情况内存刚好放得下,理论上有足够的缓存空间,不应该有大量refault。
第二组,内存限制512MB,数据集1GB,内存大约只有数据集一半,refault必然大量发生,此时激活率会呈现一个中间状态。
第三组,内存限制128MB,数据集1GB,内存严重不足,refault量巨大,但激活率应该极低,因为算法会判断这些页面即使激活也存不下来,不如不激活。
我把在不同限制下观察到的典型数据列成一个表:
| 内存限制 | 数据集大小 | refault量 | activate比例 | 主要现象 |
|---|---|---|---|---|
| 1GB | 1GB | 较少 | 中度偏高 | 缓存基本命中,refault多因首次读取 |
| 512MB | 1GB | 中等偏多 | 中度 | 缓存部分命中,抖动适中 |
| 128MB | 1GB | 极多 | 偏低 | 持续颠簸,激活无法解决问题 |
这个表的价值在于,它直观地展示了Refault Distance的自我调节特性。内存越紧张,激活越多,但激活率反而降低,甚至低于内存充足时。因为内存压力大,LRU链表总长度缩短,refault_distance超过阈值的可能性反而变大。算法不会因为内存紧张就硬把所有refault都当作热数据来处理,它会精确地感知到"此页面即使激活,很快也会被再次回收",从而选择不激活。
5.4 一个典型的生产场景:文件服务器内存抖动
我在实际工作中遇到过类似案例。某对象存储网关节点,内存64GB,后台会持续扫描大量小文件,同时有用户请求读取这些文件。系统出现周期性抖动,表现为客户端时延飙高。排查时发现pgmajfault周期性暴增,而pgsteal也同步增长。
用perf top观察,发现shrink_page_list占CPU比较高,同时workingset_refault频繁被调用。进一步用tracepoint追踪后,发现大量refault被判定为false,即不激活,因此页面读回后依然在inactive链表尾部,很快又被回收,再访问时再次refault。这形成了一个死循环。
后来分析发现,问题不在Refault Distance算法本身,而在于系统里有一个进程在做扫描工作,把page cache全部污染了。真正频繁访问的热数据占比其实不高,但丧尸进程产生的扫描页面,把LRU链表搅得天翻地覆。最终修改方案是给扫描进程设置独立的cgroup,限制其page cache占用,并对用户请求线程的cgroup设置更高的内存优先级。这样改动后,refault不再大量发生,时延恢复正常。
这个案例给一个非常实用的启示,Refault Distance不是万能药。它在合理的内存分区下能高效工作,但如果系统里有不当的大规模线性扫描,算法本身无法区分,只能把压力全部体现在refault和页面激活的频繁切换上。这类问题需要结合cgroup、ionice等手段从源头治理。
6. 常见问题与排查技巧实录
6.1 refault距离太短导致的缓存命中率下降
症状:page cache命中率从95%以上掉到80%以下,应用时延明显上升,内存并没有被完全占满。
排查思路:先确认是否存在refault事件的突增。用perf trace或bpftrace统计workingset_refault的调用次数,如果数量很高但activate比例不高,说明大量refault被判定为不需要激活。
可能原因之一是inactive链表长度被压缩得很短。Refault Distance算法的阈值是active_list + inactive_list,如果其中一个几乎为空,阈值就会变小。比如文件系统缓存都跑到了active链表,inactive链表空荡荡,那么inactive_age的推进速度会变得异常快,导致refault距离计算偏大,正常热页也被误伤。
解决办法:关注内核文档提到的sc->file_is_tiny逻辑。当文件缓存总量太小(通常小于总内存的某个比例,比如2%)时,内核会跳过workingset_refault的激活判定,直接激活所有refault页面。这是为了避免"文件缓存过小时,所有refault都被压制"的极端情况。如果你的系统里文件缓存经常处于低水位,靠这套算法的自动调节是救不回来的,必须从业务层面减少内存占用,或者用其他手段限制anon内存的挤占。
6.2 anon内存场景下refault distance失真
匿名页面的回收和page cache回收有一个本质区别:匿名页面回收时必须先写入swap,读回时也走swap。这意味着一次anon refault的代价比page cache refault高一个数量级。但在workingset_refault的实现里,anon页面的distance计算和file页面用的是同一套算法,没有区分代价差异。
解决这个问题的倾向,是给anon设置更高的激活优先级。我在生产里也做过类似的调整,通过调整vm.swappiness参数来间接影响anon页面的回收倾向。如果设备有足够的swap空间,还可以把swap换入换出的代价降下来。但如果swap本身在慢速磁盘上,那可能要从业务层做处理,减少匿名内存压力的冲击。
6.3 refault distance出现负数
理论上refault_distance = inactive_age - refault_time,如果refault_time快照晚于inactive_age,会得到负数。这种情况说明shadow entry的时间戳比当前inactive_age还新,通常由两类原因引起:
第一类,shadow entry长时间不被访问后,workingset_node里存储的refault信息已经过期,但node还没有被回收。内核维护了一个refault_inactive的定时清理逻辑,在workingset_refault里如果检测到distance异常(比如UVISITED),会视为无效shadow entry,直接返回false不激活。
第二类,RPS(Remote Process Service)或cgroup迁移导致的内存节点切换,shadow entry里的数据在迁移中出现了不匹配。这类问题内核在较新的版本中通过增加generation校验来缓解。
实际排查时,可以在bpftrace脚本里打印distance值,如果发现大量负数,先检查内核版本,再看cgroup配置是否频繁变更。
6.4 THP和大页对refault distance的影响
透明大页开启后,页表的基本单位变成2MB。当一个2MB大页被回收,再次触发refault时,内核要按base page(4KB)来处理,此时一个THP会被拆分成512个base page,每个base page都要经过一次refault判定。
这个场景下,Refault Distance的计算结果会被放大,因为inactive_age的单位是base page。举个例子,一个THP被回收,内核记录了一个时间戳。当它被重新读回触发refault时,distance计算出来可能是几万个页面,但实际上只是一个2MB大页的回收。这种情况下,如果阈值不够高,可能直接判定为不需要激活,导致大页读回后又被晾在inactive链表尾部,很快再次被回收。
生产环境如果频繁使用THP,建议关注thp_nr_page_cache_swpout等统计指标,必要时对特殊情况设置thp的split行为。不过常规场景下,内核已经通过unmapping和split逻辑做了很多保护,不会让这个场景无限恶化。
6.5 用memory.reclaim和memory.peak辅助判断
在cgroup v2下,可以直接用memory.reclaim接口来主动触发某个cgroup的内存回收,配合内存压力事件,可以快速复现refault相关的问题。
做法是监控memory.pressure里的si_mem_available值,当它掉到很低时,手动执行:
echo "1M" > /sys/fs/cgroup/xxx/memory.reclaim然后观察memory.stat里的pgscan和pgsteal,以及psi文件里的cpu时间。如果psi的some/avg值在高位持续,十有八九系统在经历严重的refault循环。用这个方法来区分"内存真的不够"和"内存有但分配不均"两种场景,能少走很多弯路。
7. 多一点实用经验
我在实际调优中最大的体会是,Refault Distance不是一个可以拿着文档照搬参数的算法,它更像一个动态标尺,需要结合业务访问模式来理解。它对连续顺序读的场景优化效果有限,对随机读和小文件密集访问场景的优化效果最明显。如果你的应用表现出refault高、activating高、但时延没有恢复,那通常不是算法设置问题,而是业务模式本身需要调整,比如减少非热点数据的内存缓冲,或者在应用层做数据热度的预分类。
另外,调试这类问题时,一定要记住页面回收量和时间并不是一回事。用inactive_age而不是时间戳作为尺子,是这套算法的灵魂。理解了这一点,你就不会再犯"为什么我的系统空闲时refault反而更多"这类方向性错误了。
还有一个容易忽略的小技巧:在cgroup v2环境下,不同内存cgroup的refault距离是独立计算的。如果你有多个业务容器共用同一台机器,最好为每个容器单独监控workingset_refault的tracepoint,避免把容器间的内存回收活动混在一起分析。实际效果上,容器级别的隔离越严格,refault distance的判定越准确。
Refault Distance在btrfs等文件系统的缓存管理上已经有实际落地,未来如果内存资源更加紧张、混部场景更多,这套机制会是内存QoS里不可绕过的基础设施。现在花点时间把它吃透,以后碰到内存抖动、page cache命中率掉坑,排查起来会顺手很多。