Linux OOM Killer 排查与内存调优实战:从 dmesg 取证到 cgroup v2 限制、oom_score_adj 保护全流程

为什么你的服务半夜被"杀死"了

很多站长都遇到过这样的场景:白天一切正常,凌晨三点手机突然收到监控告警,网站 502 打不开,登录服务器一看,Nginx 或 MySQL 进程不见了,systemctl status 显示进程被 kill,重启时间点正好是内存打满的那一刻。这不是被黑客攻击,也不是程序主动退出,而是 Linux 内核的 OOM Killer(Out-Of-Memory Killer) 出手了。

OOM Killer 是内核内存管理子系统的一部分。当系统物理内存加上交换分区都被耗尽,且无法回收出足够内存时,内核必须先杀掉某个进程来保住系统不整体崩溃。它并不是随机挑人,而是按一套打分机制(badness 分数)选择"最该死的那个"。理解这套机制,才能真正定位问题、做出正确的调优,而不是盲目加内存或者关闭 OOM Killer。

一、先确认:到底是不是 OOM Killer 干的

排查任何问题,第一步永远是取证。判断进程是否被 OOM Killer 杀掉,有三条线索必须交叉验证。

1. dmesg 里的关键词

最直接的方式是查内核环形缓冲区日志:

dmesg -T | grep -i -E "out of memory|oom-kill|killed process"

典型的输出长这样:

[Tue Oct 14 03:11:42 2026] php-fpm invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[Tue Oct 14 03:11:42 2026] Out of memory: Killed process 18422 (php-fpm) total-vm:2097152kB, anon-rss:1572864kB, file-rss:0kB, shmem-rss:0kB, UID:33 pgtables:3216kB oom_score_adj:0
[Tue Oct 14 03:11:42 2026] oom_reaper: reaped process 18422 (php-fpm), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB

这里有三个关键信息:invoked oom-killer 说明是谁在申请内存时触发的;Killed process 说明被杀的是谁、占了多少内存(anon-rss 是匿名内存即堆内存);oom_score_adj 是它的被杀权重。

2. 用 journalctl 查更完整的现场

dmesg 的缓冲区可能被翻页冲掉,journalctl -k 能拿到完整的内核日志,并且带时间戳可以精确定位:

journalctl -k --since "2026-10-14 03:00" --until "2026-10-14 04:00" | grep -i -A5 -B5 oom

如果服务器没装 systemd-journald 持久化,检查 /var/log/kern.log 或 /var/log/messages(Debian/Ubuntu 与 CentOS 的路径不同)。

3. 检查应用自身的退出记录

被 SIGKILL 杀掉的进程不会留下调用栈、不会写 crash 日志,这也是它和"程序崩溃"最本质的区别。如果你的服务日志里没有任何报错就突然中断,紧接着同一时刻有其他进程申请内存的迹象,基本可以锁定是 OOM。

二、读懂 OOM Killer 的打分机制

很多人以为 OOM Killer 杀的是"占内存最多"的进程,其实只对了一半。内核的评分逻辑大致是:

badness = (进程占用内存) × (oom_score_adj 调整系数) / 系统总内存

其中 oom_score 是内核计算的原始分(0~1000),oom_score_adj 是用户可以调节的偏移量(-1000~1000),最终生效的是两者叠加后的分数。分数越高越先被杀。

查看当前系统上各进程的实时分数:

for p in /proc/[0-9]*/oom_score; do
  pid=$(echo $p | cut -d/ -f3)
  score=$(cat $p 2>/dev/null)
  name=$(cat /proc/$pid/comm 2>/dev/null)
  echo "$score $pid $name"
done | sort -rn | head -20

这条命令会按分数从高到低列出最可能被杀的进程,非常实用。你会注意到,占用内存大的进程天然分高,但 root 身份的进程会被内核扣分,而普通用户进程相对更容易被杀——这也是为什么数据库、Web 服务这类跑在低权限用户下的进程往往是 OOM 的第一批受害者。

三、保护关键服务不被误杀

如果你已经知道某个进程是核心业务(比如 Nginx 主进程、MySQL),可以通过 oom_score_adj 降低它的被杀概率。对单个进程:

echo -500 > /proc/$(pgrep -o mysqld)/oom_score_adj

但每次重启都要设太麻烦,正确的做法是写进 systemd 单元里:

[Service]
OOMScoreAdjust=-500

对于绝对不能被杀的服务,可以设为 -1000,内核将永远不对它下手。但这里有个非常重要的陷阱:把关键服务保护成 -1000 之后,内核就会转而去杀其他进程,可能导致你的 SSH 会话、监控 agent 甚至 init 进程被干掉,严重时整个系统失联。所以负值要克制使用,一般 -500 到 -800 已经足够,并且要保证系统总内存有合理余量。

反过来,对那些"可以牺牲"的批处理任务、临时构建进程,可以设置正值鼓励内核优先杀它:

echo 300 > /proc/$$/oom_score_adj

四、从根源上解决:内存到底被谁吃了

保护关键进程只是止血,真正解决问题必须找到内存消耗源头。按顺序排查以下几项。

1. 用 free 看清内存构成

free -h
              total        used        free      shared  buff/cache   available
Mem:           3.8Gi       3.5Gi       120Mi        35Mi       210Mi       180Mi
Swap:          2.0Gi       1.9Gi        90Mi

