
OOM 的完整触发链:不是「内存满了」这么简单
进程申请内存(缺页时才真正分配物理页,第 2 篇讲过),内核发现空闲页不足时不是立即 OOM,而是走一条逐级加码的回收链:
- 直接回收(direct reclaim):触发缺页的进程自己动手,按「页缓存 → 回写脏页 → 收缩 slab → 逐出匿名页到 swap」的顺序释放内存。这一步就开始伤性能——每次分配都可能附带一次同步回收。
- kswapd 后台回收:水位线(watermark)机制让后台线程提前回收。
min、low、high三条水位线控制节奏:低于low唤醒 kswapd,低于min则所有分配进入同步直接回收——这就是「内存告急」的性能悬崖。 - OOM Killer:回收后仍无法满足分配(且无 swap 可逐出),内核启动 OOM 流程选择牺牲者。
flowchart TB
A[进程缺页申请物理页] --> B{空闲页 < low 水位?}
B -->|否| Z[正常分配]
B -->|是| C[kswapd 异步回收]
C --> D{回收后充足?}
D -->|是| Z
D -->|否| E[直接回收<br>同步、伤延迟]
E --> F{能满足?}
F -->|是| Z
F -->|否| G{有 swap / 可回收页?}
G -->|是| E
G -->|否| H[OOM Killer<br>按 badness 评分选牺牲者]
H --> I[发送 SIGKILL<br>释放其全部内存]
很多「内存不足」事故的真实形态不是 OOM 杀进程,而是第 1、2 步的性能塌方:机器内存紧张但还没到 OOM,所有进程在直接回收里空转,CPU 被 kswapd 吃满,延迟暴涨——监控上表现为「CPU 高但没进程在干活」。这是判断内存问题的第一个直觉:先看 psipcst/pgscan 指标,再怀疑 OOM。
badness 评分:内核怎么选牺牲者
OOM Killer 的选择算法(oom_badness())出奇地简单粗暴:评分 = 进程的 RSS + swap + 页表占用,再乘以 oom_score_adj 的调节系数。选分最高的杀。没有进程年龄、没有重要性元数据、没有业务语义——内核不知道你的数据库比日志进程重要,它只看谁占的内存多。
结论显而易见:占内存最多的进程最容易被杀——而那往往正是你最核心的服务。这就是「OOM 杀错人」的结构性原因:元凶是内存泄漏的旁路进程时还好;元凶是缓存膨胀(Page Cache 不可回收的脏页堆积)时,评分最高的反而是无辜的主业务。
干预接口是 oom_score_adj,范围 -1000 到 1000:
-1000:永不杀(内核线程与 sshd 通常被设为 -1000,防止 OOM 时连管理通道都丢了)。0:默认。- 正值:提高被杀概率。旁路进程(监控 agent 之外的可有可无者)可以设 500+。
# 保护主服务:永不选中
echo -1000 > /proc/PID/oom_score_adj
# 牺牲品标记:可观测的辅助进程优先被杀
echo 500 > /proc/<辅助进程pid>/oom_score_adj
# 查看 OOM 历史:内核日志里的完整评分表
dmesg | grep -A 30 "Out of memory"
OOM 事件的日志值得逐行读:它会打印当时的内存全局状态、每个候选进程的评分表、以及最终选择。事后复盘「为什么杀的是它」全靠这份现场。
cgroup v2:容器时代的 OOM 语义
物理机内存耗尽是全局事件,容器化之后 OOM 变成了逐 cgroup 的局部事件:每个容器有自己的 memory.max,超限时内核只在该 cgroup 内做回收与 OOM——容器 A 内存泄漏不会触发整机 OOM,只会杀容器 A 自己。这就是 dmesg 里 Memory cgroup out of memory: Killed process 与全局 OOM 的区别,排查时先确认是哪种。
cgroup v2 提供两个关键开关:
memory.oom.group:设为 1 后,cgroup 内任何进程触发 OOM 时整个 cgroup 的所有进程一起被杀。Kubernetes 场景很实用——Pod 的多个进程是一个整体,杀一半留一半(如留下孤儿 sidecar)反而制造诡异故障。memory.max+memory.high:high是软限制,超过即节流回收;max是硬限制,超过走 OOM。把high设在max下方 10%–20% 处,让回收提前介入,避免撞墙式 OOM。
Kubernetes 的 OOMKilled 事件对应的正是 cgroup 层 OOM:Pod 超过 limits.memory → cgroup OOM → 容器内评分最高的进程被杀 → 按重启策略拉起。「Pod 反复重启但 dmesg 没有全局 OOM」几乎是内存 limit 配低的固定剧本。
三道防线:水位、告警、优雅降级
第一道:容量水位管理。给每台机器/每个节点定义内存水位红线(如 used 超过 85% 告警、92% 触发处置),把内存大户的 oom_score_adj 提前配好。Page Cache 占用大不是问题(可回收),匿名内存(不可回收、只能 swap 或杀)的占比才是真正的风险指标。
第二道:提前于 OOM 的监控。vmstat 的 si/so(swap 进出速率)非零就是警报;/proc/vmstat 的 pgscan_kswapd、allocstall(直接回收次数)上升说明已经在性能悬崖边缘;memory.events 里的 oom_kill 计数器是容器环境的直接证据。这些指标全部先于「进程消失」出现。
第三道:应用层优雅降级。真正的防线在应用自己:内存池上限、请求级内存预算(超限拒绝而非堆积)、大对象流式处理。内核的 OOM Killer 是最后仲裁者,不是容量规划工具——依赖 OOM Killer 兜底的系统,等于把容量管理外包给了一个只看 RSS 的启发式算法。
与本系列其他章节的勾连
OOM 评分按 RSS 计算,而 RSS 有共享页的统计陷阱(第 17 篇:mmap 共享页被多进程重复计入 RSS,但物理上只有一份)——所以评分会「高估」共享内存大户,这是 mmap 共享架构在 OOM 场景的一个隐性优势。cgroup 的 memory.max 底层就是第 12 篇的 cgroup 机制;swap 与 Page Cache 的回收优先级在第 4 篇(fsync)与第 21 篇(writeback)之间形成闭环:匿名页贵(要 swap)、文件页便宜(直接丢),是理解所有内存回收问题的第一性原理。
OOM 现场重建:从日志到根因的复盘方法
OOM 事后复盘的标准流程分四步。第一步定性:区分全局 OOM(dmesg 搜 Out of memory: Killed process)与 cgroup OOM(搜 Memory cgroup out of memory,或 Kubernetes 事件的 OOMKilled)——两者嫌疑范围完全不同,前者查整机内存大户,后者只查该容器的 limit 与用量曲线。第二步拿现场:全局 OOM 日志里有完整的候选进程评分表(每个进程的 pid、rss、oom_score_adj、总分),先确认被杀者是否本来就该是牺牲品(adj 配置缺失是最常见的管理失误);容器场景拿监控的内存用量曲线,确认是缓慢爬升(泄漏)还是瞬时尖峰(大对象/并发突刺)。第三步找根因:泄漏用 pmap 比对不同时点的内存分布、heaptrack/JFR 拍堆快照;尖峰看触发点(大查询、大文件读入、fork 的瞬时页表拷贝)。第四步闭环:修复之外,把 oom_score_adj 的保护与牺牲配置写进部署清单、limit 按实测 P99 用量上浮百分之三十、水位告警前移到 high 水位。最容易被忽略的是第二步的半途而废:Kubernetes 里 Pod 重启把现场清掉,如果不提前配置好监控快照与事件留存,OOM 复盘只剩猜——所以「重启前先取证」要成为值班肌肉记忆。
容器内存 limit 的容量规划方法
cgroup 时代的内存容量规划有一套独立于物理机的方法论,核心是把 limit 当成独立于物理内存的「虚拟容量」来运营。第一步:定基线。用至少一个完整业务周期(覆盖日峰值与周峰值)的容器内存曲线,取 P99 作为基线——注意取 cgroup 的 memory.current(含 Page Cache)还是 anon 内存(不含可回收缓存)要按业务口径定,缓存型负载两者差异巨大。第二步:定余量。limit = 基线 × 1.3 是通用起点,但两个因素可以修正:内存使用率的增长斜率(缓慢爬升型要加大余量并优先排查泄漏)与 GC 行为(JVM 把 limit 传给堆参数(MaxRAMPercentage)让 GC 感知边界,避免堆外挤兑)。第三步:定梯队。memory.high 设在 limit 的 85%——超过即触发节流回收(分配变慢、GC 加频)而不是直接 OOM,给监控告警留出介入窗口;Kubernetes 里对应 memory.high 字段(v1.28+ 稳定)。第四步:定预案。OOM 后的自动动作要有剧本:事件告警、快照取证(memory.peak、当时的 top 消费者)、按策略重启或摘流量。两个高频误区值得点名:其一,limit 里的 Page Cache 陷阱——大量读写文件的容器,memory.current 含缓存看着逼近 limit 触发回收,其实匿名内存很健康,监控必须拆开 anon 与 cache(memory.stat);其二,「limit 设大点保险」的反面——limit 远超节点可分配量时,调度器超卖节点,整机 OOM 时 kill 的评分逻辑反而更不可控。limit 是租约不是保险箱,规划方法比数值本身重要。
OOM 与调度器的联动:内存压力对调度的影响
内存紧张对系统的影响不止于 OOM 杀进程——在到达 OOM 之前,内存压力已经通过多条路径污染调度与延迟,这条隐蔽链路值得单独拆解。链路一:直接回收发生在分配路径上(第 20 篇正文的触发链),意味着持锁的线程可能在锁内被拉去回收内存——其他等锁的线程全部陪绑,回收的几十毫秒变成锁的等待时间放大器,这是「内存压力表现为锁超时」的机制。链路二:kswapd 回收 Page Cache 把应用的文件映射页(代码段、mmap 数据)逐出,之后每次访问都缺页——缺页中断本身微秒级,但密集缺页让 CPU 时间片在缺页处理里碎掉,调度延迟与指令吞吐双降。链路三:swap 开启时,匿名页换出的恢复成本(major fault 要从盘读回)把单次延迟抬到毫秒级——「偶发 50ms 毛刺 + swapused 非零」是标准组合。三条链路的共同观测锚点是 /proc/vmstat 的 pgscan、pgsteal、allocstall 计数与 psr 的 major fault——内存监控除了水位线,这几个「压力活动」指标更能预示延迟问题。工程结论:内存容量规划的目标不是「不 OOM」而是「不进入直接回收」——水位告警线应该设在 allocstall 开始增长的位置(通常 used 80% 附近,视 Page Cache 占比浮动),那才是延迟劣化的真实起点。
小结
OOM 不是「内存满了就杀」,而是「逐级回收失败后按 badness 评分处决」——评分只看 RSS+swap,天然偏向杀内存大户,也就是核心服务。oom_score_adj 是唯一的进程级干预接口,-1000 保命、正值标记牺牲品。容器时代 OOM 按 cgroup 隔离,memory.oom.group 与 memory.high 提供了成组处决与提前节流两个新工具。三道防线的次序是:水位管理 → 先于 OOM 的监控 → 应用层优雅降级——把 OOM Killer 当成容量规划工具的系统,迟早为这个误会付出代价。
读者留言
COMMENTS 暂无还没有留言,来说第一句?