结论先行:宿主机 OOM 时内核不是按「谁更关键」挑受害者,而是按 oom_score 打分挑「RSS 占系统内存比例最高」的进程。业务上最不能死的 Redis(600MB)得分最低、反而最安全;业务上最该被杀、也最好重启的 Java(4.2GB)得分最高、必死。这套机制本身没毛病,毛病在于我们从来没给 oom_score_adj 做过治理——出事后才想起「为什么杀的是它」,就已经晚了。下面是一次真实宿主机 OOM 的复盘 + 一套可复现的选择机制讲解 + 保护/放羊清单。
一、现象:OOM 杀对了进程,业务却死了
背景:一台非容器化业务节点(32C / 128G RAM,内核 5.10.134,无 swap,vm.overcommit_memory=0),同机跑三类进程:
java -Xmx4g(订单服务,RSS 峰值 4.2GB,堆外 + 元空间 + 直接内存);redis-server(600MB,本节点唯一缓存,挂了 = 全部请求穿透到 MySQL);- 一批 sidecar(监控 agent、日志采集器,合计 ~800MB)。
凌晨 03:26 告警:订单服务进程消失(exit 137)。dmesg -T 里的 oom 现场:
Out of memory: Killed process 21834 (java) total-vm:9876544kB, anon-rss:4302848kB, file-rss:1024kB
[ 1845.234] java 21834 0 root 4302848 ... 35 ...
[ 1845.234] redis-server 30211 0 redis 614424 ... 5 ...
内核杀的是 java(score 35),放过 redis(score 5)——单看内核决策,完全正确。但业务结果反过来:
- java 重启需要 90s 预热,期间请求打向 redis 的旁路逻辑;
- redis 存活但缓存命中率从 99.2% 掉到 81%(热 key 集中在刚重启的 java 实例上,本地 cache 全冷);
- MySQL 主库 QPS 从 8.2k 冲到 95k,4 分钟内触发连接池打满(max=2000),核心接口 P99 从 45ms 飙到 6.8s,持续 22 分钟才回稳。
复盘结论一句话:我们防的是「杀错进程」,但真正打穿业务的是「杀对进程 + 无预热 + 缓存冷启动」三件套叠加。
二、原理:oom_score 到底怎么算的(三句话版)
- 打分公式:
oom_score ≈ (该进程 RSS / 系统总内存) × 1000,再叠加oom_score_adj(范围 -1000 ~ +1000)修正。内核选得分最高且可杀的进程,与进程名、优先级、业务重要性完全无关。 - 分母是整机、不是 cgroup:所以一台 128G 机器上 4.2G 的 java 只得 35 分——它离「必死」其实很远,这次是因为 sidecar + page cache 把整机水位顶到了
free≈200MB才触发的。宿主机 OOM 本质是整机内存预算失控,不是某个进程「太胖」。 oom_score_adj是唯一的「投名状」旋钮:设 -1000 表示「本进程永不作为受害者」(内核会跳过它),设 +500 表示「优先杀我」。它是 per-process 的,通过/proc/<pid>/oom_score_adj或 systemd 的OOMScoreAdjust=持久化。
顺带区分一个常见混淆(站内 id=714 那篇讲得很透):vm.overcommit_memory=2 拒绝的是内存承诺(commit),报 ENOMEM 但不会触发 OOM killer;本文场景是物理内存耗尽,必然走 OOM killer,两者现场完全不同——前者 dmesg 干净,后者有 Out of memory: Killed process 全文。
三、具体做法:定位 → 保护 → 放羊 → 预警,四步可复现
1) 事后定位(5 分钟版)
# 找最近一次 oom
dmesg -T | grep -n -A 30 "Out of memory" | head -60
# 看每个进程的打分(重点看 oom_score 列,第 9 列附近)
ps -eo pid,rss,comm --sort=-rss | head
# 看内核当前配置
cat /proc/sys/vm/overcommit_memory /proc/sys/vm/overcommit_ratio
cat /proc/sys/vm/panic_on_oom
# 看内核版本是否支持 oom group kill(5.4+)
uname -r
2) 保护关键进程(systemd 持久化,重启不丢)
给 redis 这类「杀了比杀别人更贵」的进程设高保护,但不要设 -1000(原因见第四节坑 1):
# /etc/systemd/system/redis.service.d/oom-protect.conf
[Service]
OOMScoreAdjust=-500
systemctl daemon-reload && systemctl restart redis
# 验证
cat /proc/$(pidof redis-server)/oom_score_adj # 应为 -500
3) 放羊内存大户(让内核优先杀它,别让它带崩整机)
给已知会失控的 java 进程显式「让位」——这不是自暴自弃,而是把「内核凭 RSS 打分」的隐式行为变成运维显式决策:
# /etc/systemd/system/order-svc.service.d/oom-sacrifice.conf
[Service]
OOMScoreAdjust=300
这样即使 java 内存泄漏,内核也会更确定地杀它而不是某个 agent;配合 systemd 的 Restart=always + 应用侧的冷启动预热(启动时先跑一遍热 key 预加载脚本,~35s),把「杀对进程」变成「可预期的降级」。
4) 预警:别等 oom 事件,用 PSI 提前 30s
整机 PSI(cgroup v1 时代读 /proc/pressure/memory,v2 读根 cgroup 的 memory.pressure):
# 实时看:some 是部分线程受阻、full 是整机所有线程同时受阻
cat /proc/pressure/memory
# some avg10=1.42 avg60=0.31 avg300=0.12 total=84213.44
# full avg10=0.87 avg60=0.19 avg300=0.08 total=41208.91
告警规则(Prometheus 伪代码):rate(node_pressure_memory_full_seconds[60s]) > 0.05 持续 2 分钟 → 提前 30s~2min 收到通知,远早于 oom_kill 事件(站内 id=904 那篇在 cgroup 内验证过同样的量级,本文补充「整机级」这一层:宿主机 OOM 看的是根 PSI,不是某个 pod 的)。
5) 长期:把「整机内存预算」写进容量规范
这次事故的深层根因是 128G 机器上「java 4.2G + redis 0.6G + sidecar 0.8G + page cache 无上限」——page cache 是唯一没有预算的部分,也是唯一能把水位顶穿的部分。规范里应明确:
- 业务进程 RSS 上限 ≤ 整机内存的 60%(128G → 76G);
- 剩余 40% 留给 page cache + 内核 + 突发;
- 任何新增进程上线前,
Σ(声明内存上限) ≤ 60%是硬性检查项。
四、踩过的坑(反直觉清单)
oom_score_adj=-1000不是「无敌」,是「核弹」:如果整机所有可杀进程的 adj 都是负值(都「不可杀」),内核会直接panic而不是继续杀——我们测试机上真复现过一次,整机重启。所以保护值建议 -500 ~ -800 区间,永远留一个「可杀」的默认分池。oom_score_adj只影响「宿主机 OOM killer 的选谁」,不影响 cgroup OOM:容器里memory.max触发的 oom_kill 走的是 memcg 内的打分逻辑,OOMScoreAdjust在 cgroup v2 下对 memcg 内选择无效(只对整机级有效)。所以「给容器设 OOMScoreAdjust=-1000 就安全了」是一个不成立的假设——容器内存隔离靠的是 limit 本身,不是 adj。- 内核 5.4+ 的 OOM group kill:如果一个 cgroup 内的进程触发整机 OOM,内核会直接杀整个 cgroup(
oom_group_kill),而不是杀单个进程。这意味着「保护 java 主进程、放过 sidecar」在 group kill 下不成立——同 cgroup 同生共死。想分开,就得拆 cgroup。 free里的 buff/cache 不是「可用内存」的安慰剂:这次 OOM 时free显示 buff/cache=40G、available=42G,但内核仍然触发 OOM——因为 Java 的mmap大页申请不可被 page cache 回收(它要的是连续的匿名页),available统计里的高水位 page cache 在这一刻救不了你。看到available绿就放心,是运维圈最大的自我安慰之一。dmesg里的 oom 现场,anon-rss和file-rss要分开看:file-rss 高(比如 2G)说明这个进程大量 mmap 了文件(JIT 代码段、so 库),这部分可回收,杀它的「代价」比纯 anon-rss 进程低——内核打分时 file-rss 权重更低,这也是为什么同样 RSS 的 java 比 redis 得分略低一点点(虽然这次差距主要来自总量)。
五、结论与可复用清单
可复用清单(按落地优先级)
- [ ] 给所有「杀了更贵」的进程(redis / etcd / 数据库)设
OOMScoreAdjust=-500,不要 -1000; - [ ] 给已知内存大户(java / 大 worker)设
OOMScoreAdjust=+300,把「隐式被杀」变成「显式让位」; - [ ] 整机 PSI
full告警(rate(...[60s]) > 0.05持续 2min),比 oom_kill 早 30s~2min; - [ ] 容量规范:业务进程 RSS 上限 ≤ 整机 60%,剩余留给 page cache + 内核;
- [ ] 应用侧「被杀后冷启动」预案:预热脚本 + 缓存旁路降级 + 连接池上限(max=2000 这类参数要写进发布 checklist);
- [ ] 内核 ≥ 5.4 时,同 cgroup 内进程「同生共死」,想分开就拆 cgroup;
- [ ]
dmesg | grep -A 30 "Out of memory"是 OOM 事后 5 分钟定位的第一条命令,比任何监控都准。
一句话总结:OOM killer 不关心你的业务,只关心 RSS 占比;你的工作不是「让它别杀」,而是让它杀在你想让它杀的地方,并在它杀完之后 90 秒内把业务拉回来。前者靠 oom_score_adj 治理,后者靠应用侧预热预案——两件事缺一不可。