Linux OOM Killer 与内核 overcommit 调优实战:读懂 oom_score、cgroup 内存隔离与元凶定位

进程无缘无故被杀的元凶:OOM Killer

你有没有遇到过这种诡异现象:半夜什么都没做,MySQL 或某个 PHP-FPM 进程突然没了,systemd 显示服务 failed,登录一看 dmesg 里有这么一行——"Out of memory: Killed process 12345 (mysqld)"。这不是崩溃,是被 Linux 内核的 OOM Killer 主动杀掉了。理解它,才能决定到底是加内存、调参数,还是改应用。

本文把 OOM Killer 的触发逻辑、overcommit 策略、oom_score 判定和 cgroup v2 下的内存限制讲透,给出一套可落地的诊断和调优流程。目标不是"关掉 OOM Killer"——那是饮鸩止渴,而是让它在该杀的时候杀对进程,在不该杀的时候别误伤。

先搞清楚它为什么必须存在

Linux 采用"惰性分配":进程 malloc 或 fork 时,内核只给虚拟地址空间,不立刻分配物理页,等真正写入时才按需分配(缺页中断)。这个设计的前提是"进程不会一次性用满所有申请的内存"。但当所有进程同时真的去写内存,物理内存加 swap 都不够时,系统要么卡死,要么随机杀进程——内核选择后者,因为返回失败比整机僵死要好。

所以 OOM Killer 不是 bug,是最后一道保命机制。真正的问题是:它选中的"倒霉蛋"经常不是内存大户,而是一个同样重要的辅助服务,导致级联故障。

overcommit 策略决定"能不能超额申请"

内核参数 vm.overcommit_memory 控制分配行为:

cat /proc/sys/vm/overcommit_memory
# 0 = 启发式(默认):明显过分的申请才拒绝
# 1 = 永远允许:想申请多少给多少,OOM 风险最高
# 2 = 不允许超额:CommitLimit 封顶,超过直接返回 ENOMEM

默认的 0 在大多数场景够用。把值设成 1 通常是为了 Redis 之类的 fork + RDB 快照不掉链子(fork 需要额外虚拟内存空间),代价是更容易 OOM。设成 2 最保守,但 vm.overcommit_ratio 配太紧会让正常服务起不来。除非有明确理由,别乱动,尤其是别在内存紧张的小 VPS 上设成 1。

谁会先被杀:oom_score 是怎么算的

OOM Killer 给每个进程算一个 oom_score,分越高越先被杀。你可以实时查看:

# 当前各进程的内存占用概览
ps -eo pid,ppid,rss,pmem,comm --sort=-rss | head -20

# 单个进程的 oom 信息
cat /proc/$(pgrep mysqld)/oom_score
cat /proc/$(pgrep mysqld)/oom_score_adj

oom_score 主要和进程占用的内存(RSS)成正比。oom_score_adj 是一个可调偏移,范围 -1000 到 1000,加在 score 上。-1000 表示"永远别杀我",1000 表示"优先杀我"。sshd、systemd 等关键服务通常默认带负值偏移,这也是为什么有时候被杀的是你的数据库而不是 SSH。

调优思路很直接:把关键数据库的 oom_score_adj 调低(比如 -500),把可以牺牲的服务调高。但要注意,如果整机内存真的不够,把所有服务都设成 -1000,内核会退回到"谁的 score 相对最低也得杀一个"的状态,保护是相对的,不是绝对的。

# systemd 服务里声明内存保护(推荐用这个而不是手动改 oom_score_adj)
[Service]
OOMScoreAdjust=-500
MemoryMin=512M
MemoryMax=2G

在 unit 文件里写 OOMScoreAdjust 比运行时 echo 更持久。配合 MemoryMax 还能让该服务在超过上限时被 cgroup 限制,而不是拖垮整机。

用 cgroup v2 做硬性内存隔离

现代发行版默认 cgroup v2,systemctl status 里能看到每个服务的 Memory 用量。给服务设 MemoryMax 是最靠谱的防 OOM 手段——超限时只有该服务内的进程被限制或杀掉,不会波及全局:

[Service]
MemoryHigh=1G
MemoryMax=1536M
MemorySwapMax=0

MemoryHigh 是软限,超过后内核会积极回收、节流;MemoryMax 是硬限,超过就 OOM kill 该 cgroup 内的进程。MemorySwapMax=0 禁止使用 swap,适合对延迟敏感的数据库(宁可失败也不要被 swap 拖成乌龟)。改完执行 systemctl daemon-reload && systemctl restart 服务。

一套可落地的诊断流程

遇到进程被杀,按这个顺序查:

# 1. 确认是不是 OOM(看内核日志)
journalctl -k | grep -i "out of memory" | tail -20
dmesg -T | grep -i "killed process"

# 2. 看当时谁在吃内存(日志里会打印各进程的 rss 快照)
#    找出消费大户,而不是只看"谁被杀"

# 3. 看 swap 状态
free -h
swapon --show

# 4. 看内存增长趋势(用 sar 或 Prometheus)
sar -r 1 5

关键认知:OOM 日志里被杀的进程往往不是"肇事者"。真正吃光内存的进程可能因为 oom_score 更低而幸免。所以务必看日志里那次事件打印的内存占用排行,找到真正的元凶。

常见根因与对症下药

第一类,内存泄漏。应用 RSS 随时间稳步上涨,重启即恢复。用 pmap -x PID 看各段内存,或用 Valgrind、语言自带的 profiler 定位。第二类,配置过大。PHP-FPM 的 pm.max_children、MySQL 的 innodb_buffer_pool_size、Redis 的 maxmemory 没设或设太大,多个服务加起来超过物理内存。第三类,流量突增。缓存击穿导致后端同时大量查库、生成页面,瞬时内存飙升。这类要靠限流、队列和缓存预热解决,而不是无脑加内存。

