凌晨两点半,手机连续震动,监控告警弹出来一片红:站点开始大面积返回502。我爬起来打开终端,先看 systemctl status php-fpm,服务还活着,但ps里一个worker都没有,master的PID也变了。再看nginx错误日志,满屏都是 connect() failed (111: Connection refused)。第一反应是重启php-fpm,服务确实恢复了,可第二天同一时间又挂一次,这次连MySQL都差点跟着凉。最后翻内核日志,看到一行要命的记录:Out of memory: Kill process 12345 (php-fpm)。真凶找到了,是Linux内核的OOM Killer,跟PHP本身和Nginx都没关系。
这篇文章就把整条链路讲透:怎么确认是OOM Killer、它凭什么杀PHP-FPM、紧急情况下怎么临时止血、长期怎么调优根治,以及代码层面积累的内存问题怎么排查。不管你是用宝塔、小皮面板这类面板搭的环境,还是纯手工编译的LNMP,或者干脆把PHP丢进Docker容器里跑,只要内存一紧张,OOM Killer那刀都会砍下来,只是位置有点不一样。适合被502反复折腾的运维、后端开发,以及一个人管好几台小服务器的独立开发者。
1. 先看现象:PHP-FPM被OOM Killer干掉时,线上到底什么样
1.1 凌晨被502叫醒的现场
很多人的第一反应是:PHP-FPM挂掉就是进程崩溃了,也就是segfault。但这两者在日志层面的表现完全不同。如果PHP-FPM自身崩溃,在php-fpm.log里通常会有信号记录,比如这样的行:WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) after 123.456789 seconds。可如果进程是被OOM Killer杀的,php-fpm自己的日志里什么都看不到,因为内核直接发了SIGKILL,进程根本没机会写日志,连挣扎的余地都没有。
所以判断的关键就在内核日志。最常用的三条命令:
bash复制dmesg -T | grep -i "killed process"
journalctl -k --since "today 00:00" | grep -i "out of memory"
grep -i "out of memory" /var/log/messages /var/log/kern.log
日志里一般长这样:
code复制[71432.987654] Out of memory: Killed process 12345 (php-fpm) total-vm:1234567kB, anon-rss:345678kB, file-rss:0kB, shmem-rss:0kB
看到带有 Out of memory 和 Killed process 的记录,就能实锤:内核在系统内存耗尽后,选择消灭了php-fpm的某个worker,严重的时候master也跑不掉。
除了看日志,还有一个辅助证据:如果开了php-fpm status页,会发现活跃进程数在某个时刻直接断崖。因为OOM Killer通常不是只杀一个worker,而是连续杀好几个,甚至把master一起带走。这个时候nginx和后端的长连接全部失效,502就铺天盖地涌过来。
1.2 判断OOM Killer的3条铁证
我把实际排查中更可靠的判断依据整理一下,主要看三条:
- 内核日志里有明确的
Out of memory: Killed process记录,并且被杀的进程名是php-fpm或与PHP相关的worker。 - 同时段php-fpm.log里没有任何异常记录,进程消失得悄无声息,没有segfault之类的信号日志。
- 事发现场的内存快照显示可用内存已经见底,通常是几十MB甚至0,swap也基本耗尽。
如果三条都满足,那就可以完全排除PHP自身的问题,把方向转到内存管理和资源分配上。
这里要提醒一个容易踩的坑:如果你的PHP跑在Docker容器里并且给容器加了 --memory 限制,那么OOM日志不一定出现在宿主机的dmesg里。容器本身会触发cgroup级别的OOM,表现是容器里的主进程直接被杀,exit code可能是137,而宿主机的 /var/log/kern.log 里可能连痕迹都没有。这种场景更隐蔽,后面第3节我会单独讲排查方法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. OOM Killer的判定逻辑:它凭什么盯上PHP-FPM
2.1 内存是酒店房间,OOM是前台赶人
我习惯把物理内存比作一家酒店,每个进程就是一个住客,请求产生的页缓存(page cache)就像房间里临时堆放的行李。Linux的虚拟内存机制允许一定程度上的“超售”:系统可以承诺比实际房间更多的容量,只要不是所有住客同时来齐,就没问题,这就是overcommit。但是当物理内存加swap全部耗尽,连走廊都站满人的时候,内核就必须开始赶人了。赶谁?看谁住得最多、占得最凶。这个“赶人”的动作就是OOM Killer,而它用来决定“赶谁”的评分叫badness。
badness的核心因素很简单:进程占用常驻内存(RSS)越大,分数越高。默认情况下,一个大胖子占了半层楼,几个瘦子各占一个房间,那胖子基本跑不掉。这正好是PHP-FPM的困境:生产环境里php-fpm通常有几十个worker,每个worker因为框架加载、请求处理、数据库结果集缓存等原因,RSS轻松到几十MB甚至上百MB,加在一起就是机器上的内存大头。系统一紧张,PHP-FPM的得分自然名列前茅。
更关键的是,OOM Killer还支持人为干预评分。oom_score_adj 默认是0,但你把它设为-1000,相当于给进程发了一面免死金牌,内核直接跳过;设成正值则提高被杀优先级。systemd里有现成的 OOMScoreAdjust 参数,Docker启动时可以用 --oom-score-adj。这在实际运维中非常有用,比如给MySQL这类服务设置保护值,让它不容易成为OOM的牺牲品,后面第3节会细说具体做法。
2.2 为什么偏偏是PHP-FPM中刀
理解了评分机制,再看PHP-FPM中刀的原因就会很清楚。我把最常见的几类诱因列一下:
pm.max_children设置过大。网上很多通用教程一上来就是max_children=100、200甚至500,完全没考虑你的机器只有2G内存。结果PHP-FPM按配置拉起了几十个worker,每个worker 50MB,光PHP这边就吃掉2G多,其他服务一上来,内存立刻爆。- 老项目、老框架。比如一些还在维护的ThinkPHP 3.x项目,一条列表接口一次性读几千条记录塞进数组,高峰期多个请求一叠加,单个worker的内存峰值很容易冲到几百MB。如果并发再上来,OOM只是时间问题。
- 业务代码或第三方扩展里有内存泄漏或内存累积。比如循环里不断往大数组追加数据,或者用静态变量做了无上限的缓存。这种问题在php-fpm长驻进程里尤其阴险:一个worker的内存会随着处理的请求数缓慢爬升,直到某天深夜低峰期,某个大请求把它顶破。
- 同机服务太密集。小公司最典型:一台4G机器上跑Nginx、PHP-FPM、MySQL、Redis,还开了一堆队列worker。MySQL的
innodb_buffer_pool_size如果按网上的“性能优化”设成2G,Redis的maxmemory再给1G,那PHP能用的内存就所剩无几,OOM随时可能发生。这类OOM日志里,往往是MySQL占了大头,但最后被杀的却是php-fpm,因为内核评分时会综合考虑。
2.3 内核参数在幕后做了什么
应用层之外,内核参数也在影响OOM是否触发,三个参数值得关注。
vm.overcommit_memory 默认是0,意思是内核允许一定程度的超额分配虚拟内存。很多进程申请的内存远大于实际使用,内核赌它们不会同时用满。如果你把它改成1,内核就永远不做超额判断,程序申请多少就批多少,物理内存耗尽时OOM概率暴涨。改成2则是严格按照公式限制超额比例,同样不建议在生产环境乱调。
vm.swappiness 默认是60,代表内核在物理内存吃紧时有多积极地把匿名页换到swap。如果机器根本没配swap,这个参数的意义就变得很微妙——没有swap可换,内核只能先清页缓存,清完还不够就触发OOM。
vm.panic_on_oom 默认是0,触发OOM后只是杀进程;如果你改成1,内核会直接panic并重启系统。这个参数对高可用业务来说往往是灾难,不建议在生产上开启。
所以如果要从内核层面缓解OOM,第一步是确保swap存在,哪怕只有1G-2G,也能给OOM Killer一个缓冲。第二步是不要把 overcommit_memory 改成1,很多人为了“提高性能”去改,结果反而把系统更快推向OOM。
3. 实战排查流程:5分钟确认问题并临时止血
3.1 第一步:翻日志,拿证据
不管多急,先花一分钟确认是不是OOM。我在服务器上一般直接执行:
bash复制dmesg -T | grep -iE "out of memory|killed process" | tail -50
如果确认出现过 Killed process,再往上翻几行,能看到当时的内存分布情况,包括总内存、空闲内存、各进程的RSS。如果日志已经被轮转掉,就用journalctl:
bash复制journalctl -k --since "1 hour ago" | grep -iE "oom|killed process"
拿到日志后,重点看三样东西:被杀的是不是php-fpm、当时可用内存还剩多少、内存都被谁吃掉了。如果日志里显示MySQL占了多半内存,那基本可以断定,php-fpm是在给数据库的buffer pool背锅。
3.2 第二步:看当前内存和进程占用
排查时快速看一下当前状态:
bash复制free -h
ps aux --sort=-%mem | head -20
这两条命令能帮你判断“系统现在是不是又处于临界状态”。我一般还会把所有php-fpm worker的RSS列出来:
bash复制ps -ylC php-fpm --sort:rss
重点看每个worker的RSS平均值和最大值。如果某个worker的RSS比平均值高出一大截,说明这个worker处理过内存暴涨的请求,或者已经累积了一段时间。如果绝大多数worker都很高,那基本就是 max_children 设置过大,或者代码普遍吃内存。
3.3 第三步:临时救火,争取时间
确认是OOM后,最优先的事情是把服务先拉起来,让业务恢复。临时手段按优先级排列:
- 重启php-fpm。
systemctl restart php-fpm或service php-fpm restart,重启能立刻释放所有worker占用的内存。 - 调小
pm.max_children并平滑重载。比如原来100,临时改成30,然后reload。改完看free -h,内存会明显松一口气。注意这个只改参数用reload就行,不要restart,否则瞬间连接会闪断。 - 如果机器连swap都没有,赶紧加一个临时swap文件:
bash复制fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
这是一个应急动作,能撑一阵,但不能当长期方案。swap满了之后OOM照样会发生,而且频繁的换入换出会让PHP响应变得非常慢。
- 如果MySQL、Redis也在同一台机器,并且不是马上能拆走的,可以先下调它们的buffer pool或maxmemory。比如把MySQL的
innodb_buffer_pool_size从2G降到512M,重启后能立刻腾出不少物理内存。这是取舍,业务高峰期做这个操作要评估影响。 - 顺手把关键服务的
oom_score_adj设置一下,至少让数据库不再成为下一个被“赶出去”的进程。临时生效可以这么写:
bash复制echo -500 > /proc/$(pidof mysqld)/oom_score_adj
注意,直接写/proc的方法在进程重启后会失效,要长期生效得落到systemd或启动脚本里。
3.4 容器场景下的特殊处理
如果你用的是Docker,容器被 --memory 限制了内存,那OOM发生时宿主机dmesg往往没有记录。这时候第一步看容器的退出状态码,如果是137,也就是128+9,说明是SIGKILL杀的。再用 docker inspect 看 OOMKilled 字段:
bash复制docker inspect -f '{{.State.OOMKilled}}' 容器名
如果确实是OOM,说明不是宿主物理内存不够,而是容器的limit太小。处理思路和裸机一致:要么调大 --memory,要么减小 max_children,要么优化代码。很多人忽略这一点,结果在宿主机上加内存、调全局参数,容器还是照杀不误。
4. 根治方向:PHP-FPM参数计算与同机内存规划
4.1 先算清楚一个worker到底占多少内存
调优PHP-FPM的第一步,不是拍脑袋改参数,而是算清楚你的业务场景下单worker的平均RSS。我一般这么操作:
bash复制ps -ylC php-fpm --sort:rss
然后取平均值:
bash复制ps -ylC php-fpm --sort:rss | awk 'NR>1 {sum+=$8; n++} END {print "avg RSS:", sum/n/1024, "MB"}'
注意,不同ps版本里RSS的列位置可能不一样,建议先不带awk跑一次确认列号。而且不要只取平均值,还要看最大值,因为内存峰值决定了你在并发高峰时会不会爆。
举个例子:一台4G内存的机器,Nginx约0.1G,MySQL缓冲池1G,Redis 0.5G,系统预留0.4G,留给PHP的可用内存大约是2G。如果你测出单个php-fpm worker平均RSS是40MB、峰值80MB,那 max_children 的计算逻辑是:按均值算,2G/40MB=50个;按峰值保守算,最多25个。稳妥起见取中间偏低值,比如35个,并配合 pm.max_requests 定期回收。这样并发能力看起来少了,但至少不会把整机拖进OOM。
我见过太多人把 max_children 设为512、1024然后怪系统不稳定。你可以这样反驳他:你的worker每个占50MB,512个就是25G,你的机器有25G内存吗?没有就不要在网上抄这个数。
4.2 pm模式与关键参数怎么选
PHP-FPM的pm有三种模式:dynamic、static、ondemand。一句话说清区别:dynamic是动态维护一定数量的worker,static是一口气拉满固定数量,ondemand是有请求才拉起worker、空闲就回收。
pm = static:适合大流量且流量波动不大的场景。worker数量固定,避免频繁创建销毁的开销,代价是内存占用一直很高,哪怕半夜没有请求也占着。小内存机器慎用。pm = dynamic:最常用的模式。通过start_servers、min_spare_servers、max_spare_servers控制worker数量在合理范围内波动,兼顾内存和响应速度。pm = ondemand:适合低流量、内存紧张的场景。空闲worker会被回收,内存占用最小,但请求突增时创建worker会有短暂延迟。
我之前给小内存机器(1G-2G)上的PHP站点调优,会比较推荐 pm = ondemand,或者dynamic且 min_spare_servers 设小一点。如果业务本身是稳定的API接口,流量均匀,static也OK,但一定要严格计算 max_children。
除了 pm.max_children,还有一个必调参数是 pm.max_requests。它不限制单请求并发数,而是让worker在处理完这么多请求后主动退出、重新拉起,能有效防止worker内存逐渐累积。建议设成5000-10000,具体看请求耗时和业务体量。如果你的业务代码里已经有内存泄漏,这个参数就是保命绳;设太小会频繁创建worker,设太大又起不到回收作用。
再就是 request_terminate_timeout 和 max_execution_time,很多人会混。PHP的 max_execution_time 限制的是CPU执行时间,而 request_terminate_timeout 是PHP-FPM层面对整个请求的超时控制,包括等待、IO等。如果一个请求卡在外部IO上,max_execution_time 可能不触发,但 request_terminate_timeout 会帮你把它杀掉,避免worker长期占着内存。建议设置30-60s,根据业务接口响应时间定。
还有一个容易忽视的 memory_limit。默认128M够小站用,但如果你图省事把 memory_limit 调成2G甚至-1,单个worker内存峰值就可能飙到1G以上,几个并发请求一上来就OOM。除非确实需要处理大文件、大数据集,否则不建议超过512M。想通过调大 memory_limit 解决“脚本跑不动”,实际上是在给OOM埋雷。
4.3 同机服务的内存预算表
如果PHP、MySQL、Redis都在同一台机器,最理想的做法是给每个服务写一个内存预算,加总核对,再留10%-20%余量。一个典型2核4G机器的预算表长这样:
| 服务 | 内存占用预估 | 说明 |
|---|---|---|
| 系统+内核缓存 | 0.4G | 基本盘,没法砍 |
| Nginx | 0.1G | worker进程少,内存占用很低 |
| PHP-FPM | 1.0G | 按30个worker、均值40MB估算,留余地 |
| MySQL | 1.0G | innodb_buffer_pool_size控制在512M-1G |
| Redis | 0.5G | maxmemory限制在512M |
| 预留 | 0.7G | 应对突发请求和系统抖动 |
如果MySQL或Redis负载确实很高,建议优先考虑把它们拆出去,而不是继续在同一台机器上挤。小业务可以用上面的
