对个人站长来说,最让人措手不及的故障不是磁盘写满,也不是 CPU 飙高,而是服务在毫无征兆的情况下突然消失——SSH 连得上,Nginx 还在跑,但 PHP-FPM 或者数据库进程没了,dmesg 里躺着一行冷冰冰的 "Out of memory: Killed process"。这就是 Linux 的 OOM Killer 在干活。本文从原理讲起,把「为什么会 OOM、怎么确认是它、怎么排查真凶、怎么根治」这条链路完整走一遍。
一、OOM Killer 到底是什么
Linux 有一个先天设计理念:内存不用白不用。所以内核会尽量把空闲内存拿去做文件缓存(page cache),让磁盘读写更快。这意味着你执行 free -h 时看到 available 很小,并不一定是真的内存紧张——buffer/cache 是可以随时回收的。
但当真正需要内存的进程(匿名内存,即堆和栈)申请不到,且连回收缓存、换出到 swap 都失败时,内核就面临一个选择:要么整个系统死锁,要么杀掉某个进程换回内存。Linux 选择了后者,这就是 OOM Killer。它的职责是:在内存耗尽的最后关头,牺牲一个进程保住整个系统。
理解这一点很重要:OOM Killer 杀死进程不是 bug,而是系统在崩溃边缘的自救。要解决的是内存不足这个根因,而不是想办法阻止内核杀进程。
二、怎么确认发生了 OOM
第一时间应该看内核日志。dmesg 和 journalctl 都会留下记录:
dmesg -T | grep -i -E "out of memory|killed process" journalctl -k --since "1 hour ago" | grep -i oom
典型输出长这样:
Out of memory: Killed process 12345 (php-fpm) total-vm:1048576kB, anon-rss:587200kB, file-rss:1024kB, shmem-rss:0kB, UID:33 pgtables:1200kB oom_score_adj:0
这条日志信息量很大,逐项解读:
- total-vm:进程申请的虚拟内存总量。这个值常常很大但不代表真实占用。
- anon-rss:匿名内存实际驻留量,这才是真正吃掉物理内存的部分,重点看它。
- oom_score_adj:OOM 打分调整值,范围 -1000 到 1000。值越高越容易被杀。
- UID:进程属主,33 通常是 www-data。
如果 dmesg 被清过或者机器重启了,可以查系统日志文件:
grep -i "killed process" /var/log/syslog /var/log/messages 2>/dev/null grep -i oom /var/log/kern.log 2>/dev/null
另外一个信号是:某个服务每次运行一段时间就自动重启,日志里找不到任何异常退出原因,跟「被外部杀进程」的感觉一致,这时候就要优先怀疑 OOM。
三、看内存的真实使用情况
free -h 是最基础的工具,但要会看:
free -h
total used free shared buff/cache available
Mem: 1.9Gi 1.4Gi 85Mi 12Mi 450Mi 420Mi
Swap: 2.0Gi 380Mi 1.6Gi关键指标是 available(可用量,420Mi),而不是 free(85Mi)。free 小是正常的,buff/cache 里大部分随时可回收。真正要警惕的是:
- available 长期低于总内存的 10%
- Swap 的 used 持续增长而且居高不下,说明物理内存已经不够,系统在死亡边缘用磁盘顶着
要看是哪个进程在吃内存:
ps aux --sort=-rss | head -15 top -o %MEM
RSS 列(驻留集大小)就是进程真实占用的物理内存。按它排序,排名靠前的就是嫌疑对象。在 LNMP 环境里,常见的情况是 PHP-FPM 子进程数量过多、MySQL 缓冲池设置过大,或者某个 PHP 脚本处理大文件时内存暴涨。
四、深入排查:找出真正的内存大户
知道哪个进程吃内存之后,还要搞清楚是「配置导致」还是「代码导致」。分几种情况:
情况一:PHP-FPM 子进程过多
每个 PHP-FPM 子进程都会独立占用一份内存。如果 pm.max_children 设成 50,而每个进程峰值占 100MB,理论上限就是 5GB——2GB 的机器必然 OOM。查单个进程的平均内存:
ps --no-headers -o rss -C php-fpm8.2 | awk '{sum+=$1; n++} END {print "avg RSS:", sum/n/1024, "MB, count:", n}'用这个平均值乘以 max_children,就能算出最坏情况的内存需求,再对照机器实际内存做调整。这也解释了为什么低配服务器上「加大 max_children 提升并发」往往适得其反。
情况二:MySQL 参数超标
MySQL 的 innodb_buffer_pool_size 是最大的一块内存占用配置。在 2GB 内存的机器上,如果照搬网上的教程设置成 1GB,再叠加 PHP-FPM 和系统开销,溢出是迟早的事。记住一条经验:单机 LNMP 环境下,MySQL 至少给系统留出 512MB 以上的余量。
情况三:某个请求或脚本内存暴涨
如果内存曲线是平稳的,只在特定时刻出现尖峰然后掉下去,那多半是某段代码引起的。定位方法:
# 记录某个进程的内存变化 while true; do ps -o rss= -p $(pgrep -f 'php-fpm' | head -1) >> /tmp/mem_trace.log sleep 5 done
或者直接在应用层排查,比如 PHP 里用 memory_get_peak_usage、开启 slowlog 记录执行慢的请求。常见元凶包括:一次性读取超大 CSV、不加限制的 SELECT 全表查询、递归调用没有终止条件、图像处理没有控制分辨率。
五、cgroup 与容器环境的 OOM
如果你的服务跑在 Docker 里,OOM 有两层:容器级别的 cgroup OOM 和宿主机的全局 OOM。容器被限制 512MB 内存时,超出只会杀容器内的进程,宿主机通常不受影响。确认方式:
docker inspect --format '{{.State.OOMKilled}}' 容器名
docker inspect --format '{{.HostConfig.Memory}}' 容器名如果 OOMKilled 返回 true,说明容器因为超限被杀。这时候要看的不是宿主机 dmesg,而是调整容器的内存限制,或者排查应用为什么超出预期。cgroup 内的 OOM 记录也可以在宿主机上通过 dmesg 看到,只是会标明是哪个 cgroup 触发的。
容器里跑数据库、Java 应用时特别容易踩这个坑,因为 JVM 和 MySQL 的默认内存策略会假设自己独占整台机器,不会感知到 cgroup 限制。
六、swap 与 vm.swappiness
swap 不是万能的,但在小内存机器上确实是缓冲垫。查看和建议:
swapon --show free -h cat /proc/sys/vm/swappiness
vm.swappiness 默认值通常是 60,表示内核比较积极地使用 swap。对于有数据库的服务器,通常建议调低到 10 左右,因为数据库进程被换出到磁盘会导致查询性能断崖式下降,反而不如让它老实待在内存里:
sysctl -w vm.swappiness=10 echo "vm.swappiness=10" >> /etc/sysctl.conf
但要注意:swap 只能延缓 OOM,不能根治 OOM。当物理内存和 swap 都满时,OOM Killer 依然会出手。如果你的 swap 长期占用超过一半,说明这台机器的内存配置已经到了必须升级的临界点。
七、oom_score_adj:给关键进程上保险
内核给每个进程算一个 oom_score,分数越高越先被杀。你可以通过 oom_score_adj 手动干预,范围 -1000 到 1000。给 SSH 和 Nginx 这类不能死的进程设为 -1000,就几乎不会被 OOM Killer 选中:
# 临时设置 echo -1000 > /proc/$(pgrep -o sshd)/oom_score_adj echo -500 > /proc/$(pgrep -o nginx)/oom_score_adj # 让 MySQL 优先被杀(避免它拖着整个系统陪葬) echo 200 > /proc/$(pgrep -o mysqld)/oom_score_adj
正确的心智模型是:不是保护所有进程,而是决定牺牲顺序。把最不重要的、最容易重启的进程排在前面被杀,把系统和运维通道留在最后。一个实用的排序思路是:SSH 和 Nginx 保护起来 → 应用进程(PHP-FPM)排中间 → 无状态的临时任务排在前面。
要让设置永久生效,systemd 服务可以加一个参数:
[Service] OOMScoreAdjust=-1000
八、根治方案:从配置层面解决
排查到根因之后,治本的方法无非几条:
- 限制并发:把 PHP-FPM 的 pm.max_children 按「可用内存 ÷ 单进程峰值内存 × 0.8」来估算,宁可少一点也不要贪多。
- 裁剪功能:关闭不用的 MySQL 功能、降低缓冲池、限制 PHP memory_limit,让每个进程的内存上限可预测。
- 拆分服务:如果预算允许,把数据库拆到独立服务器,一台机器只干一件事,内存规划会简单得多。
- 加监控:用脚本每分钟记录 available 内存,低于阈值就告警。这样你能在 OOM 发生前收到提醒,而不是事后翻日志。
- 升级配置:如果做完上面所有优化仍然频繁 OOM,那就是真该加内存了。1GB 的机器跑 LNMP 加数据库,本来就非常勉强。
一个简单有效的监控脚本:
#!/bin/bash
AVAIL=$(free -m | awk '/Mem:/ {print $7}')
TOTAL=$(free -m | awk '/Mem:/ {print $2}')
PCT=$((AVAIL * 100 / TOTAL))
if [ "$PCT" -lt 10 ]; then
echo "内存告警:可用 ${AVAIL}MB / 总 ${TOTAL}MB,剩余 ${PCT}%" | \
mail -s "OOM 风险告警 $(hostname)" you@example.com
fi加入 crontab 每分钟执行一次,就能在内存真正耗尽前拿到预警。
九、经验总结
OOM 排查的核心链路其实很清晰:先看 dmesg 确认是不是 OOM,再看 anon-rss 锁定被杀进程,然后用 ps/top 按 RSS 排序找出内存大户,接着区分是配置超标还是代码暴涨,最后从并发限制、参数调优、服务拆分三个方向根治。
对个人站长来说,最需要记住的一点是:低配服务器的内存规划,本质是在做预算。系统、Nginx、PHP-FPM、MySQL 每个都要花钱,谁超支了谁就要付出代价。与其事后反复排查 OOM,不如一开始就把每个进程的内存上限算清楚、配保守一点。宁可牺牲一点并发上限,也不要让服务在流量高峰时被内核直接杀掉——前者只是慢,后者是不可用。
把这套排查思路变成习惯之后,你的服务器运维会从「出事救火」变成「提前预防」,这才是长期稳定运营一个网站的基础。