用户登录

技术文章

外部链接将在新标签页打开。

Nginx 与 PHP-FPM 性能调优实战:进程池、缓冲与慢日志

一句话回答

调优顺序应该是先定 PHP-FPM 进程池上限,再压 Nginx 的 fastcgi 缓冲与长连接,最后用慢日志定位具体函数。8G 机器上单进程 RSS 63MB 时 max_children 落在 60~70,方向配反只会把请求更快地送进队列。

核心要点 TL;DR
  • 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 的三步:

  1. 先把主要路由各跑一遍,让 OPcache(PHP 字节码缓存)填满。冷启动时 RSS 偏低,按那个数算会翻车。
  2. 在压测进行中采样进程常驻内存,取中位数,不看峰值。
  3. 用可用内存除以中位数,再乘 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_childrenfpm-status?full 的 saturation池子不够,加 max_children 或降单请求耗时
ttfb 正常但 total 高,响应体大fastcgi_buffers 与临时文件目录缓冲不足,响应被写到磁盘再发
connect 高、大量 SYN 重传netstat -s | grep -i listenbacklog 与 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_consumption256MB。中型项目 128 常不够,用 opcache_get_status() 看 used_memory
opcache.interned_strings_buffer32MB。低于 16 时字符串驻留频繁失效
opcache.max_accelerated_files32768要大于项目文件数,用 find . -name '*.php' | wc -l 先数
opcache.validate_timestamps0生产关掉,改代码后 reload php-fpm 生效
opcache.revalidate_freq60仅在 validate_timestamps=1 时有意义
opcache.jittracingCPU 密集计算收益明显,IO 密集接口几乎无感
opcache.jit_buffer_size128M设 0 等于关闭 JIT
realpath_cache_size4096k默认 16k,框架路由文件一多就频繁 stat
realpath_cache_ttl600秒。与部署频率挂钩

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 分钟内 > 0P2,说明池子被撑爆过
listen queue持续 1 分钟 > 5P1,检查 backlog 与实际负载
TTFB P99> 800msP2,拉 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,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。

常见问题(FAQ)

PHP-FPM 的 max_children 到底设多少合适?
用可用内存除以压测期间单进程常驻内存(RSS)的中位数,再乘 0.8 的安全系数。8G 内存、单进程 63MB 的站点通常落在 60~70;设到 100 以上必须重新核对私有内存与共享内存的实际占比。
pm 用 static 还是 dynamic?
请求量长期稳定在高位选 static,省掉 fork 抖动;有明显波峰波谷选 dynamic,把 pm.start_servers 设成峰值的四分之一。ondemand 只适合低频后台服务,公网接口别用。
Nginx 的 fastcgi_read_timeout 设多少?
默认 60s 太长,会让卡死的请求长期占住 PHP-FPM 进程。多数 JSON 接口设 10~15s,导出类接口单独用 location 放宽到 60s,并配 request_terminate_timeout 兜底。

参考来源

  1. Widespread PHP Flaw May Cause Many Site Outages Today
  2. Updated PHP Windows builds for Brotli security fix
  3. Introducing the Cloudflare PHP Capture the Flag challenge

以上为本文写作时的参考资料,引用观点以原文为准。

作者:沐小悠

本文约 27 分钟读完 · 关键词:Nginx 调优、PHP-FPM、max_children、fastcgi 缓冲、OPcache

更多文章 GET 示例接口

「」标签下的其他文章