大家好,我是内核喵。
很多做后端的小伙伴经常遇到服务突然暴毙的情况,去 dmesg -T 一看,赫然写着:
[Sun Mar 29 14:23:01 2026] Out of memory: Kill process 28419 (java) score 952 or sacrifice child
[Sun Mar 29 14:23:01 2026] Killed process 28419 (java) total-vm:8451200kB, anon-rss:4194304kB, file-rss:1024kB, shmem-rss:0kB
大部分人的第一反应是:加内存!或者无脑把 /proc/$PID/oom_score_adj 拉到 -1000。但作为一个喜欢刨根问底的系统调优爱好者,咱们必须扒开 Linux 内存子系统的底裤,看看到底是哪行代码、哪次分配把 OOM Killer(Out of Memory Killer)给召唤出来的。
今天喵就带大家顺着内核源码的调用链路走一遭,顺便理清楚在 K8s / 容器时代,内存到底是怎样一步步被挤爆的。
一、 内存到底是怎么被耗尽的?伙伴系统告急
很多初学者容易误解:进程调了 malloc(1GB),内核就会立刻给它分 1GB 物理内存。
并没有。
用 strace 抓一下就知道,malloc 底层无非是 brk 抬高堆顶指针,或者调用 mmap 分配一段匿名虚拟映射。这时候内核只是给进程的 mm_struct 里塞了一个 vm_area_struct(VMA),物理页帧(Page Frame)连影子都没有。
真正分配物理内存的时刻,是 CPU 第一次去访问这块虚拟地址,MMU 查页表发现页表项是空的,随即触发缺页异常(Page Fault),代码陷入内核态的 do_page_fault -> handle_mm_fault。
这时候,内核终于要去向伙伴系统(Buddy System)讨要物理页了:
handle_mm_fault()
└── __handle_mm_fault()
└── handle_pte_fault()
└── do_anonymous_page()
└── alloc_zeroed_user_highpage_movable()
└── alloc_pages_vma()
└── __alloc_pages()
进入 __alloc_pages() 后,事情开始变得精彩起来。内核会根据 Zone 的水位线(Watermark: WMARK_MIN, WMARK_LOW, WMARK_HIGH)来判断内存紧张程度:
- 快速路径(Fast Path):剩余内存高于
WMARK_LOW,伙伴系统开心发牌,直接返回物理页。 - 慢速路径(Slow Path -
__alloc_pages_slowpath):- 内存低于
WMARK_LOW时,唤醒后台守护线程kswapd异步回收内存(回写脏页、收割 Page Cache)。 - 如果内存继续暴跌至
WMARK_MIN,进程就会被阻塞,进入直接内存回收(Direct Reclaim)。这时候你会发现业务请求延时突然飙高,perf top里能看到大量的shrink_inactive_list和shrink_page_list。 - 如果直接内存回收和页规整(Memory Compaction)折腾了半天,依然凑不出一张干净的连续物理页……
- 内存低于
好吧,内核两手一摊:触发 out_of_memory()。
二、 源码窥探:OOM Killer 挑谁下手?
既然下定决心要杀进程,内核究竟是怎么决定“献祭”哪个进程的?
我们来看 Linux 内核中 mm/oom_kill.c 的核心逻辑。
当慢速路径彻底走投无路时,内核会调用:
// 伪代码整理自 linux/mm/page_alloc.c
__alloc_pages_slowpath(...)
{
// ... 多轮直接回收和规整失败 ...
if (out_of_memory(&oc)) {
// 成功杀了一个进程,期待释放出物理页,重新尝试分配
goto retry;
}
}
而在 mm/oom_kill.c 中,内核通过遍历所有任务,调用 oom_badness() 计算每个进程的坏蛋指数(Badness Score):
// 整理自 linux/mm/oom_kill.c
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
long adj;
if (oom_task_origin(p))
return ULONG_MAX;
// 无法被杀死的进程:init 进程、内核线程等
if (p->flags & PF_KTHREAD || is_global_init(p) || (p->signal->flags & SIGNAL_GROUP_COREDUMP))
return 0;
// 读取用户的 oom_score_adj [-1000, 1000]
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN) // -1000 表示彻底豁免
return 0;
// 基础分:主要计算进程消耗的物理内存大小
// 匿名页 + 页表占用的内存 + Swap 占用
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
atomic_long_read(&p->mm->nr_ptes);
// 根据 oom_score_adj 线性调整
adj *= (long)totalpages / 1000;
points += adj;
return points > 0 ? points : 1;
}
从源码中可以看出两个极其关键的结论:
- 基础分几乎等于
RSS + Swap + 页表开销:谁占的物理页多,谁就是靶子。Java / Go 等常驻大内存后端往往首当其冲。 oom_score_adj是绝对的权重杠杆:其取值范围是[-1000, 1000]。若设置为-1000,直接返回 0,免死金牌生效;若是正值,会让原本占用小的进程也成为被杀目标。
三、 容器时代的降维打击:Memcg OOM vs 宿主机 OOM
在容器和 Kubernetes 场景下,绝大多数后端碰到的并不是宿主机全局 OOM,而是 Memory Cgroup(Memcg)OOM。
1. 现象差异
- 宿主机 OOM:整台物理机或虚拟机内存耗尽。内核遍历所有进程找个分最高的杀。
- Memcg OOM:宿主机内存还剩大半(比如还有 64GB),但是你的容器申请的 Limit 是 4GB,此时容器内进程消耗达到了 4GB。这时候 Cgroup 会单独触发 OOM,只在当前 cgroup 内寻找受害者杀掉。
2. Cgroup v1 与 v2 的演进
在 Cgroup v1 中,memory.limit_in_bytes 是一条绝对死线。一旦超标且 memory.oom_control 没关,直接触发 kill。
而在 Cgroup v2 中,内存控制精细了许多:
memory.max:硬限制(Hard Limit),触发 Memcg OOM。memory.high:软限制(Throttle Limit)。如果超过了这个值,内核不会立刻杀掉进程,而是会通过在进程退出系统调用时注入强制延迟(Direct Throttling),逼迫进程慢下来,同时加大力度回收内存。memory.oom.group:这是个救命特性。在微服务多进程容器(比如带 Sidecar)中,一旦配置为 1,容器里只要有人触发 OOM,整组容器进程会一起死掉,避免主进程死了、Sidecar 还在装模作样跑着的僵尸状态。
四、 现场捕获:怎么用工具精准抓凶?
别等容器重启了再去猜发生了什么。喵一般会通过以下手段排查:
1. 解析 dmesg 中的 OOM Dump
看 dmesg 时别只看最后一行,注意看分配失败时的上下文:
[Sun Mar 29 14:23:00 2026] java invoked oom-killer: gfp_mask=0x1100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=950
[Sun Mar 29 14:23:00 2026] CPU: 2 PID: 28419 Comm: java Not tainted 5.15.0-88-generic #98-Ubuntu
[Sun Mar 29 14:23:00 2026] Call Trace:
[Sun Mar 29 14:23:00 2026] <TASK>
[Sun Mar 29 14:23:00 2026] dump_header+0x4a/0x200
[Sun Mar 29 14:23:00 2026] oom_kill_process+0xa2/0x150
[Sun Mar 29 14:23:00 2026] out_of_memory+0x212/0x520
[Sun Mar 29 14:23:00 2026] __alloc_pages_slowpath.constprop.0+0x8f2/0xcc0
order=0:说明是在申请单个 Page(4KB),而不是大块连续内存。这代表内存是真耗尽了,而不是内存碎片问题。gfp_mask:内存分配掩码。GFP_HIGHUSER_MOVABLE表示这是用户空间的一般内存,允许被迁移。
2. bpftrace 实时捕获 OOM 事件
使用 eBPF 在内核触发 oom_kill_process 时打印调用栈,比轮询系统日志快得多:
#!/usr/bin/env bpftrace
kprobe:oom_kill_process
{
$oc = (struct oom_control *)arg0;
$victim = (struct task_struct *)arg1;
time("%H:%M:%S ");
printf("OOM triggered! Killer: %s (PID: %d), Victim: %s (PID: %d)\n",
comm, pid, $victim->comm, $victim->pid);
}
五、 后端防杀与调优实战
既然搞明白了原理,在日常后端架构中,我们该怎么防止关键进程被莫名献祭?
1. Kubernetes QoS 与 oom_score_adj 的映射
Kubernetes 是通过调整 Pod 的 oom_score_adj 来实现服务质量保障(QoS)的:
| QoS Class | 资源配置条件 | 容器的 oom_score_adj | 命运 |
|---|---|---|---|
| Guaranteed | Limits 和 Requests 完全一致 | -997 | 最后被杀,极其安全 |
| Burstable | Requests 小于 Limits | min(max(2, 1000 - (1000 * req) / cap), 999) | 视内存使用率而定 |
| BestEffort | 既没配 Requests 也没配 Limits | 1000 | 最先被献祭的炮灰 |
避坑指南:你的核心网关、结算系统,Requests 和 Limits 务必写成一样,直接升至 Guaranteed 级别,拿到 -997 的超低被杀分值。
2. 避免 Runtime 视界偏差(Java & Go)
很多后端杀手其实是语言运行时自己看不清 cgroup。
- Java (JVM):
千万别盲目写死
-Xmx。JDK 8u191+ 和 JDK 11+ 已经支持 cgroup 限制,推荐使用百分比配置:bash留出 25% 给元空间(Metaspace)、堆外内存(DirectMemory)以及 glibc 的java -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -jar app.jararena堆开销。 - Golang:
Go 1.19 引入了革命性的运行时软限制
GOMEMLIMIT。 如果你的 Pod 限制是 2GB,请务必设置:bash这样当 Go 堆接近 1.8GB 时,GC 就会狂暴运行以压榨空间,绝不轻易去摸 2GB 的 Cgroup 边界,从根源避免被 OOM Killer 强杀。export GOMEMLIMIT=1800MiB
3. 避免内存过度承诺(Overcommit)失控
在宿主机层面,如果你的机器专门跑计算服务,可以通过 sysctl 约束内核的内存分配策略:
# 查看当前的 overcommit 模式
sysctl vm.overcommit_memory
# 0: 启发式过量分配 (默认)
# 1: 始终允许 overcommit (慎用,Redis 官方文档推荐值,但对多租户极不友好)
# 2: 严格禁止 overcommit,物理内存用完即报 ENOMEM
sysctl -w vm.overcommit_memory=2
sysctl -w vm.overcommit_ratio=80
在模式 2 下,进程在调用 malloc 虚拟内存越界时就会直接拿到 ENOMEM,虽然可能导致程序直接抛出异常退出,但能避免内核突然无差别下黑手,保全宿主机上的其他重要服务。
尾巴
探究到内核这层就会发现,Linux 的 OOM Killer 从来不是一个 Bug,而是内核在物理资源破产时为了保护操作系统自身不至于死机 Panic,做出的最后挣扎与妥协。
面对 OOM,不要急着用黑魔法给进程“免死”,深入分析到底是谁在漏内存、把 Cgroup 边界与语言运行时做好联动,才是系统调优的正道。
大家线上有遇到过什么匪夷所思的 OOM 现象吗?欢迎在评论区贴日志,咱们一起 review!
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