重点看 available 而不是 free。Linux 会把空闲内存大量用于 page cache(buff/cache),这部分在需要时可以被回收,所以 free 很小是正常的。但如果 available 长期低于总内存的 10%,系统就已经处在 OOM 边缘了。 另外 shared 一列如果异常大,要怀疑 tmpfs 或共享内存(MySQL 的 buffer pool、PHP 的 opcache 都可能占用)。

2. 用 smem 或 ps 按进程归类

最经典的 top 按内存排序只显示 RES,会重复计算共享内存。想要准确知道每个进程的真实占用,推荐装 smem:

apt-get install -y smem
smem -t -k -c "pid user name pss rss" -s pss | tail -25

PSS(Proportional Set Size)会把共享内存按共享进程数平摊,比 RSS 更接近真实值。-t 会输出总计行,一眼看出总占用。smem -t -k -c "user pss" -g 还能按用户聚合。

3. 区分"内存泄漏"和"缓存膨胀"

这是最容易被误判的地方。如果某个进程的 RES 持续单调上涨、重启后归零又慢慢爬升,这才是真正的内存泄漏(比如 PHP-FPM 里跑了一个有 bug 的扩展)。而如果是 page cache 涨满,那只是内核在利用空闲内存做磁盘缓存,属于正常现象,不该动它。

要观察趋势,可以用一段脚本采样:

while true; do
  echo "$(date +%H:%M:%S) $(ps -o rss= -p $(pgrep -o php-fpm))"
  sleep 30
done

如果 RSS 呈现缓慢爬升、接近某个阈值后 OOM,基本就是 PHP-FPM 的 pm.max_requests 设得太大,脚本里累积的静态变量没能释放。把 pm.max_requests 从 500 降到 100~200,让子进程定期主动重启,能极大缓解这类问题。

五、overcommit 与 cgroup:两个容易被忽视的开关

1. vm.overcommit_memory

Linux 默认允许"超卖"内存,即进程申请的内存总量可以大于物理内存(依赖按需分配)。可以通过 sysctl 调整:

sysctl vm.overcommit_memory
vm.overcommit_memory = 0

三个取值的含义:0 启发式判断(默认,最平衡);1 总是允许超卖(适合 Redis 等依赖 fork 的场景,Redis 官方推荐设 1);2 严格禁止超卖,内核按 CommitLimit 硬算(最容易导致进程 malloc 失败,慎用)。

如果你的服务器跑 Redis,官方明确建议设置 vm.overcommit_memory = 1,否则 fork 生成 RDB 快照时可能因为内存不够直接失败。持久化配置写进 /etc/sysctl.d/99-oom.conf:

vm.overcommit_memory = 1
vm.swappiness = 10

2. swappiness 与 swap 的取舍

vm.swappiness 决定内核多积极地使用交换分区(0~100)。默认 60 对服务器来说偏高,会造成热点数据被换出、磁盘 IO 抖动。生产服务器建议设为 10 甚至 1,让内核尽量留在物理内存里。但要注意:把 swappiness 设为 0 并不等于禁用 swap,在内存实在不够时内核仍会使用它,只是放弃了主动换出。真正的取舍是:宁可短暂卡顿也不要 OOM,就保留一点 swap;宁可 OOM 立刻暴露问题也不要性能抖动,就把 swappiness 调低并监控内存水位。

3. cgroup v2 精确限制单个服务

最"文明"的做法是给每个服务划定内存上限,让它在自己的边界内被限制或杀掉,而不是拖垮整个系统。systemd 直接支持:

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

MemoryHigh 是软限制,超过会被强行回收和降速;MemoryMax 是硬限制,超过立刻触发该 cgroup 内的 OOM(只杀这个单元里的进程,不影响系统其他部分)。这种方式对多服务共存的单人 VPS 特别有价值——一个失控的 PHP 进程最多拖垮自己的服务,不会连带把 MySQL 一起弄死。

验证限制是否生效:

systemctl show php8.2-fpm | grep -i memory
cat /sys/fs/cgroup/system.slice/php8.2-fpm.service/memory.current

六、一套可落地的排查与防护清单

把上面的内容整理成站长的日常操作清单:

  1. 先取证再动手:dmesg -T | grep -i oom 和 journalctl -k 确认是否 OOM,记录被杀进程名和 anon-rss。
  2. 装 smem 建立基线:平时就定期记录各服务的 PSS,出事时和基线对比,快速锁定异常增长的服务。
  3. 给关键服务设 OOMScoreAdjust=-500:写在 systemd 单元里,重启生效;但不要动辄 -1000,避免连 SSH 都被杀。
  4. 给失控候选服务设 MemoryMax:把风险关进 cgroup 笼子里,让故障局部化。
  5. 调 swappiness 到 10 左右,跑 Redis 的机器设 vm.overcommit_memory=1。
  6. 排查 PHP-FPM 的 pm.max_requests,找出内存泄漏脚本,而不是无脑加内存。
  7. 配一个内存水位监控:用 Telegraf/Node Exporter 采集 MemAvailable,低于 15% 就告警,给自己留出处置时间,而不是等 OOM 报警才反应过来。

OOM Killer 从来不是敌人,它是内核在最后一刻保护系统的安全阀。真正的敌人是"看不见的内存泄漏"和"没有边界的内存使用"。把 cgroup 限制、正确的 swappiness、合理的进程保护加上,你的服务器就能从"半夜随机宕机"变成"稳定可预期",这也是每个自建服务站长迟早都要补上的一课。

Last modification:October 14th, 2026 at 07:23 pm

Leave a Comment