记录一次给 PHP-FPM 调参的过程。背景是个查询比较重的接口,高峰期 502 和超时交替出现。
初始状态
服务器:4 核 8G,PHP 8.1,Nginx 反代。
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
这是宝塔面板的默认值,看着就很小。max_children = 5 意味着最多同时处理 5 个请求,第 6 个就得排队。
问了下为什么是 5,回答是"默认的,没改过"。
第一步:算该设多少
pm.max_children 的算法是:
max_children = 可用内存 / 单个进程占用内存
先看单个进程占多少:
ps -ylC php-fpm --sort:rss | awk '{sum+=$8; n++} END {print "平均 " sum/n/1024 " MB, 共 " n " 个进程"}'
结果是平均 45MB。
理论上 8G / 45MB ≈ 180。但不能这么算,因为:
- MySQL 和 Redis 在一台机器上(或者不在,但网络连接、Nginx、系统本身都要内存)
- 进程占用是动态的,遇到大请求(比如导出)单个进程能涨到 200MB+
- 要留至少 20% 余量,不然上 swap 更慢
我当时留了 2G 给其它服务,可用 6G。按平均 45MB 算是 130 个,但考虑到峰值会涨,我定了 60。
这个数字是拍脑袋的吗? 不是完全拍脑袋,但确实没法精确。我的方法是设一个值,然后压测看内存曲线,不够就降。
第二步:pm 模式怎么选
三种模式:
; static:固定进程数,最简单可预测
pm = static
pm.max_children = 60
; dynamic:按负载伸缩
pm = dynamic
pm.max_children = 60
pm.start_servers = 15
pm.min_spare_servers = 10
pm.max_spare_servers = 30
; ondemand:有请求才起进程
pm = ondemand
pm.max_children = 60
pm.process_idle_timeout = 10s
我选了 dynamic,理由:
static内存占用固定,但低峰期浪费(60 个进程常驻 2.7G)ondemand省内存,但冷启动有延迟,高峰期第一个请求要等进程创建,不适合流量波动快的场景dynamic折中
dynamic 的参数含义:
start_servers:启动时创建多少min_spare_servers:空闲进程少于这个数就扩容max_spare_servers:空闲进程多于这个数就缩容
我设的是 start 15 / min 10 / max 30。经验是 min 和 max 之间不要差太多,不然频繁扩缩容本身有开销。
第三步:关键的几个超时参数
这几个参数配错了会出很诡异的问题:
; FPM 等待请求数据的时间
request_terminate_timeout = 60s
; FPM 内部读 FastCGI 请求的超时
; 注意:这是"读取请求"的超时,不是处理超时
; Nginx 侧
fastcgi_read_timeout 60s;
fastcgi_connect_timeout 10s;
fastcgi_send_timeout 60s;
关系必须理清:
request_terminate_timeout 是 FPM 强制杀掉工作进程的时间。设成 60s,意味着一个请求跑超过 60 秒,那个 php-fpm 进程直接被 kill。
这个参数要略大于 max_execution_time(PHP 脚本自己的超时)。如果反过来,PHP 还没来得及报"超时"就被 FPM 杀了,日志里只有一句 child exited on signal 9,什么线索都没有。
我设的是:
; php.ini
max_execution_time = 55
; php-fpm.conf
request_terminate_timeout = 60s
Nginx 的 fastcgi_read_timeout 要大于 FPM 的,不然 Nginx 先超时返回 504,但 FPM 那边还在跑,白白占着进程。
所以链条是:max_execution_time < request_terminate_timeout < fastcgi_read_timeout。我是 55 < 60 < 65。
第四步:状态页面
这个太有用了,但很多人不开:
pm.status_path = /fpm-status
ping.path = /fpm-ping
Nginx 里只允许内网访问:
location = /fpm-status {
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
include fastcgi_params;
fastcgi_pass unix:/tmp/php-cgi-81.sock;
}
访问 ?full 能看到每个进程在处理什么请求:
curl 'http://127.0.0.1/fpm-status?full'
输出里能看到:
pool: www
process manager: dynamic
active processes: 42
idle processes: 18
total processes: 60
max active processes: 60 ← 这个到顶了说明不够
max children reached: 137 ← 非 0 说明经常达到上限,请求排队了
slow requests: 3
max children reached 是关键指标。 只要不是 0,就说明并发不够。
我们改完之后这个数字还是每天有几万,说明 60 也不够。但一看 slow requests 只有 3 个,说明不是所有请求都慢。
第五步:找到真正的问题
调完参数之后 502 少了,但慢请求还是有。
用 ?full 状态页看到有几个进程长时间卡在同一个 URL 上——是个报表导出接口。
看了下代码:
// 一次查了 30 万行
$rows = DB::table('orders')->whereBetween('created_at', [$start, $end])->get();
// 然后在 PHP 里循环处理
foreach ($rows as $row) {
// 每次循环还查一次关联表!
$user = DB::table('users')->find($row->user_id);
$out[] = [...];
}
30 万行一次性加载进内存,然后在循环里查 30 万次数据库。这个接口要跑好几分钟。
这才是根因。 FPM 参数调优只是缓解,不是解决。
改法是:
- 用
chunkById分批处理,每批 1000 - 关联数据一次性查出来做 map
- 改成异步任务,导出完发通知
改完之后这个接口从"卡死 FPM 进程几分钟"变成"200ms 返回任务 ID"。502 彻底消失。
我学到的东西
FPM 参数调优的收益有上限。 它解决的是"并发不够",不解决"单个请求太慢"。如果有一个接口要跑 60 秒,你把 max_children 调到 200 也只是让 200 个进程一起卡住。
先看指标,再调参数。 状态页里的 max children reached、slow requests、listen queue 这三个数字基本能定位问题类型:
max children reached高 → 并发不够,调max_children或优化慢请求slow requests高 → 有慢接口,去查代码listen queue非 0 → 请求在排队,同上
别一次改太多参数。 我一开始把 max_children 从 5 直接改到 200,结果触发 OOM,MySQL 被 OOM killer 干掉了。改参数要一档一档调,每次观察。
listen 用 socket 还是 TCP? 同机的话 unix socket 略快,而且省掉 TCP 端口。但如果 Nginx 和 FPM 不在同一台机器,只能用 TCP。我们用 socket。
pm.max_requests 要设。 默认是 0(不重启)。PHP 进程跑久了可能有内存碎片或第三方扩展的小泄漏,设成 500~1000 让它定期重启,代价很小:
pm.max_requests = 800
最终的配置
pm = dynamic
pm.max_children = 60
pm.start_servers = 15
pm.min_spare_servers = 10
pm.max_spare_servers = 30
pm.max_requests = 800
request_terminate_timeout = 60s
pm.status_path = /fpm-status
pm.ping_path = /fpm-ping
; 慢日志,查慢请求
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout 强烈建议开。 它会把超过指定时间的请求的完整 PHP 调用栈写到日志里,直接告诉你慢在哪一行。这个比任何监控都好用。
配好之后,我看慢日志的第一行就找到了上面那个报表接口。