ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Nginx与PHP-FPM高并发调优实战:从瓶颈定位到缓存与负载均衡

Nginx与PHP-FPM高并发调优实战:从瓶颈定位到缓存与负载均衡 开篇一个让我连夜改配置的高并发事故两个月前遇到一个客户站点晚上九点营销活动一开首页直接504刷屏Nginx 的 error.log 里全是 upstream timed out。我先看一眼服务器配置PHP-FPM 的 max_children 默认设置了 128但机器只有 4G 内存PHP 进程平均占 200MB 左右Nginx 的 worker_connections 还是编译默认的 1024WordPress 没有任何页面缓存每个请求都重新执行一遍 PHP。说白了这套 Ubuntu 22.04 上的 Nginx PHP-FPM 组合在默认参数下裸奔流量一上来就崩。这篇文章不打算讲把某几个参数调大这种零散技巧而是完整地走一遍我在真实生产环境里的调优流程先定位 Nginx 与 PHP-FPM 在这一架构里承担的角色再量化当前瓶颈然后逐步调 Nginx、调 PHP-FPM、上页面缓存最后把单机方案扩展到多机负载均衡。适合的对象是正在用 Ubuntu 22.04 Nginx PHP-FPM 跑 WordPress 的站长、刚接手运维的开发者以及所有想知道为什么这些参数要这样配而不是只会抄命令的人。1. 瓶颈定位先看清楚一个请求在 Nginx 和 PHP-FPM 之间怎么走完一圈很多人一提到高并发调优第一反应是把配置里能调大的数字都调大。这个思路害人不浅。在动任何参数之前我建议你先把 WordPress 在这个架构下的请求链路盘一遍浏览器请求 → Nginx 接收静态文件或转交 FastCGI → PHP-FPM 进程池分配一个 worker → 执行 WordPress 的 PHP 代码 → 查 MySQL / Redis → 生成 HTML → 返回给 Nginx → Nginx 回给浏览器。这条链路里Nginx 本身处理静态文件的能力非常强瓶颈通常不出在它身上。真正让 CPU 飙高、内存吃紧、响应变慢的是 PHP-FPM 里那一个个反复执行 WordPress 代码的 worker 进程。而 Nginx 连接数的配置决定了你能同时挤进来多少请求PHP-FPM 进程池的配置决定了这些请求能不能及时被处理。1.1 Nginx 的角色不只是反向代理还是流量入口的流量控制器Nginx 在 WordPress 架构里实际承担三件事第一静态资源图片、CSS、JS直接由它返回完全不经过 PHP第二动态请求通过 FastCGI 协议转发给 PHP-FPM第三它是所有请求的第一道入口连接能不能进得来、进来后能不能快速释放全看它的配置。我见过很多人在这一层犯错——把 worker_processes 和 worker_connections 当成越大越好结果连接数堆到几万PHP-FPM 根本接不住Nginx 只能排队等上游超时。调这一层的核心不是无脑拉高而是让 Nginx 的连接处理能力与后端 PHP-FPM 的实际处理能力匹配。1.2 PHP-FPM 的角色进程池就是你的动态请求车间PHP-FPM 维护着一组 worker 进程每个进程同时只能处理一个请求。WordPress 的 PHP 代码执行完成后这个 worker 才能空闲出来接下一个请求。所以进程池里的 worker 数量直接决定了并发动态请求的上限。默认配置里Ubuntu 22.04 的 PHP-FPM 通常用 pm dynamicstart_servers 只有 5 个左右。也就是说同一时刻只能处理 5 个动态请求剩下全部在 Nginx 里排队。排队时间一长前端就出现 502、504。这里真正要解决的是进程池大小是否匹配你的实际流量和内存而不是系统默认给了什么就用什么。2. 量化摸底在 Ubuntu 22.04 上先测出基线再决定怎么调不量化就开始调优等于闭眼开车。我每次到一台新服务器会先花 10 分钟做基线检查。Ubuntu 22.04 默认安装的 Nginx 版本一般是 1.18 或 1.20PHP 8.1 是默认版本。这些版本对下面要讲的优化方式都适用。先看硬件和当前跑着的服务# CPU 核心数 nproc # 内存总量 free -h # PHP-FPM 实际进程数及每个进程的内存占用 ps aux | grep php-fpm # Nginx 源码编译参数里默认的 worker 连接数上限 nginx -V 21 | grep -o worker_connections[0-9]* # 当前 PHP-FPM 配置 php-fpm8.1 -v再说一遍这五条命令不是走形式。worker_processes 应该设多少由 CPU 核心数决定pm.max_children 能设多少由总内存减去 MySQL、Redis、系统本身占用后剩余多少来决定。这两个数必须以实际测量为准不能靠猜。2.1 用压测工具做一个可复现的基线我习惯用 wrk 做动态请求压测因为它能稳定地测量每秒请求数RPS和延迟分布。如果没有 wrk用 ab 也行但 ab 不擅长模拟高并发下的长连接场景。先安装工具apt update apt install -y wrk apache2-utils然后对一个只输出hello之类内容的测试页面做压测记录当前配置下的 RPS。注意一定要在调整前后用同一个命令、同一个 URL、同一个时长去测才有可比性。基线数据记录在案后每改一个参数就重测一次你就知道哪个参数真正起了作用哪个只是心理安慰。实际检查时还要多留一个心眼如果 WordPress 本身还没装任何缓存插件第一次压测打出来的数字会非常难看因为每个请求都在重复执行全部 PHP 逻辑。这个难看是正常的它就是后面优化的对比基准。2.2 检查前后端日志和现有缓存状态在动手之前打开 Nginx 和 PHP-FPM 的日志花两分钟看看最近有没有可疑错误tail -n 200 /var/log/nginx/error.log tail -n 200 /var/log/php8.1-fpm.log常见的问题比如 PHP-FPM 的listen.backlog溢出、worker 进程 panic、pm.max_children到顶报警都会在这里留下痕迹。同时确认 WordPress 侧是否已有缓存插件W3 Total Cache、LiteSpeed Cache、Redis Object Cache 等如果有记录它是怎么配置的避免后面对 Nginx 缓存和插件缓存相互打架。基线检查做到这一步才算真正具备调优的底气。3. Nginx 调优实战把入口的连接处理能力与文件读取效率拉起来这一层的目标很简单让 Nginx 在不打满 CPU 的前提下尽可能快地接收和释放连接同时减少静态文件读取造成的磁盘 I/O。下面的参数都是我在 Ubuntu 22.04 上实际验证过的调整后需要用nginx -t检查语法再systemctl reload nginx热加载。3.1 worker 进程与连接数按 CPU 核心数来定在/etc/nginx/nginx.conf的events和worker_processes部分我通常这样配worker_processes auto; events { worker_connections 4096; multi_accept on; use epoll; }worker_processes auto让 Nginx 自动分配与 CPU 核心数相同的 worker 进程数。不建议手动指定更大因为 Nginx worker 是纯事件驱动的多出来的进程不会带来线性性能提升反而增加上下文切换。worker_connections 4096是每个 worker 进程能同时保持的最大连接数。所以理论最大并发连接数 worker_processes * worker_connections。一台 4 核机器就是 16384 个连接。注意这个数是能连接的上限不是我们应该立刻灌进去的量。它必须和后面 PHP-FPM 的进程数配套否则大量连接被 Nginx 接受后却等不到 PHP-FPM 响应照样 504。multi_accept on让每个 worker 一次能接受多个新连接减少空转唤醒的次数短连接场景下效果明显。use epoll在 Linux 上是默认选择但显式写出来能避免编译版本差异带来的猜测。这块有个常见误解有人把 worker_connections 调成 65535觉得越高越好。实际上当并发超过后端的处理能力时Nginx 只是把请求堆积在自己的队列里前端表现为页面迟迟不返回后端表现为一堆超时记录。正确思路是让 Nginx 的连接上限略高于 PHP-FPM 实际能消化的能力而不是高出一个数量级。3.2 文件缓存与连接保持静态资源不重复读盘WordPress 主题和插件会产生大量小文件Nginx 每次命中和回源都可能敲一次磁盘。配置 open_file_cache 后文件句柄、文件大小、修改时间都会缓存在内存里第二次访问同一个文件时直接走缓存open_file_cache max10000 inactive5m; open_file_cache_valid 2m; open_file_cache_min_uses 1; open_file_cache_errors on;max10000表示最多缓存 10000 个文件条目超过之后按 LRU 淘汰。inactive5m表示 5 分钟内没被访问的条目会被移除。open_file_cache_min_uses设置为 1表示只要访问过一次就缓存避免频繁访问的文件反复命中磁盘。连接保持方面长期跑 WordPress 我建议 keepalive 设置如下keepalive_timeout 30; keepalive_requests 100;keepalive_timeout 让客户端与 Nginx 的连接不立即断开30 秒内复用同一个连接。100 是单个 keepalive 连接可复用的请求数太低会造成频繁重建连接太高又会让慢客户端占着资源。对 WordPress 这种大量静态资源发送的场景合适的 keepalive 能明显减少 TCP 三次握手次数。3.3 gzip 与静态资源缓存头把体积减下来把命中提上去Nginx 层做 gzip 压缩能省掉不少流量也直接影响页面加载速度。PHP-FPM 返回的 HTML 和 WordPress 的 CSS/JS 都建议压缩gzip on; gzip_comp_level 5; gzip_min_length 1024; gzip_types text/plain text/css application/json application/javascript application/xml; gzip_vary on;注意 gzip_comp_level 不要超过 6经验值是 5 左右性价比最高。级别再高CPU 开销上去了压缩率提升却不明显。另外要在虚拟主机配置里给静态资源加缓存头让浏览器和中间缓存有明确的过期时间location ~* \.(css|js|jpg|jpeg|png|gif|ico)$ { expires 30d; add_header Cache-Control public, max-age2592000; }这一层配置做完Nginx 对静态资源的基本处理能力已经到位了。但这只是把入口弄顺了真正的并发压力还在 PHP-FPM 侧下面来啃硬骨头。4. PHP-FPM 调优实战把进程池算清楚而不是拍脑袋PHP-FPM 是这套架构里最需要精细计算的部分。很多站点崩溃不是 Nginx 扛不住而是 PHP-FPM 的进程池配置与服务器内存严重不匹配。默认的 dynamic 模式启动后PHP worker 会随着流量增长不断新建直到内存被打爆。4.1 用两条命令算出 max_children 的真实上限首先看每个 PHP-FPM worker 进程平均吃掉多少内存ps --no-headers -o rss,cmd -C php-fpm8.1 | awk {sum$1; n} END {print 平均RSS(MB):, sum/n/1024, 进程数:, n}然后看这台机器还能给 PHP 分出多少内存。假设总内存 8GMySQL 占用 2G系统和其他服务占用 1GRedis 缓存占用 0.5G那么可分配 PHP 的内存大约还有 4G 左右。如果平均每个 PHP-FPM worker 占用 80MB那么pm.max_children 可用内存 / 平均单进程内存 4096 / 80 ≈ 51这个 51 才是真正合理的进程上限。如果像开头那个事故里直接设 128每个 worker 200MB内存一下子就吃光了系统开始 swap然后所有请求都变慢502 跟着来。4.2 进程管理模式dynamic 和 static 怎么选在/etc/php/8.1/fpm/pool.d/www.conf里核心配置我建议这样设置pm dynamic pm.max_children 50 pm.start_servers 10 pm.min_spare_servers 5 pm.max_spare_servers 20 pm.max_requests 500pm dynamic适合流量波动大的 WordPress 站点。空闲时只保留少量进程流量上来时逐步增加不会一直占着内存。pm.start_servers是启动时的进程数通常设为 max_children 的 20% 左右。pm.max_requests 500控制单个 worker 处理完 500 个请求后自动重启避免 PHP 代码里的内存泄漏长期积累把单个进程内存越撑越大。如果你的站点流量比较恒定、基础内存充足也可以直接用pm static固定进程数省去动态创建销毁的 CPU 开销。但一旦设置静态进程数就必须更保守否则内存扛不住。4.3 超时与慢日志把卡住和慢的请求揪出来高并发站点最烦的就是个别请求把 worker 占着几分钟不释放。在同一个配置文件里加上request_terminate_timeout 60 request_slowlog_timeout 5 slowlog /var/log/php-fpm-slow.logrequest_terminate_timeout 设为 60 秒一个 PHP 请求最多执行 60 秒超过就杀掉防止异常请求永久占坑。request_slowlog_timeout 设为 5 秒执行超过 5 秒的请求会记录到慢日志里方便排查是哪个插件或哪个函数拖慢了响应。实际排查中WordPress 的插件自动更新、第三方 API 调用、数据库查询过慢都会在这里现出原形。4.4 OPcache让 PHP 代码编译结果留在内存里WordPress 的核心、主题、插件加起来几百个 PHP 文件每次请求都要重新解析一遍。OPcache 可以把编译后的字节码存在共享内存中直接大幅降低 CPU 开销。Ubuntu 22.04 上安装apt install -y php8.1-opcache在/etc/php/8.1/fpm/conf.d/10-opcache.ini或 php.ini 里确认配置opcache.enable1 opcache.memory_consumption128 opcache.interned_strings_buffer16 opcache.max_accelerated_files10000 opcache.validate_timestamps1 opcache.revalidate_freq60opcache.memory_consumption128表示给字节码缓存 128MB 内存足够容纳常见 WordPress 站点的全部 PHP 文件。opcache.revalidate_freq60表示每 60 秒检查一次文件是否有更新兼顾了开发和线上的性能。opcache.max_accelerated_files10000覆盖绝大多数 WordPress 主题插件的文件数量。到这里PHP-FPM 的动态处理能力已经调整好。但即便进程池设置得再合理每个动态请求还是要实打实跑一遍 PHP。真要让站点扛住大规模高并发必须在 Nginx 侧把重复的 PHP 计算缓存下来。5. Nginx 页面级缓存用 FastCGI Cache 把热点流量直接从 PHP 手里抢下来Nginx 自带的 FastCGI Cache 能把 PHP-FPM 返回的完整 HTML 页面缓存一段时间。命中期间同一个 URL 的请求直接由 Nginx 返回PHP-FPM 和 MySQL 完全不用参与对高并发站点的提升是质变级别的。5.1 配置一个缓存目录并声明 key 规则在/etc/nginx/nginx.conf的 http 块里加入fastcgi_cache_path /var/run/nginx-fastcgi-cache levels1:2 keys_zoneWORDPRESS:128m inactive30m max_size1g; fastcgi_cache_key $scheme$request_method$host$request_uri;keys_zoneWORDPRESS:128m是缓存索引区128MB 通常能管理数万条 URL。inactive30m表示 30 分钟内未被访问的缓存自动清除。max_size1g是缓存文件总大小上限超过 1G 后 Nginx 会按 LRU 淘汰。key 里包含了 protocol、请求方法、域名和 URI保证不同站点和不同参数的 URL 不会互相串缓存。5.2 在站点配置里启用缓存并排除登录用户和后台请求在/etc/nginx/sites-available/你的站点.conf的 server 或 location 块里加location ~ \.php$ { fastcgi_pass unix:/run/php/php8.1-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; set $skip_cache 0; # 后台请求、登录用户、带购物车 cookie 的请求不缓存 if ($request_uri ~* /wp-admin/|/wp-login.php) { set $skip_cache 1; } if ($http_cookie ~* wordpress_logged_in|woocommerce_items_in_cart|wp-postpass) { set $skip_cache 1; } if ($request_method ! GET) { set $skip_cache 1; } fastcgi_cache WORDPRESS; fastcgi_cache_bypass $skip_cache; fastcgi_no_cache $skip_cache; fastcgi_cache_valid 200 301 302 5m; add_header X-FastCGI-Cache $upstream_cache_status; }这段配置解决的是 WordPress 场景里最核心的两个问题后台和登录用户不能被缓存否则会看到别人的数据非 GET 请求不能被缓存否则提交评论或表单会拿到旧页面。X-FastCGI-Cache响应头会在浏览器 F12 里显示 HIT 或 MISS方便验证缓存是否生效。5.3 缓存失效别让用户看到旧文章FastCGI Cache 是页面的全量缓存内容更新比如发布新文章、修改页面、有人评论时需要让对应页面缓存失效。比较土但绝对有效的方案是在 WordPress 的functions.php里挂上一个回调触发内容更新时用 curl 去请求一次对应 URL让 Nginx 重新生成缓存。简单示例要在服务器本机执行避免通过公网回环add_action(save_post, function($post_id) { $url get_permalink($post_id); $parts parse_url($url); exec(curl -s -o /dev/null -H Host: {$parts[host]} http://127.0.0.1{$parts[path]}); });这只是一个可用方案。生产环境里我更推荐尽量让 WordPress 自身的缓存管理和 Nginx FastCGI Cache 分层页面级交给 Nginx对象级交给 Redis Object Cache 插件。两者各管一层不冲突。6. 从单机到多机的负载均衡用 Nginx 做分发器时的关键设计当一台服务器再怎么调也扛不住流量时就该考虑横向扩展。Nginx 天然适合做负载均衡器。常见做法是前面挂一台 Nginx 做入口后面挂多台跑着相同 WordPress 代码的应用服务器。6.1 upstream 配置least_conn 还是 ip_hash 在/etc/nginx/nginx.conf的 http 块里定义一个后端组upstream wordpress_backend { least_conn; server 10.0.0.11:8080 weight3 max_fails3 fail_timeout30s; server 10.0.0.12:8080 weight1 backup; keepalive 32; }least_conn会把新请求发给当前活跃连接数最少的节点。相比默认的轮询least_conn 在高并发和节点性能不一的情况下更合理。weight3表示这台机器承担三倍流量适合节点配置不一致的场景。backup表示第二台是备份机平时不接入流量在线节点挂了才启用。keepalive 32是 Nginx 与后端服务器之间的长连接数建议根据后端数量调整避免每次请求都重新建立 TCP 连接。如果你的 WordPress 是单点部署、没有做会话同步那要特别谨慎用least_conn用户登录后处理评论或上传媒体可能被分发到另一台没有登录态的节点。此时用ip_hash更稳妥它会让同一个 IP 的请求始终落在同一台后端。6.2 会话同步和 WordPress 的站点地址设置多节点面板里的 WordPress 有几个比较容易被忽略的配置项WP_HOME和WP_SITEURL必须在wp-config.php里强行指定例如define(WP_HOME, https://example.com); define(WP_SITEURL, https://example.com);用户会话默认存储在 PHP 文件里跨节点会丢失建议启用 Redis Object Cache 插件把缓存和会话都放到共享 Redis。上传的图片路径如果各节点本地保存前端的图片会在不同节点之间不一致。要么用共享存储NFS、对象存储要么把所有节点统一用 upsteam 分发的静态资源路径由 Nginx 直接把静态文件引导到单独的文件服务器。这部分的重点是负载均衡不等于把流量分到多台就完事后端节点的状态一致、数据一致、缓存一致才真正跑得起来。我在多地实践时还遇到过节点 A 写入缓存成功、节点 B 没有该缓存数据导致性能一会儿快一会儿慢。解决思路是把对象缓存下沉到 Redis将缓存与节点解耦。7. 压测验证与真实踩坑记录配置都改完后最关键的环节是压测和验证。我习惯用 wrk 做一个 30 秒的固定并发压测分别在没缓存、有 FastCGI Cache、以及多节点负载均衡三种情况下记录 RPS 和 p99 延迟。实测下来单机部署、未开页面缓存时 RPS 大约在 200-300 之间开了 FastCGI Cache 之后静态化和命中率高的页面能到 3000-8000 RPSp99 从几百毫秒降到 50 毫秒以内。对这种数量级的提升缓存起了决定性作用。7.1 如何验证缓存真的命中看响应头和缓存文件在开启 FastCGI Cache 后访问首页并查看响应头curl -I https://example.com/如果输出里有X-FastCGI-Cache: HIT就说明命中已经被 Nginx 缓存。如果是MISS首次访问会继续回源执行 PHP第二次再访问同一个路径才变为HIT。如果一直是 MISS多半是请求带了 cookie 或者 URL 参数频繁变化需要回顾上一章的skip_cache规则。也可以通过缓存目录大小判断du -sh /var/run/nginx-fastcgi-cache# 如果发现缓存文件数量异常庞大说明 key 设计可能需要调整 find /var/run/nginx-fastcgi-cache -type f | wc -l7.2 踩过的坑一缓存目录权限问题导致 502我最早配置 FastCGI Cache 时只改了 fastcgi_cache_path忘了检查目录权限。Nginx worker 进程没有写权限结果缓存文件创建失败所有动态请求都返回 502。解决方法是确保目录所有者与 Nginx worker 进程一致mkdir -p /var/run/nginx-fastcgi-cache chown -R www-data:www-data /var/run/nginx-fastcgi-cache这个坑特别容易出现在换了 socket 运行用户比如从 nginx 改成 www-data之后。7.3 踩过的坑二登录用户的 cookie 命中了公共缓存另一个很隐蔽的问题正则里的 cookie 匹配做得太宽把wordpress_logged_in_哈希值和普通访客 cookie 弄混了。结果登录用户和游客看到同一个缓存页面有人反映用户名去哪儿了。修正方法很明确只排除真正影响个性化内容的 cookie 名称别图省事写了一个.*把所有 cookie 都排除。凡是对所有用户一视同仁的页面都应该进缓存。7.4 压测命令与护理建议看完以上内容还不够的话我这里直接给你一份可以照抄的压测命令# 100 个并发连接持续 30 秒 wrk -t4 -c100 -d30s https://example.com/ # 观察 PHP-FPM 响应时间 tail -f /var/log/php8.1-fpm.log # 观察 Nginx 错误 tail -f /var/log/nginx/error.log最后的最后调优完成后我会坚持做三件事把改动的配置做一次备份在 cron 里添加一个定时任务定期清理超过 24 小时的缓存文件每次更新 WordPress 主题或插件后重新压测一次确认性能没有跳水。这套方法从单机到多节点都适用只要第一步的基线数据留得住后面每一层优化都能看到明确的收益。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进