Nginx 与 PHP-FPM 性能调优实战:从压测到容量配置
Nginx 与 PHP-FPM 调优的关键不是照抄参数表,而是先建立可复现的压测基线,用实测内存算出 pm.max_children,再优先处理日志缓冲、FastCGI 缓冲与 OPcache,最后用 slowlog 和状态页形成观测闭环。
- 先建立压测基线,看 p95/p99 与错误率,别只看平均值
- access_log 加 buffer、调整连接数与 FastCGI 缓冲,收益最直接
- pm.max_children 按可用内存除以实测单进程 RSS,宁可偏小也不要 swap
- opcache.validate_timestamps=0 后必须把 reload FPM 写进发布流程
- 用 slowlog、FPM 状态页与 stub_status 构成观测闭环,每次只改一个变量
为什么按参数表抄一遍,性能往往没有变化#
大多数 Nginx + PHP-FPM 调优文章会给出一张“最佳参数表”,但直接抄下来的结果常常是:QPS(Queries Per Second,每秒请求数) 没涨,CPU 反而更高,或者压测时偶发 502。原因并不复杂——这些参数互相耦合,而且强依赖机器规格、PHP 进程的真实内存占用,以及业务到底卡在 CPU 还是卡在 IO。你改的是参数,瓶颈往往在队列和内存上。
下面这条路径是实战中比较可靠的:先建立可复现的压测基线,再改配置;一次只动一个变量;用实测数据决定 pm.max_children 这类关键数字;最后把观测手段固化下来,让下一次调优有据可依。示例基于 PHP 8.3 与 Nginx 1.26,其他版本思路一致。
第一步:建立可复现的压测基线#
没有基线的调优等于赌博。开始前先把环境“冻结”:压测机与被测机分开,关闭 Nginx 的 access_log 或改为带缓冲写入,确认 OPcache 已生效,并且在正式计时前先跑一轮预热请求,避免把首次编译的时间算进结果。
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
# 并发梯度压测,观察延迟分布而不是只看平均值
for c in 50 100 200 400; do
echo "=== concurrency=$c ==="
wrk -t4 -c$c -d60s --latency http://127.0.0.1/api/get
done每组数据至少记录五项:吞吐(Requests/sec)、p95 与 p99(99 分位延迟) 延迟、错误数、Nginx worker 的 CPU 使用率、以及 FPM 进程的常驻内存总量。平均值会骗人,一个 3 秒的尾延迟就能让平均值看起来还行,但用户已经流失了。
如果你暂时没有压测目标,可以先用 POST 示例接口 在测试环境模拟写请求,用 IP 查询接口 模拟读请求,先把工具链跑通,再换成自己的业务接口。
Nginx 侧:连接数、缓冲与日志开销#
进程与连接:别把两个数字混为一谈
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 8192;
multi_accept on;
use epoll;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 30s;
keepalive_requests 1000;
client_header_timeout 10s;
client_body_timeout 10s;
client_max_body_size 8m;
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript;
access_log /var/log/nginx/access.log combined buffer=32k flush=5s;
error_log /var/log/nginx/error.log warn;
}几个容易踩的点:worker_connections 是每个 worker 的上限,而且一条反向代理请求会同时占用“客户端连接”和“上游连接”两个槽位,所以实际可服务的并发大约是总量的一半;worker_rlimit_nofile 必须大于 worker_connections,否则会出现 accept 失败的报错;access_log 加 buffer=32k 在高并发下能明显减少磁盘写入,这是最省事的一次优化。
FastCGI 缓冲:502 的真正来源之一
upstream php_backend {
server unix:/run/php/php8.3-fpm.sock;
keepalive 64;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php_backend;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_keep_conn on;
fastcgi_connect_timeout 2s;
fastcgi_send_timeout 30s;
fastcgi_read_timeout 30s;
fastcgi_buffer_size 32k;
fastcgi_buffers 16 16k;
fastcgi_busy_buffers_size 64k;
}如果你的响应头里塞了大量 Cookie 或较长的缓存控制字段,默认的 4k 响应头缓冲会被撑爆,于是出现“upstream sent too big header”并返回 502——很多人第一反应是去加 pm.max_children,其实完全改错了地方。另外,upstream 里写 keepalive 之后必须配合 fastcgi_keep_conn on,否则连接复用不会生效。
PHP-FPM 侧:进程模型比单个参数更重要#
pm.max_children 必须算出来,不能猜
公式很简单:可用于 FPM 的内存 ÷ 单个 PHP 进程的平均常驻内存。难点在第二个数字,必须实测。
# 统计 FPM 子进程数量与平均 RSS(进程名按实际情况调整)
ps -o rss= -C php-fpm8.3 | awk '{s+=$1; n++} END {printf "procs=%d avg=%.1fMB total=%.1fMB\n", n, s/n/1024, s/1024/1024}'假设 8GB 的机器,留给 FPM 6GB,单进程平均 55MB,那么上限大约 110。但这里有个反直觉的事实:pm.max_children 设得过大,比设小了更糟。一旦超出物理内存,系统进入 swap,所有请求一起变慢,表现为 CPU 不高但延迟飙升。宁可让 FPM 在极端流量下排队甚至短暂拒绝,也不要让整台机器 swap。
[www]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
listen.backlog = 4096
pm = dynamic
pm.max_children = 96
pm.start_servers = 16
pm.min_spare_servers = 12
pm.max_spare_servers = 32
pm.max_requests = 800
request_terminate_timeout = 30s
request_slowlog_timeout = 3s
slowlog = /var/log/php-fpm/slow.log
pm.status_path = /fpm-status
进程模型的选择:流量长期高位且稳定,用 static,省掉 fork 开销,延迟更平稳;有明显波峰波谷,用 dynamic;低频内部服务用 ondemand。pm.max_requests 是防御性参数,用来隔离第三方库的轻微内存泄漏,不要设得太小,否则频繁重建进程反而增加开销。
慢日志与状态页:把问题定位到函数
location = /fpm-status {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_NAME /fpm-status;
allow 127.0.0.1;
deny all;
}
location = /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}FPM 状态页里 listen queue 持续大于 0,说明子进程不够或请求变慢了;Nginx 的 stub_status 里 Waiting 偏高在开启 keepalive 时是正常的,不必惊慌。真正值得追的是 slowlog——它会直接打印出卡住的 PHP 调用栈,比任何猜测都有价值。
通信层与 OPcache:两个被低估的开关#
Nginx 与 FPM 在同一台机器上时,优先用 Unix Socket,并注意 backlog 是否溢出:
ss -xl | grep php # Send-Q 列可反映监听队列排队情况
ss -lnt | head跨机部署才用 TCP,并显式开启 listen = 0.0.0.0:9000 配合内网防火墙,不要暴露在公网。OPcache 的收益通常比调 FPM 参数更大:
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.jit=tracing
opcache.jit_buffer_size=64M
realpath_cache_size=4096k
realpath_cache_ttl=600两个细节值得强调:validate_timestamps=0 意味着发布代码后必须 reload FPM,否则改动不生效,请把这一步写进发布脚本;save_comments=1 不能关,关掉后依赖注解的框架(Doctrine、Symfony 路由等)会直接报错。至于 JIT(Just-In-Time,即时编译),在典型的 Web 请求场景里收益有限,更多是给 CPU 密集型计算带来提升,建议开启后用自己的基线压测验证,别指望它把 QPS(Queries Per Second,每秒请求数) 翻倍。
用观测数据闭环,而不是靠感觉#
| 现象 | 常见的错误归因 | 更可能的原因 |
|---|---|---|
| 突发 502 | FPM 进程数不够 | 响应头超过 FastCGI 缓冲,或 backlog 溢出 |
| 504 增多 | Nginx 性能问题 | 业务代码慢查询、外部接口阻塞,应看 slowlog |
| CPU 打满但 QPS(Queries Per Second,每秒请求数) 很低 | PHP 版本太老 | OPcache 未命中、磁盘 IO 等待、频繁创建连接 |
| 内存高但吞吐不变 | 需要更多进程 | max_children 过大导致 swap |
把上述指标接入 Prometheus 或至少写进每日巡检脚本,每次调整只改一项并立刻复测。没有回归验证的调优,本质上是把风险留给下一次流量高峰。
AI 在这个流程里能做什么,不能做什么#
2026 年,Amazon 在其工程师角色指南更新中提到一个观点:AI 并不定义开发者这个角色,真正被看重的是把工具、判断力和领域知识结合起来的能力。放到性能调优上,这句话非常具体。
可以让 AI 帮你做的事:把 slowlog 按调用栈聚合后再让它解读、根据你的接口语义生成 k6 压测脚本、审阅两份 nginx.conf 的差异、解释 ss 输出里某个字段的含义。不该让 AI 替你做的事:直接给你一个 pm.max_children 的数字。任何没有结合你机器实际内存与进程 RSS 的容量建议,都只是巧合。同理,不要让模型直接改生产配置,正确姿势是产出 diff、人工 review、灰度发布、观察指标再全量。
如果你的服务包含图片型接口(例如 随机动漫壁纸接口 这类返回图片地址的场景),那么 CDN(Content Delivery Network,内容分发网络) 与缓存头的收益往往远大于 FPM 调参——先确认瓶颈在哪一层,再决定投入方向,这也是判断力的一部分。
总结#
把整套流程压缩成一份清单:
- 先压测出基线,记录 p95、p99(99 分位延迟) 与错误率,而不是只看平均值;
- Nginx 侧优先做日志缓冲、连接数与 FastCGI 缓冲,这三项收益最直接;
- pm.max_children 用内存实测值计算,宁可略小也不要让机器 swap;
- 开启 OPcache 并处理发布流程中的 reload,JIT 用数据验证是否值得;
- 用 slowlog、FPM 状态页、stub_status 形成观测闭环,每次只改一个变量。
调优的本质不是堆参数,而是把系统里看不见的队列和内存变成可观测的数字。做完这些之后,如果你需要一个稳定的外部接口来做链路回归或补充业务能力,可以在 API.PLUS 注册获取免费 API Key,先跑通 IP 查询接口 与 POST 示例接口,更多接入方式可浏览 API 文章中心,遇到问题也可以通过 技术支持 反馈。