凌晨三点,线上告警。你打开监控面板,发现主数据库进程消失了,而那个只占用几兆内存的后台日志收集进程却安然无恙。dmesg 的输出冷酷而简洁:
Out of memory: Kill process 18472 (postgres) score 891 or sacrifice child
这不是电影情节,而是无数运维工程师的真实经历。Linux 内核的 OOM Killer 本应是系统安全的最后一道防线,但它那套基于启发式评分的选杀机制,在过去二十多年里"误杀"了无数关键进程。问题来了:这套机制到底是怎么工作的?为什么数据库进程总是第一个被选中?又该如何让关键服务在内存危机中存活下来?
一个进程的"死刑判决书"
当 Linux 内核检测到系统内存即将耗尽、且所有回收尝试都失败后,OOM Killer 就会被激活。它会扫描系统中所有可杀进程,为每一个计算一个"不良评分"(badness score),然后向得分最高的进程发送 SIGKILL 信号。
这个评分的核心逻辑定义在内核源码 mm/oom_kill.c 的 oom_badness() 函数中:
// Linux 5.0+ 内核核心评分逻辑
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
mm_pgtables_bytes(p->mm) / PAGE_SIZE;
adj = (long)p->signal->oom_score_adj;
adj *= totalpages / 1000;
points += adj;
return points > 0 ? points : 1;
这段代码揭示了评分公式的本质:进程的 RSS(常驻内存)+ Swap 使用量 + 页表大小,再加上用户可调整的 oom_score_adj 偏移值。得分越高,越容易被杀死。
这就解释了为什么数据库进程总是首当其冲——它们恰恰是系统中 RSS 占用最高的进程。
从虚拟内存到物理内存:算法的十年演进
但早期版本的 OOM Killer 并非如此。在 Linux 2.6.36 之前,评分基准是进程的虚拟地址空间大小(total_vm),而非实际使用的物理内存。
这个设计存在严重缺陷。一个进程可以通过 malloc() 申请 10GB 虚拟内存但实际只使用 100MB,按照旧算法它却会成为头号猎杀目标。反过来,一个通过 fork 产生大量子进程、每个子进程都占用大量物理内存的服务(如 Apache 的 prefork 模式),主进程可能因为子进程的内存被部分计入评分而被杀死,导致整个服务崩溃。
Linux 2.6.36 版本彻底重构了这套算法,确立了三个核心原则:
- 物理内存为基准:评分完全基于 RSS 和 Swap 的实际使用量,而非虚拟地址空间
- 用户策略可控:通过
/proc/<pid>/oom_score_adj允许管理员干预评分 - 杀子不杀父:当父进程有多个子进程时,倾向于杀死子进程而非父进程,减少对系统整体的影响
评分的细节:谁在加分,谁在减分
现代 Linux 内核(3.10+)在基础评分之外,还加入了一些特殊的调整逻辑:
/* Root processes get 3% bonus */
if (has_capability_noaudit(p, CAP_SYS_ADMIN))
points -= (points * 3) / 100;
拥有 CAP_SYS_ADMIN 权限的进程(通常是系统关键服务)会获得 3% 的评分减免。这意味着,在其他条件相同的情况下,root 进程比普通用户进程更不容易被杀死——但这个减免幅度实在太小,当内存差距悬殊时几乎不起作用。
真正有效的控制手段是 oom_score_adj。这是一个 -1000 到 +1000 的整数值:
- -1000:魔法值,进程永远不会被 OOM Killer 选中
- 负值:降低被杀概率
- 正值:提高被杀概率
- +1000:几乎保证被选中
一个具体例子
假设系统总内存为 16GB(约 4,194,304 个 4KB 页面):
| 进程 | RSS + Swap | 基础评分 | oom_score_adj | 最终评分 |
|---|---|---|---|---|
| PostgreSQL | 8GB (2,097,152 pages) | 500 | 0 | 500 |
| Java 应用 | 4GB (1,048,576 pages) | 250 | +500 | 750 |
| 日志收集 | 100MB (25,600 pages) | 6 | +900 | 906 |
在这个例子中,虽然 PostgreSQL 内存占用最大,但由于 Java 应用和日志收集器的 oom_score_adj 设置了较高的正值,它们反而更可能被杀死。这就是为什么合理设置 oom_score_adj 可以有效保护关键进程。
容器时代的新挑战
当 Linux 进入容器时代,OOM Killer 的行为变得更加复杂。
每个 cgroup 都有独立的内存限制。当容器内存使用超过其 memory.limit_in_bytes 时,内核会触发 cgroup 级别的 OOM,只在该 cgroup 内选择牺牲进程,而不是整个系统。
但这带来了新问题。Kubernetes 默认会根据 Pod 的 QoS 等级自动设置 oom_score_adj:
- Guaranteed(limits == requests):
oom_score_adj = -998 - Burstable(有 requests 但无 limits):根据请求比例计算,通常为较高的正值
- BestEffort(无限制):
oom_score_adj = 1000
这意味着,如果你的数据库运行在 Burstable Pod 中,它在主机内存紧张时几乎注定会被优先杀死——即使它在容器内部内存充足。
PostgreSQL 社区的二十年抗争
PostgreSQL 社区从 2003 年就开始与 OOM Killer 斗争。他们的推荐策略至今仍是业界典范:
- 禁用内存过量分配:
vm.overcommit_memory = 2 - 保护 postmaster 进程:设置
oom_score_adj = -1000 - 允许后端进程被杀:子进程保持
oom_score_adj = 0
这个策略的核心逻辑是:postmaster 是数据库的核心守护进程,一旦被杀,所有连接都会断开,数据库进入崩溃恢复模式。而单个后端进程被杀,只会影响一个连接,代价小得多。
但在 Kubernetes 环境中,这套策略难以实施。Kubernetes 强制设置 vm.overcommit_memory = 1,并且不允许 Pod 自定义 oom_score_adj。这导致 PostgreSQL 在 Kubernetes 中运行时,OOM 风险显著增加。
用户空间的替代方案
内核的 OOM Killer 存在一个根本性问题:它触发得太晚了。当系统真的到了内存耗尽的边缘,可能已经进入严重的抖动状态,此时即使杀进程,系统响应也可能极慢。
这就是 earlyoom、nohang 和 systemd-oomd 等用户空间守护进程存在的原因。它们在内存使用达到临界阈值之前就开始工作,避免系统进入完全不可用的状态。
earlyoom 的默认策略是:当可用内存和 Swap 都低于 10% 时,向占用内存最多的进程发送 SIGTERM(而非 SIGKILL),给进程一个优雅退出的机会。这比内核 OOM Killer 的"先斩后奏"要温和得多。
实战:如何保护关键服务
场景一:传统服务器
# 保护进程永不被 OOM 杀死
echo -1000 > /proc/$(pidof postgres)/oom_score_adj
# 让某个进程优先被杀
echo 500 > /proc/$(pidof worker)/oom_score_adj
场景二:Kubernetes 环境
对于关键数据库服务,最安全的做法是:
- 使用 Guaranteed QoS 的 Pod
- 设置合理的内存 limit,留有足够余量
- 监控 cgroup 内存使用,在达到 80% 时告警
- 考虑使用
memory.requests而非memory.limits来获得 Burstable QoS(如果主机内存充足)
场景三:禁用 OOM Killer(高风险)
# 某些极端场景下可以完全禁用 OOM Killer
echo 0 > /proc/sys/vm/oom_kill_allocating_task
# 或者让系统直接 panic 而非杀进程
echo 2 > /proc/sys/vm/panic_on_oom
第二种配置会让系统在 OOM 时崩溃重启,在高可用集群中,这反而可能比随机杀进程更可控——至少可以确保备节点接管。
那些被忽略的细节
OOM Killer 还有一些鲜为人知的行为:
vfork 的特殊保护:进程正在执行 vfork() 时会被跳过,因为此时父进程和子进程共享内存空间,杀任何一个都会导致问题。
不可中断睡眠进程:处于 TASK_UNINTERRUPTIBLE 状态的进程理论上可以被杀,但实际上内核会尽量避免,因为这类进程通常正在进行关键的 I/O 操作。
OOM 通知机制:cgroup v1 提供了 memory.oom_control 文件,可以注册 OOM 事件通知。用户空间程序可以监听这个事件,在 OOM 发生时执行自定义逻辑(如扩容或迁移)。
结语:没有完美的解决方案
OOM Killer 的设计哲学是"宁可错杀,不可系统崩溃"。在内存耗尽的极端情况下,它的确能防止整个系统挂起。但它的启发式评分机制注定无法完美——内存占用高不等于进程不重要,这个假设在数据库、消息队列等服务上根本不成立。
对于生产环境,最稳妥的策略永远是:充足的内存规划 + 合理的 cgroup 限制 + 关键进程的 oom_score_adj 保护。OOM Killer 不应该是你的第一道防线,而应该是最后的救命稻草。
当那个凌晨三点的告警再次响起时,希望你已经在监控系统里看到了内存使用率的上升趋势,而不是在 dmesg 里翻找被杀进程的尸体。
参考资料
- Linux Kernel Source,
mm/oom_kill.c, https://github.com/torvalds/linux/blob/master/mm/oom_kill.c - LWN, “Taming the OOM killer”, https://lwn.net/Articles/317814/
- man7.org, “proc_pid_oom_score_adj(5)”, https://man7.org/linux/man-pages/man5/proc_pid_oom_score_adj.5.html
- Crunchy Data, “Deep PostgreSQL Thoughts: The Linux Assassin”, https://www.crunchydata.com/blog/deep-postgresql-thoughts-the-linux-assassin
- ClickHouse Blog, “The case of the vanishing CPU: A Linux kernel debugging story”, https://clickhouse.com/blog/a-case-of-the-vanishing-cpu-a-linux-kernel-debugging-story
- Linux Kernel Documentation, “Memory Resource Controller”, https://docs.kernel.org/admin-guide/cgroup-v1/memory.html
- 阿里云开发者社区, “OOM-KILLer的演进与新的启发式策略”, https://developer.aliyun.com/article/494318
- Last9 Blog, “Linux OOM Killer: A Detailed Guide to Memory Management”, https://last9.io/blog/understanding-the-linux-oom-killer/