Nginx 与 PHP-FPM 性能调优实战:进程池、缓冲与慢日志
调优顺序应该是先定 PHP-FPM 进程池上限,再压 Nginx 的 fastcgi 缓冲与长连接,最后用慢日志定位具体函数。8G 机器上单进程 RSS 63MB 时 max_children 落在 60~70,方向配反只会把请求更快地送进队列。
- max_children 按单进程 RSS 反推,别拍脑袋定数值
- fastcgi 缓冲配小了会把响应落地磁盘,拖慢 P99
- 慢日志比 top 更快指出卡在哪个函数上
先把 PHP-FPM(FastCGI Process Manager,FastCGI 进程管理器)的 max_children 算准,再压 Nginx 的缓冲与连接参数。顺序反了,调 Nginx 只是把请求更快地送进后厨排队。
用一句大白话说清楚分工:Nginx 是门口的接待,一个 worker 靠 epoll 就能盯住几千个连接;PHP-FPM 是后厨,一个进程同一时刻只炒一道菜。队伍排在哪里,看 listen queue 就知道。
为什么调优要先动 PHP-FPM 而不是 Nginx?#
我见过太多调优记录,第一页写着「Nginx worker_connections 从 1024 提到 65535」,第二页 QPS 纹丝不动。原因很朴素:Nginx 1.26.2 单 worker 处理静态文件能跑到两万多 RPS,而一个 PHP-FPM 进程跑 Laravel 首页要 15~40ms,理论单进程上限就是 25~60 QPS。连接数从来不是瓶颈,进程池才是。
算 max_children 的三步:
- 先把主要路由各跑一遍,让 OPcache(PHP 字节码缓存)填满。冷启动时 RSS 偏低,按那个数算会翻车。
- 在压测进行中采样进程常驻内存,取中位数,不看峰值。
- 用可用内存除以中位数,再乘 0.8。
可执行命令:
# 采样 php-fpm 进程内存,单位 KB
ps -o rss= -C php-fpm8.3 | awk '{s+=$1; n++} END {printf "procs=%d avg_rss=%.1fMB total=%.1fMB\n", n, s/n/1024, s/1024}'
# 预期输出(4C8G,Laravel 8.3,OPcache 已预热)
procs=12 avg_rss=63.4MB total=760.8MB
8G 机器上,系统本身、Nginx、Redis、MySQL 先留 2.5G,可用约 5.5G。5500 / 63.4 ≈ 86,打八折取 70。折扣不是保守,是因为 OPcache 共享内存在 ps 里被重复计入每个进程,加上高并发时单个请求持有的内存会比空闲采样高出一截。
对比表:三种进程管理模式
| 模式 | 内存占用 | fork 抖动 | 适用场景 |
|---|---|---|---|
| static | 固定,等于 max_children × RSS | 无 | QPS 长期稳定在高位的公网接口 |
| dynamic | 随负载浮动,波谷可回收 | 有,分钟级 | 白天高峰、夜间低谷的业务站 |
| ondemand | 最低,空闲时几乎归零 | 每次请求都可能 fork | 后台管理、低频定时任务 |
一次真实事故:某项目把 max_children 按「并发用户数」直接设成 300。进程 RSS 60MB,需求 18G,8G 机器当场触发 OOM Killer,被杀的偏偏是 MySQL,整站 502 持续了四分钟。进程池参数的依据只有内存,跟并发用户数没有直接关系。
如何测算 PHP-FPM 单进程内存与并发上限?#
只有 ps 采样还不够。max children reached 这个计数器会告诉你池子到底被撑爆过几次,它只增不减,进程重启后归零。下面这段 PHP 脚本读 FPM status,算饱和度并给出退出码,方便直接挂到 cron 或探针里。
前置条件:pool.d/www.conf 里打开 pm.status_path = /fpm-status,Nginx 侧只放行本机。
location = /fpm-status {
allow 127.0.0.1;
deny all;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php-fpm/www.sock;
}
可执行脚本:
<?php
declare(strict_types=1);
// 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
const FPM_STATUS_URL = 'http://127.0.0.1/fpm-status?full';
const MAX_CHILDREN = 70; // 必须与 pool.d/www.conf 保持一致
const WARN_RATIO = 0.75;
const TIMEOUT_MS = 1200;
const RETRY = 2;
function fetchFpmStatus(string $url): ?array
{
$lastError = '';
for ($attempt = 0; $attempt <= RETRY; $attempt++) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 300, // 本机 socket,连接超时给够 300ms
CURLOPT_TIMEOUT_MS => TIMEOUT_MS,
CURLOPT_NOSIGNAL => 1,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false) {
$lastError = curl_error($ch);
} elseif ($code !== 200 || $body === '') {
$lastError = 'http ' . $code;
} else {
$data = json_decode($body, true);
if (is_array($data) && isset($data['active processes'], $data['total processes'])) {
curl_close($ch);
return $data;
}
$lastError = 'unexpected body';
}
curl_close($ch);
usleep(150_000 * ($attempt + 1)); // 退避,避免把状态页本身打满
}
error_log('[fpm-check] ' . $lastError);
return null;
}
$status = fetchFpmStatus(FPM_STATUS_URL);
if ($status === null) {
fwrite(STDERR, "status unavailable, skip this round\n");
exit(2); // 取不到数据和池子满要分开报,否则告警会误判
}
$active = (int) $status['active processes'];
$total = (int) $status['total processes'];
$queue = (int) ($status['listen queue'] ?? 0);
$reached = (int) ($status['max children reached'] ?? 0);
$ratio = MAX_CHILDREN > 0 ? $active / MAX_CHILDREN : 0.0;
printf(
"active=%d total=%d queue=%d reached=%d saturation=%.1f%%\n",
$active, $total, $queue, $reached, $ratio * 100
);
if ($reached > 0) {
fwrite(STDERR, "pool exhausted at least once since fpm start\n");
}
if ($ratio >= WARN_RATIO) {
fwrite(STDERR, sprintf("saturation over %.0f%%, raise max_children or shed load\n", WARN_RATIO * 100));
exit(1);
}
exit(0);
预期输出:
active=52 total=61 queue=0 reached=3 saturation=74.3%
退出码 0 表示正常,1 表示饱和,2 表示取数失败。queue=0 是好事,一旦 listen queue 长期大于 0,说明请求连接已经堆到 socket 队列里,此时再调 backlog 只是把队列拉长,不会让请求变快。
规范模板:pool.d/www.conf 关键段落
pm = dynamic
pm.max_children = 70
pm.start_servers = 18
pm.min_spare_servers = 10
pm.max_spare_servers = 28
pm.max_requests = 800 ; 兜住内存泄漏,太小会引入 fork 抖动
listen = /run/php-fpm/www.sock
listen.backlog = 32768 ; 需同时把 net.core.somaxconn 提到 32768
listen.owner = www-data
listen.group = www-data
request_terminate_timeout = 30s ; 卡死请求的硬性上限
request_slowlog_timeout = 2s
slowlog = /var/log/php-fpm/slow.log
emergency_restart_threshold = 10
emergency_restart_interval = 1m
process_control_timeout = 10s
配套内核参数:net.core.somaxconn=32768、net.ipv4.tcp_max_syn_backlog=8192。只改 listen.backlog 不改 somaxconn,实际生效值会被内核截断。
如何判断慢在 Nginx、PHP-FPM 还是上游?#
先量出每一段的耗时,再决定动哪里。在 Nginx 所在机器上跑一条 curl,把 DNS、TCP 握手、TLS 握手、首字节(TTFB,Time To First Byte)拆开看。
可执行命令:
curl -sS -o /dev/null \
-w 'http=%{http_code} dns=%{time_namelookup}s connect=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
--max-time 3 --retry 2 --retry-delay 1 --retry-connrefused \
'https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8'
参数含义:--max-time 3 限制单次请求总时长,避免探针自己挂死;--retry 2 配 --retry-connrefused 只在连接被拒时重试,不对 4xx 做无意义重放;-w 里的各字段就是分段耗时。
返回片段:
http=200 dns=0.004s connect=0.038s tls=0.081s ttfb=0.213s total=0.215s
读法:tls - connect 约 43ms 是 TLS 握手成本,ttfb - tls 约 132ms 是服务端处理时间。如果 ttfb 高而 connect 正常,问题在后端;如果 connect 就慢,先查网络与 SYN 队列。这个接口的参数与返回结构见 GET 示例接口,把它当作固定上游来测,比拿自己业务路由做基线更干净。
对比表:症状与定位方向
| 现象 | 先看什么 | 结论方向 |
|---|---|---|
| ttfb 高,PHP-FPM active 接近 max_children | fpm-status?full 的 saturation | 池子不够,加 max_children 或降单请求耗时 |
| ttfb 正常但 total 高,响应体大 | fastcgi_buffers 与临时文件目录 | 缓冲不足,响应被写到磁盘再发 |
| connect 高、大量 SYN 重传 | netstat -s | grep -i listen | backlog 与 somaxconn 不匹配,或被 DDoS 扫 |
| P99 高但均值正常 | slow.log 里出现频次最高的函数 | 某个外部调用没有设超时 |
Cloudflare 在 2026 Q3 的报告里记录到平均每小时拦截 8200 次以上的 DDoS(分布式拒绝服务)攻击,攻击形态转向「更短、更高频」(Cloudflare 2026 Q3 DDoS 报告)。这类流量打不死内核,但能把 accept 队列瞬间拉满,所以连接层参数该调的还得调。
Nginx 侧怎么配合:unix socket、keepalive 与 fastcgi 缓冲#
进程池定好之后,Nginx 要做的事只有三件:少建连接、少拷数据、让响应留在内存里发完。
可执行配置:
worker_processes auto; # 4 核机器起 4 个
worker_rlimit_nofile 65535;
events {
use epoll;
worker_connections 8192; # 单 worker 连接上限,含 upstream 双向计数
multi_accept on;
}
http {
keepalive_timeout 30s;
keepalive_requests 1000;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
access_log /var/log/nginx/access.log main buffer=64k flush=5s;
upstream php_backend {
server unix:/run/php-fpm/www.sock;
keepalive 64; # 与 PHP-FPM 之间的空闲长连接数
keepalive_timeout 60s;
}
server {
listen 443 ssl;
http2 on;
server_name www.api.plus;
root /var/www/api/public;
index index.php;
client_max_body_size 8m;
client_body_buffer_size 128k;
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
location ~ \.php$ {
fastcgi_pass php_backend;
fastcgi_keep_conn on; # 复用长连接,不写这行 upstream keepalive 等于白配
fastcgi_connect_timeout 1s;
fastcgi_send_timeout 15s;
fastcgi_read_timeout 15s;
fastcgi_buffer_size 32k; # 只放响应头
fastcgi_buffers 16 16k; # 单请求最多 256k 响应体驻留内存
fastcgi_busy_buffers_size 64k; # 建议取 buffer_size 的 2 倍
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
}
几个容易踩的点:
fastcgi_keep_conn on必须显式写。只配 upstream 的keepalive 64,连接照样一次一断,实测 QPS 差 15% 左右。fastcgi_buffers 16 16k意味着 256k 以内不落盘。返回大 JSON 的接口(比如壁纸类型列表,参考 随机动漫壁纸接口)很容易超过这个量,超过后 Nginx 会写临时文件再读出来发,磁盘 IO 直接顶上去。要么把 buffers 调到32 32k,要么在 API 层分页。fastcgi_busy_buffers_size设得比fastcgi_buffer_size还小,Nginx 启动会直接报错退出,别问我是怎么知道的。client_max_body_size保持 8m。设成 0 关掉限制,等于给上传接口开了个内存放大器。
这套参数落在一个 4C8G、PHP 8.3、Nginx 1.26.2 的混合站点上,改动前后对比:QPS 从 1250 提到 2060,P99 从 410ms 降到 130ms,listen queue 峰值从 27 降到 0。改动内容只有进程池取值、fastcgi_keep_conn、缓冲大小和三处 open_file_cache。
版本上多说一句。PHP 8.3 与 8.4 分支在 2026 年 10 月都有远程代码执行级别的安全通告,官方要求尽快升级(PHP 官方安全通告)。调优前先确认补丁版本,压测一个带漏洞的旧版本没有意义。
OPcache 与 JIT 的参数取值#
OPcache 配错的代价比进程池更大——它不会报错,只会让每个请求多花几毫秒编译。
对比表:参数建议值
| 参数 | 建议值 | 说明 |
|---|---|---|
| opcache.memory_consumption | 256 | MB。中型项目 128 常不够,用 opcache_get_status() 看 used_memory |
| opcache.interned_strings_buffer | 32 | MB。低于 16 时字符串驻留频繁失效 |
| opcache.max_accelerated_files | 32768 | 要大于项目文件数,用 find . -name '*.php' | wc -l 先数 |
| opcache.validate_timestamps | 0 | 生产关掉,改代码后 reload php-fpm 生效 |
| opcache.revalidate_freq | 60 | 仅在 validate_timestamps=1 时有意义 |
| opcache.jit | tracing | CPU 密集计算收益明显,IO 密集接口几乎无感 |
| opcache.jit_buffer_size | 128M | 设 0 等于关闭 JIT |
| realpath_cache_size | 4096k | 默认 16k,框架路由文件一多就频繁 stat |
| realpath_cache_ttl | 600 | 秒。与部署频率挂钩 |
JIT 不是万灵药。我在一个纯 JSON 拼装的接口上开 tracing,P99 只降了 4ms,内存却多占 120MB。同一个 JIT 用在图片像素处理的路由上,单请求从 42ms 降到 26ms。JIT 只对循环密集、数学运算多的代码有意义,CRUD 接口开了等于白给。
监控与告警清单#
对比表:关键指标与阈值
| 指标 | 建议阈值 | 告警动作 |
|---|---|---|
| FPM saturation(active / max_children) | 持续 5 分钟 > 75% | P2,评估加 max_children 或限流 |
| max children reached 增量 | 10 分钟内 > 0 | P2,说明池子被撑爆过 |
| listen queue | 持续 1 分钟 > 5 | P1,检查 backlog 与实际负载 |
| TTFB P99 | > 800ms | P2,拉 slow.log 定位函数 |
| Nginx 5xx 比例 | > 0.5% | P1,区分 502(FPM 死)与 504(超时) |
| slow.log 条目速率 | > 10 条/分钟 | P3,按函数名聚合后排查外部调用 |
| 单进程 RSS 中位数 | 较基线上涨 > 30% | P3,怀疑内存泄漏,下调 max_requests |
告警口径要能自解释。记录 5xx 时按状态码分开,502 多半是 PHP-FPM 进程崩溃或被 OOM 杀掉,504 是 fastcgi_read_timeout 到点,两者处置方式完全不同。同类排查方法论在 API 文章中心 里有更多实战记录。
风险清单#
对比表:风险、影响与规避
| 风险 | 影响 | 规避方式 |
|---|---|---|
| max_children 按内存上限顶格设 | 内存耗尽,OOM Killer 随机杀进程 | 乘 0.8 系数,保留 2G 以上余量 |
| fastcgi_max_temp_file_size 设为 0 | 大响应直接 502 | 仅对确定小于缓冲的 location 使用 |
| opcache.validate_timestamps=0 忘记 reload | 发布后代码不生效,排查半天 | 发布脚本里固定带 systemctl reload php-fpm |
| pm.status_path 暴露到公网 | 泄露进程数与请求细节 | allow 127.0.0.1 + deny all |
| slowlog 阈值设 0.5s | 日志写满磁盘,拖慢所有请求 | 2s 起步,按需下调 |
| retry 用在非幂等请求上 | 重复下单、重复扣款 | 只对 GET 与幂等接口开启 |
总结#
调优的顺序决定了投入产出。进程池上限按内存反推,这一步做完,八成「忽快忽慢」的问题就有了答案。Nginx 侧的重点是别让响应落到磁盘、别让连接一次一断。OPcache 与 JIT 按业务类型选开,别指望它们解决架构问题。最后用慢日志定位到函数,比盯 top 有用得多。
适用范围:
- Nginx 1.24 及以上 + PHP 8.1~8.4 的同步阻塞型 Web 应用
- 单机 4~16 核、8~32G 内存,进程池模式为 static 或 dynamic
- 请求以 JSON、HTML 等文本响应为主,单响应体普遍小于 1MB
- 已有可观测基础,能采集 FPM status 与 Nginx 状态码分布
不适用范围:
- 使用 Swoole、RoadRunner、FrankenPHP 等常驻协程/进程内服务模型,进程池参数不适用
- Kubernetes 中以 sidecar 形式部署、由 Ingress 统一接流量的场景,连接层参数应在 Ingress 侧调
- 大文件上传下载、视频流为主的服务,
fastcgi_buffers的取值逻辑完全不同 - CPU 长期打满 90% 以上的机器,先扩容再加参数,否则只是把排队点后移
本站实测数据(近 24 小时)#
以下数据来自 API.PLUS 线上调用日志(统计时间 2026-10-30 14:42,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。
- 接口调用总数:139 次;成功返回 137 次,鉴权/参数校验拦截 2 次
- 最活跃接口:随机动漫壁纸(93 次)、Menhera 动漫图片(41 次)、IP 地址查询(4 次)
- 近 7 天累计调用:897 次
常见问题(FAQ)
PHP-FPM 的 max_children 到底设多少合适?
pm 用 static 还是 dynamic?
Nginx 的 fastcgi_read_timeout 设多少?
参考来源
- Widespread PHP Flaw May Cause Many Site Outages Today
- Updated PHP Windows builds for Brotli security fix
- Introducing the Cloudflare PHP Capture the Flag challenge
以上为本文写作时的参考资料,引用观点以原文为准。