把一台内存 128G 的机器压到只剩 4G 可用,然后盯着监控页看整整一下午,这种事我干过不止一次。最有意思的一次是:内存明明没有被打满,CPU 也还有余量,但服务的延迟曲线每隔几分钟就抽风一次,dmesg 里全是页面回收引发的 stall 记录。后来把内核参数、回收策略、工作集大小这些因素逐个排查完,才发现问题出在传统 LRU 对“时间”这个概念的处理上——它太依赖页面被访问的先后顺序,却没法高效地回答“哪些页最近真的被频繁使用”。Linux 6.1 内核正式合入的 Multi-Gen LRU(多代 LRU)算法,就是为了解决这个问题出现的。
这篇文章我会从传统 LRU 的瓶颈讲起,把 Multi-Gen LRU 的分代思想、页表扫描、匿名页与文件页配额博弈这些核心机制拆开说清楚,再给出一套可落地的编译、调参、压测和排障流程。适合这几类读者:搞内核和内核调优的工程师、做容器平台和云基础设施的运维,以及在大型服务里天天跟内存水位和 swap 打交道的性能优化人员。你能从中得到的,不只是“怎么打开这个特性”,而是理解它为什么有效、什么时候无效、以及真正把它的收益发挥出来需要做什么。
1. 先聊聊传统 LRU 为什么不够用
1.1 双链表模型的四个真实痛点
传统 LRU 的经典实现是 Active/Inactive 双链表。页面第一次被访问时进入 Inactive 链表,反复被访问后会被提升到 Active 链表,老页面从 Active 尾部逐步降级回 Inactive,回收线程主要从 Inactive 尾部取页。这个设计本身没问题,它用两次机会机制过滤掉偶发访问,思路直观,也容易维护。但当你把它放到现代大内存、复杂访问模式的环境里,问题就接踵而至。
第一个痛点是扫描成本。回收器为了找到足够多的可回收页,就得从 Inactive 尾部往前遍历,每经过一页都要做引用检查、反向映射查找、页面状态判断。内存越大,链表越长,真正被回收的页占扫描过的页的比例就越低。我在一次内存 192G 的机器上做过统计,传统 LRU 在重度回收时,单次 shrink 扫描中真正被回收的页只有百分之十几,其余大部分时间都花在“确认这页确实不热”这件事上。
第二个痛点是对“单次访问”过于敏感。一个页面只要被访问过一次,就可能被提到 Active,哪怕这个访问只是 mongod、日志扫描这种顺序读产生的一次性触碰。批量导入、全量索引这种扫描型场景,会把成千上万的冷页一次性推入 Active 链表,把真正稳定的热页挤出去。热页一旦被挤出 Active,很容易在 Inactive 尾部落入回收区。
第三个痛点是匿名页和文件页的隔离回收。传统 LRU 把匿名页和文件页分开维护,页面回收器在两者之间按固定比例分配收缩预算。问题在于,这个比例跟业务的实际访问模式往往不匹配。文件页缓存被反复挤出时,磁盘 IO 立刻上升,而匿名页那边可能还囤着一堆只有生命周期没有复用价值的页。
第四个痛点是它基本不感知“refault”。所谓 refault,就是页面被回收后再次访问触发的缺页。如果回收器把工作集内的页面误杀,业务就会在磁盘和内存之间来回折腾,这种抖动在监控上表现为 pgscan 和 pgsteal 成对暴涨,应用延迟出现周期性尖峰。传统 LRU 要等到 refault 反复发生后,才会通过页面的 workingset 状态做一些被动反馈,反应速度太慢,而且反馈粒度以单个页面为单位,效率不高。
1.2 从“单链表”到“分代”的思路转变
MGLRU 的最核心变化,是把“页面级前后顺序”改成“代际批量管理”。你可以理解成文件归档:传统 LRU 像一本按时间排序的账本,回收页面时要从最后一页往前翻,逐条确认哪些条目过期;MGLRU 像把数据按天打包,用户只删最旧的几个整包,不需要知道包里每个文件的最后修改时间。
代(generation)在这里代表一个逻辑时间窗口。页面不断从新的代向旧的代移动,回收器只从最老的代获取回收目标。一个页面只要在最近几个时间窗口内被访问过,就会停留在较新的代,回收器根本不会碰到它。这种方式天然过滤掉偶发冷页,也让扫描范围从“整条链表”缩小到“一级最老代”,回收成本大幅下降。
这套思路并不是凭空发明的。早在 MGLRU 进入主线之前,就有内核开发者意识到 LRU 缺一个“时间维度”,只是当时的实现方案在内存开销和扫描复杂度上无法平衡。MGLRU 的贡献在于,它用一组精心设计的位图、代际计数和页表扫描机制,把时间维度的维护成本压到了可以接受的范围。后面我会把这些具体的实现手段逐个展开。
2. Multi-Gen LRU 的核心机制逐层拆解
2.1 用时间批量管理页面:代际如何产生与流转
MGLRU 把页面按照访问新鲜度分成多个代,常见实现里每个内存节点维护一组代,从最年轻到最老排列。页面被访问时,如果它当前位于较老的代,就会被提升到最新代,这个过程叫 promotion;随着时间推进,所有代的编号逐步递增,旧代的内容逐渐“老化”,这个过程叫 demotion。
老化不是定时器驱动的,而是由回收线程和内存压力反馈驱动的。也就是说,当系统内存充足时,代可以少翻,页面在年轻代待得久一点;当内存压力升高,回收线程会主动推进代际,把更多页面推到更老的代,从而扩大可回收池。这个“按压力调速”的设计,比固定时间窗口要聪明得多,它能自动适配业务波峰波谷。
从实现上看,每个 folio(内核现在以 folio 为内存管理基本单位,可以把它理解成“一页或一组连续页”)都会维护一个代际标记。标记的读取和修改,尽量用页表项提供的访问位和数据结构里的位标志完成,不需要额外为每个页面分配一个大字段。这样做的直接好处是内存开销可以忽略不计,坏处是并发读取多代状态时需要仔细设计锁,这也是 MGLRU 早期补丁迭代很多轮的原因之一。
2.2 只扫最旧代,不碰年轻代:回收路径的核心区别
传统 vmscan 的回收路径是:对某个 lruvec 先扫描 active 链表,把一部分页面降级,再扫描 inactive 链表,挑选可回收页。这个过程会碰到很多仍然很热的页,于是需要不断跳过、重新检查引用,浪费大量 CPU。
MGLRU 的回收路径明显更“偏科”。它会直接从最老代开始,把这一代里的页面整批送入回收器。最老代里的页面,理论上都已经在多个时间窗口内没有任何访问,回收器可以快速判断并回收。年轻代里的页面,回收线程基本不碰,因为从统计上看它们更可能属于当前工作集。
这个设计带来了两个肉眼可见的效果:第一,回收扫描的无效工作量大幅下降;第二,热页被误杀的概率降低。我自己在压测里对比过,传统 LRU 在内存压力升高时,回收线程占用 CPU 的时间可以占到单核接近 60%,而 MGLRU 在同样压力下,回收线程 CPU 占用只有原来的三分之一左右,而且波动更平稳。
这里的“最老代”并不是固定的某一代,MGLRU 会根据内存压力和 refault 情况动态调整所操作的代范围。内存压力越大,允许回收的旧代数量越多;如果回收后 refault 增加,说明回收过头了,系统会减少回收深度,让页面在更年轻的代上多停留一会儿。这就是它自反馈的雏形。
2.3 页表扫描:把“是否被访问”这件事查得更彻底
MGLRU 里有一个很关键的功能,就是主动扫描页表。传统 LRU 判断页面是否被访问,主要依赖硬件在页表项上设置的年轻位(PTE young),以及页面自己的引用标志。这种做法的缺点是异步、松散:回收器在扫描链表时,需要通过反向映射找到页表项,再逐个检查年轻位,效率很低。
MGLRU 的做法是定期对指定的内存映射做页表遍历,把一段时间内被访问过的页面统一标记出来。大致流程是这样的:先清掉一批页表项的访问位,等待一个时间片,再回来看哪些页表项重新置上了年轻位。那些重新置位的页面,说明在实际运行中真的被访问过,MGLRU 会据此把它们提升到更年轻的代。这相当于把“是否活跃”的判断从链表扫描前移到页表周游阶段,一次遍历可以同时确认成千上万个页面的访问状态。
页表扫描不是没有代价的。它需要持有内存映射相关的锁,遍历进程的页表结构,在高频创建和销毁映射的场景下会有额外开销。MGLRU 的实现里对扫描频率和并发做了很多限制,比如按内存节点的扫描配额、按 mm_struct 分组扫描、避免无意义的重复遍历。实际部署中我看到的典型结论是:对于大页缓存、长生命周期进程居多的服务,页表扫描的收益远大于开销;但如果你面对的是大量短生命周期进程,比如每秒钟启动几千个短时任务,需要留意扫描开销是否被放大。
2.4 匿名页与文件页的配额博弈
内存回收始终面临一个选择:优先回收文件页,还是优先回收匿名页?回收文件页可以释放内存但可能带来磁盘 IO,回收匿名页通常需要把页面内容写回 swap 设备,代价更高。传统内核用 swappiness 参数给这个选择定基调,但它是一个相对粗糙的全局阈值。
MGLRU 对这个博弈做了更细的控制。它在每个代内部仍然区分匿名页和文件页,但回收配额不是固定的,而是根据 refault 反馈动态调整。如果文件页反复被回收后又缺页,说明文件页缓存可能更需要保留,回收器会把预算向匿名页倾斜;反过来,如果匿名页频繁写 swap 而没有实际收益,预算会向文件页倾斜。
我见过不少团队在传统内核上无脑把 swappiness 调到 0,以为这样能防止 swap。但代价是文件页缓存被压缩得很小,每次文件读取都变成磁盘 IO,应用延迟照样崩。MGLRU 下我一般建议不要一上来就动 swappiness,先让它用默认值跑一轮,再看 PSI 指标决定是往 10 还是往 30 方向调,效果通常更可控。
还要特别说明一点:MGLRU 的配额控制是 per-lruvec、per-memcg 的。也就是说,不同容器可以有不同的回收比例,一个负载偏文件 IO 的容器和一个负载偏匿名内存的容器,在同一个节点上可以各走各的策略,这一点比传统全局 swappiness 更精细。容器平台用好了这一层,往往能从整体上把内存利用率提上去。
3. 关键数据结构与代码路径笔记
3.1 lruvec 上的代际状态与 folio 标记
MGLRU 的运行时状态主要挂在每个 lruvec(逻辑上的内存回收单元,通常对应一个内存节点和一个内存 cgroup 的组合)上。每个 lruvec 内部维护一个多代状态结构,里面记录着当前最大代、最小代、每个代的页面数量,以及代际之间的位图标记。这里不展开具体代码,但理解它的核心关系就够了:回收器每到一个 lruvec,先看这个 lruvec 的“代际年龄”,决定如何入手,而不是盲目地从链表头部开始翻。
folio 上的代际信息用位标志保存。folio 在这里取代了传统结构中的单个 page,它可以包含多个物理页,比如一个 2MB 的大页、一个缓存了 4KB 文件页的页组。MGLRU 以 folio 为单位维护访问记录和回收状态,这直接提升了回收吞吐量。传统 LRU 面对大页时,要以 page 为单位挨个处理,很容易产生大页拆分开销;MGLRU 则能迅速识别出整个 folio 是否已经进入老代,决定整体回收还是整体保留。
从代码路径的角度看,页面访问事件发生时,会经过页错误处理、页表年轻位更新等路径,最终通过 MGLRU 的统计模块更新代际标记。这种设计把“访问事件”和“回收决策”解耦开,访问发生时只需要做轻量标记,不必同步调整整个链表结构;回收发生时再集中根据代际状态决策。好处很明显:降低锁竞争,减少路径上的延迟。
3.2 NUMA 节点下的本地优先决策
现代服务器基本都是多路 NUMA 架构,内存访问有本地和远端之分。MGLRU 在回收和老化时,天然会把内存节点的维度纳入考量。每个节点的 lruvec 独立管理自己的代际状态,回收线程优先扫本地节点;对远端内存页的扫描,不会像全局扫描那样粗暴参与逐页判断,而是尽量控制跨节点影响。
这一点在运行 NUMA 感知的数据库或者大规模虚拟化场景时尤其重要。传统回收在节点之间均衡水位时,容易把远端内存页当成普通页处理,导致进程明明在某个 NUMA 节点上跑,内存页面却被回收线程折腾到另一个节点,后续访问延迟离谱。MGLRU 的节点隔离处理,虽然不会自动做内存迁移,但至少能避免回收策略加剧跨节点访问。
实际调优时,如果你用了 numactl 做了 CPU 与内存绑定,建议配合检查/sys/kernel/mm/lru_gen/和 NUMA 相关的分配策略。MGLRU 不会覆盖你的绑定策略,但会让回收动作更配合节点的本地性。对在不支持 NUMA 的小机器上跑,这块收益不明显,可以跳过。
3.3 cgroup 维度:内存隔离的价值远超预期
我用容器做隔离时间久了,最大的感受是 cgroup 的内存隔离做得好不好,直接决定一个节点上多个业务能不能共存。传统 LRU 的回收判断经常是全局的,一个容器把文件页缓存吃到撑,可能导致整个节点的回收器把另一个容器的热页也挤出去,这叫“内存邻居效应”。
MGLRU 的每个 lruvec 都挂在具体的 memcg 下,所以它在回收时天然带着内存组的信息。回收器在某个 memcg 的老代里选页时,只会操作这个 memcg 自己的页面,不会去动另一个容器的热页。这意味着你在容器侧设置 memory.max、memory.high、memory.low 时,MGLRU 的态度会更忠实:它在超限的容器里优先做回收,而不去骚扰那些还没达到限制的容器。
cgroup v2 接口在 MGLRU 的配合下变得更实用。比如你可以在一个容器里设置 memory.low 来保护它的基础内存,设置 memory.high 限制突发,再通过 memory.reclaim 手动触发一次内存回收实验。这些操作在传统内核下效果不佳,是因为回收器难以在全局视角下精确执行 memcg 粒度决策;MGLRU 则把这个粒度的事变成了常规操作。
4. 编译开启与运行期调参实操
4.1 三步确认内核是否真的启用了 MGLRU
第一步是确认内核编译配置。MGLRU 对应的配置项是CONFIG_LRU_GEN。你可以用下面的命令检查当前内核是否包含这个配置:
grep LRU_GEN /boot/config-$(uname -r)如果输出里有CONFIG_LRU_GEN=y,说明内核编译进了 MGLRU,可以直接用;如果是=m,需要确认模块是否加载;如果什么也没有,说明发行版内核没带这个特性,你要么换一个开启该配置的内核包,要么自己编译内核。
自己编译内核时,在内存管理相关的配置菜单里把 Multi-Gen LRU 选上就可以。编译完安装新内核,重启后先做第二步检查。
第二步是看 sysfs 节点是否存在。MGLRU 运行时的控制节点一般在/sys/kernel/mm/lru_gen/:
ls /sys/kernel/mm/lru_gen/如果能看到enabled和min_ttl_ms,说明内核层面已经暴露了接口。如果目录不存在,多半是内核确实没编进去,或者你的内核版本太老。
第三步是确认 MGLRU 处于开启状态:
cat /sys/kernel/mm/lru_gen/enabled输出 1 表示开启,0 表示关闭。如果输出 0,手动开启:
echo 1 | sudo tee /sys/kernel/mm/lru_gen/enabled需要留意的是,有些内核版本在启动参数里指定了 lru_gen=off,sysfs 里写入可能无效。检查一下 /proc/cmdline:
grep lru_gen /proc/cmdline如果看到lru_gen=off,需要在 GRUB 配置里删掉或改成 on 再重启。
4.2 min_ttl_ms 与水位、swap 的协同调参
MGLRU 暴露给用户的核心参数并不多,最值得研究的是min_ttl_ms。这个参数表示“最老代中页面至少应该保留的时间”,单位是毫秒。设置它,可以防止刚进入最老代的页面立刻被回收;如果业务工作集有明显的周期抖动,这个参数非常关键。
我举一个实际例子。某个内存型缓存服务,热点 key 大约每 30 秒被完整刷新一轮,每次刷新会把大量新数据读入内存,同时把旧数据挤出工作集。传统 LRU 在这个场景下经常出现一种尴尬:新数据刚读进来,还没被二次访问,就已经因为内存压力掉进 Inactive 尾部被回收,然后下一次刷新又重复读盘。设置 min_ttl_ms 为 500 之后,相当于给这些“刚进老代但可能还有价值”的页面设置一个观察期,整体 refault 明显下降。
设置方法:
echo 500 | sudo tee /sys/kernel/mm/lru_gen/min_ttl_ms但要注意,这个值不是越大越好。设得太大,页面在内存压力下也不愿意离开,回收效率下降,内存水位可能一直偏高。对于大多数在线服务,我建议从 0 或默认值开始,测试一轮后逐步从 100ms、300ms、500ms 向上试探,同时观察 /proc/pressure/memory 的 stall 指标。哪一档 stall 显著下降,哪一档就作为候选。
水位相关的参数仍然要配合。比如vm.min_free_kbytes,它决定内核为了重要任务保留多少空闲内存,不能因为 MGLRU 回收效率高就把它降得很低。我习惯保留在物理内存的 0.5% 到 1% 之间。swappiness 这边,MGLRU 下我不建议直接调成 0,可以先用默认值 60 跑基线,如果匿名页回收导致的 swap 噪声太大,再往 10~30 之间调。
4.3 容器与 cgroup 视角下的配置实践
在容器场景里,MGLRU 的调参最好跟着 cgroup 走,而不是只调全局参数。比如你有一个在线服务容器,希望它的 page cache 尽量不被回收,可以这样设置:
echo 4G > /sys/fs/cgroup/online-service/memory.low这样即使整个节点内存吃紧,回收器在这个容器回收时也会先尝试从其他容器找内存,而不是直接把它保护的页面清掉。
反过来,如果某个离线批处理容器就是希望尽量用完内存再被回收,可以把它放进一个独立 cgroup,不给 memory.low 保护,让它的内存成为节点回收器首先考虑的对象。MGLRU 在这种“一个节点上同时有在线业务和离线任务”的混部场景里特别好用,因为它的回收粒度足够细。
如果需要在系统启动时就持久化这些配置,不要只写当前 shell 的命令。可以写一个小的 systemd unit 或者在/etc/sysctl.d/里放配置文件。sysfs 节点虽然不是 sysctl,但可以通过 systemd-tmpfiles 或 rc.local 在启动时写入。路径注意要用真实存在的绝对路径,配置前先做好文件存在性判断。
5. 性能验证方法与真实场景观察
5.1 建立基线:压测前中后的关键指标
开启 MGLRU 之后,第一件事不是看应用 P99,而是看内核内存回收指标的变化,这样才能判断回收行为本身是否变健康了。建议至少监控这几组数据:
vmstat 1:重点看 si(从 swap 读入)、so(写入 swap)、cs(上下文切换)。si/so 出现剧烈波动说明内存抖动还没有消除。sar -B 1:观察 pgpgin、pgpgout,文件页回收是否导致额外磁盘直接 IO。/proc/vmstat里的 pgscan、pgsteal:这两项直接反映回收扫描量,MGLRU 下它们应该明显下降。/proc/pressure/memory的 PSI 指标:avg10、avg60 的 some 和 full 数值,能直观反映应用被内存回收阻塞的时间占比。/proc/meminfo里的 SwapCached,以及应用自己的 latency 分布。
我的经验是,至少让压测任务跑 10 分钟以上,覆盖业务工作集的若干个完整周期。不要只看启动阶段的几分钟,那段时间回收器可能还没进入稳定的代际老化节奏。对比时保持机器环境一致,最好用 A/B 切换的方式做两到三轮,避免某一次压测的特殊性影响结论。
5.2 案例一:内存型缓存服务压测下的 P99 变化
某内存型缓存服务,单实例配置 64G 内存,业务以读取热点 key 为主,key 数量大约是 6 到 8 小时不断增长。压测中把内存水位压到接近上限,传统 LRU 下出现明显的问题:每秒 pgscan 一度冲到几十万次,si/so 交替上涨,P99 从 2ms 飙到 60ms 以上。
切换 MGLRU 内核后,同样压测规模下,内存水位还是接近上限,但页面回收明显更有“选择性”,最老代里的缓存页被释放,而频繁访问的热点 key 页面始终留在年轻代,几乎看不到 swap 活动。一组实测对比数据如下:
| 指标 | 传统 LRU | Multi-Gen LRU |
|---|---|---|
| pgscan(峰值,每秒) | 38 万 | 4.8 万 |
| pgsteal(峰值,每秒) | 35 万 | 4.5 万 |
| si/so(峰值,每秒) | 1800/2100 | 80/120 |
| P99 延迟 | 62ms | 3.8ms |
| 回收线程 CPU 占用 | 约 60% 单核 | 约 18% 单核 |
这个场景是 MGLRU 的主场:内存里有大量“长期存在但偶发访问”的缓存页,访问模型有明确热点,分代老化恰好能精准地把热点页保护住。
5.3 案例二:大规模编译场景的文件页缓存收益
另一个更偏文件 IO 的场景是持续集成编译。编译过程中会反复读取源码、头文件和对象文件,page cache 如果被反复清掉,每次编译都要重新读盘。传统 LRU 在多个编译任务并发运行时,文件页缓存很容易被大量一次性访问页挤掉,于是每轮编译都会多出明显的 IO 等待。
MGLRU 在这个场景里的表现也很直接:读过的文件页如果能在一段时间内再次被使用,就会留在较新的代里;大量只在编译前期读取一次的系统头文件页,则迅速老化到回收池。结果就是 page cache 的命中率提高,整个 CI 任务的编译时长下降。我做过一个约 40 分钟的中型项目编译测试,开启 MGLRU 后总耗时下降约 9%,磁盘读 IO 减少约 22%。
这类场景调参时,min_ttl_ms不需要设太大,因为文件页的复用周期短,设到 100~200ms 就够。重点是保证每个编译容器有自己的 cgroup 隔离,别让一个任务的文件缓存影响另一个任务的内存水位判断。
5.4 案例三:容器混部场景下的邻居隔离
最后说一个容器混部的典型案例。某个节点上同时跑着一个在线缓存容器和一个数据处理容器。数据处理容器会大量读取远端文件,把 page cache 吃得很猛。传统 LRU 下,在线缓存容器的热页时不时被邻居的文件缓存冲击,P99 出现周期性尖峰。
开启 MGLRU 并把在线容器放入一个有 memory.low 保护的 cgroup 后,情况明显好转。数据处理容器在自己的 memcg 里回收自己的文件页,在线容器的热页根本不会被回收器光顾。这种收益不是通过单纯调 swappiness 能得到的——传统全局 LRU 无法精确到“某一个容器内部优先回收”的粒度。
如果你在容器平台工作,我建议优先把 MGLRU 试到混部节点上。只要能确认每个容器都正确配置了 cgroup v2,并且业务负载不需要跨容器共享 page cache,收益通常非常明显。
6. 常见问题、排障与避坑清单
6.1 开启后没效果?对四个检查点逐项排查
遇到过不少同事反馈“内核开了 MGLRU,怎么监控没有任何变化”。这种情况,先别怀疑算法,从下面四件事查起。
第一,内核配置是否真的包含 MGLRU,并且内核对 lru_gen 的处理模式是否自洽。有的发行版把CONFIG_LRU_GEN编成了模块,但模块没有自动加载。你可以在终端里看/sys/kernel/mm/lru_gen/enabled是否存在,文件都没有说啥都白搭。
第二,检查启动参数。如果你的内核命令行里明确写了lru_gen=off,sysfs 开启操作可能被拒绝或者重启后失效。排查时不要只看当前系统里有没有这个参数,还要看 GRUB 配置和永久生效的脚本。
第三,确认你观察的回收行为确实走的是普通 LRU 路径。比如 tmpfs、共享内存页的回收是由 shrinkers 单独处理的,MGLRU 并不直接覆盖这些对象。如果你的业务大量使用 tmpfs,却发现回收行为没有变化,这不是 MGLRU 失效,而是它本身不管这块。排障时要分清楚对象。
第四,指标采集角度不对。很多人只看 free 输出里的 available 内存,但 available 本身受 page cache 影响很大。要看回收行为是否变化,建议直接看/proc/pressure/memory和 pgscan/pgsteal,这些指标才对得上回收器的工作量。
6.2 出现 refault 风暴或持续 swap 该怎么处理
MGLRU 不是灵丹妙药,它只是让回收更聪明。如果 swap 还在持续增加,首先要判断是工作集本身就超过了物理内存,还是回收策略选择不对。
如果是前者,无论什么回收算法都救不了你,你要做的是扩容、压缩数据、或者把冷数据移到远端存储,而不是继续在回收器上做文章。可以从 PSI 的 full 指标看:如果 full 长期在 50% 以上,说明应用真的在持续等内存,这种情况基本可以断定是内存总量不够。
如果是回收层面的错误选择,通常表现为 swap 和 file IO 同时飙升,refault 周期反复出现,而系统在大部分时间段还有一定空闲。此时我建议先调大min_ttl_ms,等于给老代页面一个更长的观察窗口;然后检查 swappiness,如果匿名页写盘太频繁而文件页缓存又有压力,把 swappiness 从 60 降到一个不太极端的值如 30,然后观察曲线。逐步调整,一次只动一个参数。切忌上来就 swappiness=0,那会让文件页变成回收重灾区,IO 延迟照样很难看。
6.3 透明大页与特殊内存区域的干扰
开启透明大页(THP)的系统,MGLRU 的回收单位是 folio。如果一个 folio 对应一个 2MB 大页,回收器在判断它是否真正被访问时会用整个 folio 的标记,而不是逐 4KB 页判断。好处是吞吐高,坏处是如果一个大页里某个 4KB 子页确实是热点,而其他子页很冷,MGLRU 可能会把整个 folio 都当成热页或者冷页,出现粒度过粗的情况。
在极端的可拆分场景里,内核会先尝试把 folio 拆分成普通页再做细粒度回收,拆分操作本身有开销,所以你会看到 THP 场景下 MGLRU 带来的收益没有非 THP 环境那么纯粹。如果你在用数据库等对大页友好的应用,建议压测时分别做开 THP 和关 THP 两组测试,看清楚 MGLRU 在哪种配置下收益最大。
另外,专门给 DMA、显存等用途保留的内存区域通常不走普通 LRU。比如显存映射、某些驱动预留的 CMA 区域,它们的回收有独立路径。如果你发现显存相关的进程持续报错或卡顿,别把原因甩给 MGLRU,先确认是否涉及这些特殊区域。
6.4 FAQ 速查表
| 现象 | 可能原因 | 建议操作 |
|---|---|---|
| 开启 MGLRU 后 PSI 反而升高 | 页表扫描在短生命周期进程多的场景产生额外开销 | 确认内核版本,尝试关闭页表扫描相关开关;或换长生命周期服务测试 |
| 内存压力下 swap 仍然暴涨 | 工作集超过物理内存;匿名页回收预算不够 | 先扩容或降载;再小步调整 swappiness 和 min_ttl_ms |
| file cache 被挤占严重 | 文件页回收配额没有跟上业务变化 | 调低 swappiness 到 30 附近;给在线文件缓存 cgroup 设置 memory.low |
| 回收线程 CPU 占用高但回收量低 | 页面大量处于不可回收状态或频繁拆分 | 检查 mlock、THP 拆分、特殊内存区域;考虑对大页做针对性调优 |
| 多个容器互相干扰严重 | cgroup 没有正确设置 memory.low/high | 为在线容器设置 memory.low;离线容器放入独立 memcg |
| sysfs 写入 enabled 被拒绝 | 内核启动参数覆盖了运行期开关 | 检查 /proc/cmdline,修正 GRUB 配置后重启 |
这块的排障经验,我个人的体会是:先观察,再调参,最后改启动参数。不要只盯着 P99 一个指标做判断,回收行为是否健康,应该综合看 CPU 的回收开销、IO 压力、PSI stall、swap 活动这四组数据。MGLRU 的优势本质上是一个时间窗口管理优势,只有当你的业务访问模式足够丰富、内存生命周期差异足够大的时候,它的优势才会充分暴露。反过来,如果你的工作集非常稳定,装个传统 LRU 也一样跑得挺好。
再说一个细节点:如果你用了很多监控工具读取/proc/pressure/*,记得确认这些工具在 MGLRU 内核下没有依赖旧版本的workingset_refault统计逻辑。某些老版本的监控脚本会把 refault 数据读错,导致你误判 MGLRU 失效。遇到数据异常时,用最原始的内核计数器交叉验证一下,往往能省很多时间。
这套内容写到这里,我已经把 MGLRU 从动机、原理、实现到调参、排障都过了一遍。最后留一句实际的建议:不要在没有监控和压测的情况下直接给生产环境切内核,先在隔离节点上跑足一个业务完整周期,对比一下 pgscan、PSI 和应用延迟这三样东西,再决定是否全量推进。毕竟回收器是内核里跟硬件页表、磁盘 IO、进程调度都牵扯很深的模块,任何一个新特性的收益和代价,都要用数据说话。