PHP-FPM进程池:五个参数与容量怎么算

先搞清进程池到底在管什么

很多站长调 PHP-FPM 的方式是「出问题就把 pm.max_children 调大」,调完发现要么内存被吃满触发 OOM Killer,要么调了完全没用照样 502。问题的根源在于没搞清楚进程池这个模型本身的资源账是怎么算的:一个 master 进程管着一群 worker,每个 worker 同时只能服务一个请求,池子里 worker 的个数就是这台机器能并发的 PHP 请求上限,而每个 worker 都要占一份独立的 PHP 内存。

所以进程池的本质是一个「用内存换并发」的杠杆。杠杆撬多大,取决于两个数字:单 worker 常驻内存,和这台机器的剩余内存。这两个数字没量准,参数怎么调都是猜。

这篇讲的是静态池与动态池的选择、五个参数之间的相互制约关系、慢请求如何把整个池子拖死,以及一套能落地的容量计算方法。

静态池与动态池:不是新旧之分,是流量形态之分

PHP-FPM 官方文档里给了三种池模式,实际站长能用好的主要是前两种。

pm = static:启动时就 fork 出 pm.max_children 个 worker,永不增减。优点是响应稳定,没有 fork 开销,突发流量来了立刻能接;缺点是空闲时也占着全部内存。

pm = dynamic:起始 pm.start_servers 个,空闲少了加到 pm.max_children,空闲多了降到 pm.min_spare_servers。好处是空闲省内存,坏处是扩容有延迟,而且 pm.max_children 必须预留足够,否则等于没配。

pm = ondemand:来一个请求起一个 worker,空闲 pm.process_idle_timeout 秒后销毁。适合访问极少的站点或常驻内存很大的后台接口,不适合主站,因为每次冷启动都要重新加载 opcache 和框架代码。

怎么选?给一个实操判据:如果站点日均 PV 在几千以上、白天流量起伏不大,用 static 并把 max_children 按峰值并发算死,最省心;如果流量集中在某几个小时、其余时间几乎没访问,用 dynamic,但 start_servers 别设太小(经验值是 min_spare 的 2 倍左右),否则每天早上第一批请求全在等 fork。

; /etc/php/8.1/fpm/pool.d/www.conf 片段
pm = dynamic
pm.max_children = 24
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 800

注意 dynamic 模式下五个参数有先后约束:min_spare_servers ≤ start_servers ≤ max_spare_servers ≤ max_children。写成 start_servers 大于 max_spare_servers,FPM 启动时会直接报配置错误然后不拉起来——这种错误在 systemctl 里只看到 failed,日志里才有那句 config error。

算 max_children:先量单 worker 内存,再算总量

容量计算只有一条公式:max_children ≈ (可用内存 × 安全系数) ÷ 单个 worker 的峰值内存。难的是那个分子和分母怎么取。

分母不能取 ps 里看到的 RSS 平均值,因为 PHP 的内存占用跟跑到的代码路径强相关:列表页可能只占 30MB,后台导出报表的请求能占到 200MB。用平均值算出来的池子,一旦同时来几个重请求就会被撑爆。正确做法是观察一段时间内 worker 的 RSS 高分位,或者直接按最大页面本身的内存峰值来定。

拿实测值的方法:

ps -o rss= -C php-fpm8.1 | sort -n | tail -5
ps -o rss= -C php-fpm8.1 | awk '{s+=$1; n++} END {print "avg", s/n/1024 "MB", "n", n}'

取最大值和平均值,如果最大值是平均值的三倍以上,说明站点里有一类请求特别重,这类请求应该单独拆一个池,别和普通页面抢同一批 worker。

分子这边要扣掉的不只是 PHP:MySQL/InnoDB buffer pool、Nginx 的连接缓冲、Redis、系统缓存预留。一个常见错误是拿 free 显示的 available 直接当可用内存,但 InnoDB 的 buffer pool 从操作系统视角看是会被回收的缓存,实际上它一旦热起来就会持续占用,MySQL 自己也绝不肯吐出来。稳妥的算法是把数据库和各类常驻服务的内存先减掉,剩下的才给 PHP。

给个具体例子:4 核 8G 的机器,MySQL 占 2.5G,Redis 占 300M,系统和其他服务留 1G,剩 4G 左右给 PHP;单 worker 峰值 80M,安全系数取 0.8,则 max_children = 4096 × 0.8 ÷ 80 ≈ 40。但 4 核机器上 40 个 worker 会互相抢 CPU,实际意义不大,这时候限制因素已经从内存变成 CPU 了,把 max_children 定在 25 到 30 更合理。

CPU 才是很多站点的真实瓶颈

PHP 是同步阻塞模型,一个 worker 在等 MySQL 返回的那段时间里不占用 CPU,但只要开始执行 PHP 代码,就会实打实占一个核。4 核机器如果同时跑 40 个 worker,CPU 上下文切换和 run queue 长度会迅速把延迟拉高,表现出来就是「池子没满但页面很慢」。

判断瓶颈在内存还是 CPU,看三个数:load average 是否长期高于核数、FPM 的 active processes 与 max_children 的关系、以及 CPU 的 iowait 比例。如果 active 长期贴着 max_children 而 load 只有 1 到 2,那是池子太小;如果 active 只有一半而 load 已经 8 以上,那是 CPU 打满了,加 worker 只会更慢,该做的是加缓存或者优化慢查询。

; 打开 FPM 状态页,用来观察 active/idle/listen queue
pm.status_path = /fpm-status
ping.path = /fpm-ping

