摘要
生产环境中最令人困惑的一类故障,是监控面板上 free -g 仍显示大量可用内存,进程却被内核的 OOM Killer 击杀。本文以"可用内存"与"可回收内存"的语义分离为分析主线,把诱因收敛为三条可验证路径:Page Cache 在特定回收上下文下不可回收、Slab(尤其 dentry/inode 与不可回收 slab)持续膨胀、以及 cgroup v1/v2 内存控制器在层级限额下先于全局内存耗尽触发局部 OOM。文章给出从 /proc/meminfo、/proc/slabinfo、memory.stat 到内核 tracepoint 的完整判定链路,并讨论误判边界与治理策略。
目录
一、问题定义:为什么"内存够用"是一个伪命题
1.1 可用内存的三种语义
讨论 OOM 之前必须先统一口径。Linux 上至少存在三种被口语化为"可用内存"的量:MemFree(完全未分配的物理页)、MemAvailable(内核估算的、在不触发交换的前提下可供新分配使用的内存,包含大部分可回收的 page cache 与可回收 slab)、以及特定 cgroup 的 memory.current 与 memory.max 之差。运维面板常展示 MemAvailable,而 OOM Killer 的触发条件是"在给定内存域(zone)与给定 cgroup 层级上,经过直接回收仍无法满足分配请求"。两者口径不同,于是出现"面板够用、内核却杀进程"的观感冲突。
本文评述:把 MemAvailable 当作 OOM 的预测指标是一种系统性误用。MemAvailable 是启发式估算,其计算依赖内核版本与 zone 的可回收性判断,并不承诺"任何分配都能被满足"。更严谨的做法是把"内存是否够用"拆成两个独立问题:全局物理内存是否耗尽,以及分配上下文所属的 cgroup 是否触顶。二者对应完全不同的排查工具链。
1.2 OOM Killer 的真实触发点
内核在 __alloc_pages_slowpath 路径中,先尝试轻量回收与直接回收(direct reclaim),若仍失败则进入 out_of_memory()。在 cgroup 场景下,若分配受 memcg 约束且该层级已达 memory.max,则走 memcg OOM 路径,选择并击杀该 cgroup 内 oom_score 最高的任务。关键点在于:触发条件是"分配失败",而不是"内存使用率高"。一个长期维持 95% 内存占用但回收顺畅的系统可以稳定运行;一个占用仅 60% 却因碎片、不可回收页或 memcg 限额而分配失败的系统会立刻 OOM。
笔者认为,理解这一点能直接排除大量误诊:OOM 日志中的 total-vm、rss 数值只描述被杀进程的地址空间,不描述系统状态;真正有价值的字段是 Node X Normal free、slab_reclaimable、slab_unreclaimable,以及是否出现 oom_memcg 字样。
1.3 本文的分析主线
全文围绕一条主线展开:"可回收性"是连接内存充足表象与 OOM 事实的唯一桥梁。Page Cache 的问题在于"看起来可回收、实际在特定上下文回收不动";Slab 的问题在于"部分可回收、部分永久驻留";cgroup 的问题在于"全局可回收、局部不可用"。三条路径共享同一套判定逻辑——先确认内存域,再确认回收能力,最后确认限额层级。后续章节按此顺序展开。
二、三条主因的机制拆解:Page Cache、Slab 与 cgroup
2.1 Page Cache:可回收的前提条件
Page Cache 是文件页在内存中的缓存,由 address_space 的 radix tree / xarray 组织。它被计入 Cached,在多数情况下可被 kswapd 或直接回收释放。但"可回收"有严格前提:页未被锁定(PG_locked)、未处于写回中(PG_writeback)、不属于被 mlock 的映射、且所在 address_space 允许释放。以下场景会让 Page Cache 名义上存在、实际上回收失败:
- 大量脏页堆积,回写带宽成为瓶颈,回收线程在等待 IO 时分配方已超时;
- 文件被
mmap(MAP_LOCKED)或mlock()锁定; - 内存碎片导致即使释放了页也无法拼出高阶连续页,分配仍失败;
- cgroup 内 page cache 受
memory.high节流,回收速度被主动限制。
本文评述:把 Page Cache 视为"随时可让出的缓冲"是传统运维经验的惯性,但在写回密集与容器化场景下,这一假设频繁失效。判断 Page Cache 是否为元凶,不能只看 Cached 大小,而要看 Dirty、Writeback 与 pgscan_direct 的增速,三者共同刻画"回收是否跟得上分配"。
2.2 Slab:被低估的驻留者
Slab 分配器为内核对象提供缓存,分为可回收(SReclaimable,如 dentry、inode cache)与不可回收(SUnreclaim,如 task_struct、socket buffer 相关结构)两部分。dentry/inode 在内存压力下可由 shrinker 回收,但回收速率受限于引用计数与并发访问;而 kmalloc 系列与网络栈缓存一旦泄漏或持续增长,会直接消耗不可回收内存。
Slab 导致 OOM 的典型模式是"缓慢泄漏 + 突发分配"。缓慢泄漏使 SUnreclaim 单调上升,系统长期处于高水位;突发分配在某个时刻无法通过回收腾出足够空间,直接触发 OOM。由于 Slab 不计入进程 RSS,常规进程级监控完全看不到它。笔者认为,Slab 类故障的排查价值最高,因为它的证据链最清晰:/proc/slabinfo 中按 object 数排序即可定位异常增长项,配合 slabtop 与内核 tracepoint 可进一步归因到具体子系统。
2.3 cgroup:局部限额先于全局耗尽
cgroup v2 的 memory 控制器以 memory.current、memory.max、memory.high 三个核心接口管理内存。当某 cgroup 的 current 达到 max 且回收失败,内核在该层级内触发 memcg OOM,击杀该 cgroup 内任务,而不影响其他 cgroup。这解释了大量"宿主机内存充足、容器内进程被杀"的现象。
更隐蔽的是层级效应:子 cgroup 的限额之和可能超过父 cgroup 限额,父级先触顶时,子级任务同样会被击杀,但日志归属容易误读。memory.high 则提供软限流,超过后任务被节流并强制回收,若回收不及仍可能升级为 OOM。本文评述:cgroup 类故障的判定核心不是"用了多少内存",而是"在哪个层级触顶、该层级的回收是否被 high 节流、以及 memory.stat 中哪个字段在增长"。只看 memory.current 会漏掉 anon 与 file 的结构差异,而 anon 占比过高意味着回收空间本就有限。
2.4 三者的耦合关系
三条路径并非互斥。典型复合故障是:容器内应用大量读写文件,page cache 在 memcg 内累积并逼近 memory.high,触发节流与回收;回收压力使 dentry/inode slab 增长;最终在某次突发分配中 memcg 触顶触发 OOM,而宿主机 free 仍显示充裕。识别这类故障要求排查顺序固定:先看 OOM 日志归属(全局还是 memcg),再看该域内的结构分布,最后看回收速率。顺序颠倒会导致在错误层级上反复取证。
三、判定路径:从指标到证据的分层排查法
3.1 第一步:读 OOM 日志,确定内存域
内核 OOM 日志(dmesg 或 journal)是唯一权威的现场记录。判定要点:出现 oom_memcg=/xxx 表示 memcg 路径;出现 Node 0 Normal free:...min:... 表示全局 zone 路径;slab_reclaimable 与 slab_unreclaimable 的比值直接提示 Slab 嫌疑度。若日志中 free 远高于 min 水位却仍 OOM,则高度指向碎片、不可回收页或 memcg 限额。
3.2 第二步:区分 Page Cache 与 Slab
在全局路径下,用 /proc/meminfo 与 /proc/vmstat 交叉验证。若 Dirty 与 Writeback 高企、nr_dirty_threshold 被突破,指向 Page Cache 回写瓶颈;若 SUnreclaim 持续增长且 slabtop 显示某缓存对象数异常,指向 Slab。两者可同时存在,此时按增长斜率排序,斜率更陡者为主因。
# 全局结构快照
grep -E 'MemFree|MemAvailable|Cached|Dirty|Writeback|SReclaimable|SUnreclaim' /proc/meminfo
# Slab 对象排序(按 object 数)
slabtop -o -s c | head -20
# 回收速率
grep -E 'pgscan_direct|pgsteal_direct|allocstall' /proc/vmstat
3.3 第三步:确认 cgroup 层级与结构
若日志指向 memcg,进入对应 cgroup 目录读取 memory.current、memory.max、memory.high、memory.stat。重点看 anon、file、slab、pgscan、pgsteal 与 oom 计数。若 anon 占比高,说明可回收空间小;若 file 高但 pgsteal 低,说明回收被节流或受阻。同时沿父目录逐级检查限额,排除父级先触顶。
# cgroup v2 现场取证
cat /sys/fs/cgroup/<path>/memory.current
cat /sys/fs/cgroup/<path>/memory.max
cat /sys/fs/cgroup/<path>/memory.high
grep -E '^(anon|file|slab|pgscan|pgsteal|oom)' /sys/fs/cgroup/<path>/memory.stat
# 沿层级向上确认限额
systemd-cgtop -m
3.4 第四步:用 tracepoint 固化证据
静态指标只能提供嫌疑,动态 trace 才能定性。内核提供 vmscan:mm_vmscan_direct_reclaim_begin/end、kmem:kmem_cache_alloc、oom:oom_score_adj_update 等 tracepoint,配合 perf 或 bpftrace 可观测回收耗时与分配热点。本文评述:在生产环境长期开启全量 trace 成本过高,建议采用"短窗口采样 + 故障复现"策略,仅在疑似时段开启 30–60 秒,避免观测本身引入抖动。
3.5 判定矩阵
| 证据特征 | Page Cache 主因 | Slab 主因 | cgroup 主因 |
|---|---|---|---|
| OOM 日志归属 | 全局 zone | 全局 zone | oom_memcg 出现 |
| 关键指标 | Dirty/Writeback 高 | SUnreclaim 单调增 | current≈max,anon 占比高 |
| 回收行为 | pgscan 高但 pgsteal 低 | shrinker 调用频繁但释放少 | memory.high 节流明显 |
| 宿主机 free | 低 | 低 | 可能很高 |
四、案例与数据:三类故障的现场还原
4.1 案例一:写回瓶颈下的 Page Cache 假象
某日志聚合节点在夜间批量写入时 OOM。宿主机 64 GiB 内存,free -g 显示 available 约 12 GiB。dmesg 显示全局 zone 触发,slab_reclaimable 正常,但 Dirty 达 3.8 GiB、Writeback 持续 2 GiB 以上。根因是底层存储 IOPS 不足,脏页回写跟不上写入速率,直接回收在等待 IO 时无法及时释放页。本文评述:该案例中 available 的估算包含了大量脏页,而脏页在回写完成前不可释放,正是"估算口径"与"实际可回收性"脱节的典型。治理手段是降低 vm.dirty_ratio 与 vm.dirty_background_ratio,并提升存储带宽。
4.2 案例二:dentry 缓存膨胀
某构建集群在遍历海量小文件后 OOM。slabtop 显示 dentry 对象数达千万级,SReclaimable 占 Slab 总量九成以上。理论上 dentry 可回收,但 shrinker 在并发遍历下回收效率低,且部分 dentry 被引用计数钉住。最终在某次大文件拷贝的突发分配中触发 OOM。笔者认为,这类故障的教训是"可回收"不等于"能及时回收":回收速率与分配速率不匹配时,可回收页同样会演变为事实上的不可用。缓解方式包括控制并发遍历、调整 vfs_cache_pressure,以及在容器内设置合理的内存上限。
4.3 案例三:父 cgroup 限额先触顶
某 Kubernetes 节点上多个 Pod 同时被 OOM,但节点 MemAvailable 仍有 20 GiB。排查发现所有 Pod 位于同一父 cgroup(如 system.slice 或 kubelet 管理的 runtime slice),父级 memory.max 被设置为节点内存的 80%,子级限额之和远超父级。父级触顶后,内核在父级内选择 oom_score 最高者击杀,日志中的 oom_memcg 指向父级路径。本文评述:容器编排系统普遍存在"子限额之和大于父限额"的配置,这是设计上的超卖,但故障时极易误判为单个 Pod 内存泄漏。排查必须沿 cgroup 树自底向上核对每一级 max 与 current 的关系。
4.4 数据说明
上述案例中的数值为笔者在测试环境中复现与整合所得(模拟数据),用于说明指标形态与判定逻辑,不代表特定生产系统。内核行为描述基于 Linux 5.15 与 6.1 主线代码路径,读者应以所用发行版内核版本为准。相关内核文档与回收机制说明可参考 内核内存管理文档 与 cgroup v2 文档。
五、对比、局限与前沿演进
5.1 三类主因的治理成本对比
Page Cache 类问题通常通过参数调优与 IO 扩容解决,成本低但需持续观察;Slab 类问题往往涉及内核对象生命周期,定位难、修复周期长,部分场景需升级内核或打补丁;cgroup 类问题多为配置与编排策略问题,修复快但容易复发。笔者认为,治理优先级应遵循"先配置、后参数、再内核"的顺序,避免在配置错误的前提下做深度内核调优。
5.2 方法局限
本文给出的判定路径依赖 OOM 日志与运行时指标,存在两类局限:其一,容器环境中日志可能被裁剪或丢失,需提前配置内核日志持久化;其二,memory.stat 的字段语义随内核版本变化,跨版本比对需谨慎。此外,内存碎片导致的分配失败难以用单一指标刻画,需结合 /proc/buddyinfo 与 /proc/pagetypeinfo 综合判断。
5.3 前沿演进
近年内核社区在内存回收与观测上的演进值得关注:MGLRU(多代 LRU)在 6.1 后逐步默认启用,改善了 page cache 回收的公平性与延迟;PSI(Pressure Stall Information)提供了比利用率更贴近"分配受阻"的指标,已成为容器内存压力的重要信号;cgroup v2 的 memory.peak、memory.events 让限额触顶更易观测。本文评述:这些机制并未改变 OOM 的触发本质,但显著降低了"证据获取"的成本,工程上应优先接入 PSI 与 memory.events,而非继续依赖 MemAvailable 做告警。延伸阅读可参考 PSI 文档 与 MGLRU 相关讨论。
六、结论与治理展望
"内存够用却 OOM"的本质,是把"内存占用"误当作"内存可用性"。本文以可回收性为主线,将诱因收敛为 Page Cache 回收受阻、Slab 驻留膨胀与 cgroup 局部限额三条路径,并给出"读日志定域—看结构定因—沿层级核对—用 trace 固化"的四步判定法。工程实践中,建议把 PSI、memory.events 与 Slab 对象数纳入常规监控,替代单一的可用内存告警。
展望来看,随着 MGLRU 普及与 cgroup v2 成为默认,回收行为的可观测性将持续改善,但超卖配置与碎片问题不会自动消失。笔者认为,未来的治理重心会从"事后定位"转向"事前容量建模":在编排层显式约束父子限额关系,在应用层控制脏页与元数据缓存的增长速率,才是降低此类故障复发的根本路径。
主要参考文献
- Linux Kernel Documentation. Memory Management Guide. kernel.org, 2024.
- Linux Kernel Documentation. Control Group v2. kernel.org, 2024.
- Linux Kernel Documentation. Pressure Stall Information. kernel.org, 2023.
- LWN.net. Multi-generational LRU 系列讨论. 2022–2023.
- Brendan Gregg. Systems Performance, 2nd Edition. Pearson, 2020.
- Linux 内核源码 mm/vmscan.c、mm/oom_kill.c、mm/memcontrol.c(6.1 主线)。
- Red Hat. Memory Management Guide, RHEL 9 文档, 2023.
- Google. cgroup v2 与容器内存治理实践白皮书, 2023.
内容仅供学习参考。如需引用,请以原始文献为准。全文约 6800 字 | 参考文献 62 篇(主要 8 篇)。
声明:本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。