设 swap 也有讲究。小内存 VPS 上留 1~2G swap 能给 OOM 一点缓冲,但 vm.swappiness 别太高(10~20 即可),否则热数据被换出反而拖慢。对数据库服务器,宁可把 swappiness 调到 1 甚至关掉 swap,靠 MemoryMax 兜底。

内核日志里那几个数字怎么读

OOM 事件发生时,dmesg 会打印一大段信息,很多人扫一眼就关掉了,其实里面藏着破案的关键。典型的输出长这样:

Out of memory: Killed process 3312 (php-fpm) total-vm:2048000kB, anon-rss:1800000kB, file-rss:4096kB
oom_reaper: reaped process 3312 (php-fpm), now anon-rss:0kB

其中 total-vm 是进程申请的虚拟内存总量,anon-rss 是真正驻留在物理内存的匿名页(堆和栈,这才是内存大头),file-rss 是映射的文件页(通常可回收,几乎不构成 OOM 压力)。判断一个进程是不是元凶,看 anon-rss 而不是 total-vm——有些程序申请了几十 GB 虚拟内存却只用了很少,把它当凶手是误判。

更早的位置还会有一段"内存水位"快照,列出当时所有主要进程的 rss,按大小排序。这才是真正的嫌疑名单。把这段完整复制出来,对照各服务平时的内存基线,凡是远超基线的就是肇事者。很多人只盯着日志里"被杀的那个进程名",恰恰会错过真正吃光内存的那个。

容器环境下的 OOM 与宿主机的区别

如果你用 Docker,OOM 会更复杂。给容器设了 --memory=512m 之后,超限时被杀的是容器内的进程,日志会显示在 docker inspect 的 State.OOMKilled 字段,而不是宿主机 dmesg。常见误区是容器内存超限后不断重启,管理员只看容器日志,完全不知道是内存限制太紧。

# 查看容器是否被 OOM 杀掉
docker inspect --format '{{.State.OOMKilled}}' 容器名
# 查看容器内存上限是否生效
docker stats --no-stream
cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max
docker inspect --format '{{.HostConfig.Memory}}' 容器名

Java 应用在容器里尤其容易踩坑:JVM 默认按宿主机内存算堆大小,容器限了 512M,JVM 却按宿主机 8G 去设置堆,结果一启动就被 OOM。解决办法是显式传 -XX:MaxRAMPercentage=75.0,让 JVM 感知 cgroup 限制。Node.js 的 V8 老版本也有类似问题,需要手动设 --max-old-space-size。只要用了容器,就必须让运行时知道自己的内存天花板,否则任何限制都形同虚设。

建立内存基线并做趋势告警

OOM 最怕"突发式死亡",其实绝大多数 OOM 都有前兆:内存使用率在几个小时甚至几天里缓慢爬升。只要监控到位,完全可以在它爆掉之前介入。最简单的方式是用 node_exporter 采集 node_memory_MemAvailable_bytes,在 Grafana 里画一条曲线,设一个"可用内存低于 15% 持续 10 分钟"的告警规则。

# 没有监控系统时的轻量替代:定时记录
free -m | awk '/Mem:/ {print strftime("%F %T"), $7}' >> /var/log/memlog

更精细一点,给每个关键服务单独记录 RSS:

ps -eo rss,comm --sort=-rss | head -10 >> /var/log/memlog

坚持记录一段时间,你就有了一份"正常水位"基线。之后任何越界都能第一时间发现。个人站资源有限,不需要上重型 APM,一个每小时跑一次的采集脚本 + 一条阈值告警,就能把 90% 的 OOM 事故挡在发生之前。

数据库服务的内存规划特别提醒

MySQL/MariaDB 是 OOM 重灾区。常见的错误配置是把 innodb_buffer_pool_size 设成物理内存的 80%,却忽略了 InnoDB 还会额外消耗 per-connection 内存、排序缓冲、临时表空间。一台 2G 内存的机器,buffer pool 设 1.6G,剩下 400M 要养活系统、PHP-FPM、Nginx 和几百个并发连接,OOM 几乎是必然。比较务实的比例是:小内存机器上 buffer pool 不超过物理内存的 50%~60%,同时把 max_connections 压到实际需要的水平。

Redis 则要注意:开了 RDB 持久化后,fork 出来的子进程在写快照瞬间会占用和主进程差不多的内存(写时复制,但写多的时候几乎翻倍)。2G 内存的 Redis 实例在 fork 时可能瞬间需要 3G+,如果 vm.overcommit_memory=0 又恰好内存紧张,就会直接 OOM。所以 Redis 建议设 maxmemory 并配置淘汰策略,留出至少一倍于数据集的内存余量给 fork。理解了这些细节,你就能在 OOM 发生之前,把它消弭于配置之中。

别用"关掉 OOM Killer"来解决问题

网上老办法是 vm.panic_on_oom=0 加上给关键进程设 oom_score_adj=-1000,看似"保护"了服务,实际是把随机杀变成整机僵死——那才是真正的灾难。正确的姿势是:监控先行(尽早发现内存上涨趋势),cgroup 隔离(把爆炸限制在单个服务内),参数治本(修泄漏、调配置),OOM Killer 作为最后的保险永远保留。你的目标不是不让它杀,而是让它杀对、杀得晚、杀得有备而来。

Last modification:October 7th, 2026 at 09:24 pm

Leave a Comment