状态页要限制只允许本机访问,在 Nginx 里用 allow 127.0.0.1; deny all; 圈起来,别把这个页面暴露到公网,它会把池子里正在执行的脚本路径全泄露出去。状态页里最重要的不是 active,而是 listen queue(等待被处理的连接数)和 max listen queue(历史峰值)。listen queue 一旦持续大于 0,说明所有 worker 都在忙,请求已经在排队了;max listen queue 曾经冲到几百,说明这台机器在某个时刻被打爆过,那个时间点值得回日志看。

慢请求如何拖死整个池子

进程池最脆弱的场景不是流量大,而是少数慢请求把所有 worker 长期占住。一个查了 30 秒的外部 API、一次没设超时的 curl、一个卡在 MySQL 锁等待上的查询,每个都占着一个 worker 直到脚本结束。20 个 worker 的池子只要来 20 个这样的请求,整站就 502 了,而 QPS 可能才 5。

三道防线都要上:

第一道是 FPM 自己的超时。request_terminate_timeout 是硬杀,到点直接给 worker 发信号;request_slowlog_timeout 配合 slowlog 只记录不杀,用来找出慢脚本。两个都配,前者兜底,后者用于诊断。

request_terminate_timeout = 60s
request_slowlog_timeout = 10s
slowlog = /var/log/php-fpm/$pool.slow.log

慢日志里会带完整调用栈,能直接看出是哪个函数卡住,比在应用层加计时器省事得多。

第二道是 Nginx 对上游的超时。fastcgi_read_timeout 要略大于 FPM 的 request_terminate_timeout,否则会出现 Nginx 先断开、FPM 还在跑的情况,用户看到 504 但 worker 迟迟不释放。顺序反过来最稳:Nginx 等 65 秒,FPM 60 秒自己先死。

第三道是应用层的外部调用超时。这是最容易被忽略的:PHP 里 file_get_contents 拉一个第三方接口,默认没有超时,对方不响应就一直挂着。curl 要显式设 CURLOPT_TIMEOUT 和 CURLOPT_CONNECTTIMEOUT,前者是总时长,后者是建连时长,两个都要设。

max_requests:用重启掩盖泄漏,也是一种正当手段

pm.max_requests 表示一个 worker 处理多少个请求后自动重启。设成 0 表示永不重启。这个参数经常被误解成「性能优化开关」,其实它是内存泄漏的兜底。

PHP 项目里总有一些扩展或者静态变量会慢慢攒内存,进程跑久了 RSS 一路往上涨,涨到最后就被 OOM Killer 干掉。设一个 max_requests,让 worker 定期自我了断,内存就回到基线。代价是每次重启都要重新加载一次框架,有轻微延迟抖动,但比 OOM 好得多。

取值经验:现代框架下一个请求的初始化成本在几毫秒到几十毫秒,500 到 1000 次重启一次,对用户基本无感,同时能把内存增长压住。如果你的 worker 内存曲线在一个周期内涨幅超过 50%,说明泄漏比较严重,可以降到 300;如果曲线很平,设 0 或者 5000 都行。

要注意的是,max_requests 只解决「增长」,不解决「峰值」。一个请求本身要 200M 内存,max_requests 设多少都没用。

拆池:让重请求和普通页面各走各的路

一个站点里往往混合着几类完全不同的负载:普通文章页、搜索结果页、后台导出、定时任务触发的回填脚本、给 App 用的 JSON 接口。把它们塞在同一个池里最坏的结果是:后台一次导出占满所有 worker,前台用户全部 502。

拆池的标准做法是按路径或者按 domain 分开,Nginx 里用不同的 fastcgi_pass 把请求导到不同的 socket:

location ^~ /admin/export/ {
    fastcgi_pass unix:/run/php/php8.1-fpm-export.sock;
}
location ~ \.php$ {
    fastcgi_pass unix:/run/php/php8.1-fpm-www.sock;
}

导出池的 max_children 可以设得很小(比如 4),超时设得很长(300 秒),内存给足;主站池则相反。这样两类负载互不干扰,一个池被打爆不会影响到另一个。拆池的代价是多一个 master 进程和一点内存开销,对 8G 以上的机器完全值得。

拆池之后要记得,opcache 在 FPM 里是每个池独立的,拆池意味着同样的代码要在两个池里各编译一份,内存占用会翻倍。所以只在负载确实差异很大的时候拆,不要为了「架构清晰」把一堆小站点各拆一个池。

上线与观察:改完参数必须看三个指标

改完 pool 配置不要直接 systemctl restart,先 nginx -t 之外还要 php-fpm -t 校验配置语法,然后 reload 而不是 restart——reload 是平滑的,老 worker 处理完手上的请求才退出,restart 会直接掐断正在处理的请求。

php-fpm8.1 -t
systemctl reload php8.1-fpm

改完之后的 24 小时,盯三个指标:

一是 active processes 与 max_children 的比值。长期高于 0.8 说明池子偏小,低于 0.3 说明池子过大在浪费内存。注意要区分高峰和低谷,用一天的曲线看,不要只看某个瞬间。

二是 listen queue 的最大值。只要出现过非零的排队峰值,就要结合当时的日志看是流量突增还是慢请求堆积。这两者的处置方向完全不同:前者加容量,后者修代码。

三是 worker 的 RSS 曲线。看它是稳定的锯齿(max_requests 在起作用),还是持续单调上涨(泄漏没被压住),还是有周期性尖峰(存在重请求)。

最后提醒一句:进程池参数的调整不要一次改多个维度,一次只动一个参数,观察一天再动下一个。同时改 max_children、max_requests 和拆池,出了问题根本不知道是哪个改坏的——这条经验适用于所有运维参数的调优。

Last modification:October 28th, 2026 at 01:25 pm

Leave a Comment