先说个我亲眼见过的线上事故。某项目的服务端从4核云主机迁到16核独立服务器,配置全面升级,结果上线当晚流量一起来,接口耗时反而比原来还高。监控面板上CPU使用率只有12%上下,但PHP-FPM的worker进程状态全是Busy,新请求在队列里越排越长。团队第一反应是“PHP是不是天生就用不好多核”,后来排查了一圈,发现是进程池数量、数据库连接数和两处阻塞操作叠加出来的结果。
这个问题其实很典型。在16核服务器上跑PHP,最核心的矛盾在于:PHP默认的运行模型是“一个进程同一时间只处理一个请求”,而服务器有16个核,却不一定有那么多进程在并行干活。所以“如何把16核CPU利用到极致”这个命题,根本不是让单个请求占用多个核,而是如何让足够多的请求真正并行地分散到16个核上,同时不被IO等待、外部调用、进程配置这些隐藏因素拖住。这篇文章我从PHP-FPM的进程模型开始,把进程池调优、常见并发瓶颈、常驻内存方案、CPU亲和性、系统层优化以及压测验证这条链路完整讲一遍,覆盖你在16核机器上跑PHP时大概率会踩到的坑。
1. 先搞清楚“16核跑不满”到底卡在哪一步:PHP-FPM的进程模型
1.1 一次请求从进入到完成,核心是怎么被占用的
在传统的LNMP架构里,Nginx收到请求后通过FastCGI协议转给PHP-FPM。FPM的职责是维护一个worker进程池:master进程负责管理,下面挂着一堆独立的PHP worker进程。每个worker在同一时刻只能服务一个请求,处理完当前请求后再从队列里取下一条。也就是说,PHP层面的并发能力上限,近似等于当前可用的worker进程数量。
这个模型直接决定了CPU利用率的第一个数学关系:一台16核机器上如果只有4个worker进程,那么即使4个进程全部满负荷运行、每个进程霸占一个核心跑满100%,整机的CPU使用率最多也只有4/16=25%。很多新手一看CPU才25%,立刻得出“PHP不行”的结论,其实只是进程数没给够。
另外要破除一个常见误解:PHP并不是完全不能多线程。编译时加ZTS(Zend Thread Safety)也能使用pthreads扩展,但生产环境里绝大多数发行版默认是NTS模式,而且PHP-FPM本身根本不是线程模型,它是process pool模型。所以“提高PHP并发”的正确方向,不是幻想单进程内多线程并发,而是合理调整进程池,让并发请求被多个worker进程分散承担。
这里还有一个微观层面的问题值得展开:一个请求在worker里并不是全程都在计算。从Nginx收到请求、执行PHP脚本、访问数据库、渲染输出到返回给Nginx,绝大多数时间都花在等待上——等数据库返回、等Redis返回、等外部接口返回。这段等待时间worker进程虽然活着,CPU占用几乎为零。所以worker进程数和CPU核心数之间不是简单的一一对应关系,后面调参数时会反复用到这个前提。
1.2 为什么很多团队的PHP-FPM配置跑不满CPU
我见过大量直接沿用安装默认值的PHP-FPM配置,很多就是发行版自带的参数。默认情况下pm=dynamic、pm.max_children=5或更少,这种配置在4核机器上勉强能跑,搬到16核机器上就成了最大的瓶颈——最多只有5个worker,无论怎么优化业务代码,同时处理中的请求数也不会超过5个,剩下的11个核心就在那儿闲等。
更迷惑人的是,有时候你看到CPU占用率不高、但请求已经卡死,这往往是“伪低负载”。worker数太少导致请求队列堆积,而堆积本身不消耗CPU。从监控上看系统负载很低很健康,从业务上看接口已经慢到不可用。判断这类问题需要看FPM状态页里的排队长度,后面有一章专门讲。
还有一个容易被忽略的点:进程数越多,上下文切换成本也越高。如果把worker开到200个,而每个worker都抢着上CPU,内核光切换进程上下文就能吃掉大量CPU资源。尤其PHP这种大量短生命周期请求的程序,在ondemand模式下频繁fork进程本身也是一笔昂贵的开销。所以“16核就开16个进程”和“进程数越多越好”都是错误直觉,正确的做法是结合请求耗时、内存上限和CPU核心数来折算,而不是拍脑袋选一个数。
2. 进程池调优:用pm参数把16核喂饱
2.1 三种进程管理模式怎么选
PHP-FPM的进程池有三种模式,很多人只记得名字,不理解背后的取舍。
- pm = static:固定worker数。进程数恒定,没有创建和销毁进程的开销,响应最稳定。适合流量相对平稳、没有剧烈波动的线上核心服务。缺点是一旦写死,扩容要改配置重启。
- pm = dynamic:在最小值和最大值之间动态伸缩,FPM会尽量在min_spare_servers和max_spare_servers之间维持空闲进程。适合流量有波动的常规业务。缺点是在流量陡增瞬间可能触发大量fork,fork本身是重量级操作,反而会拖慢响应。
- pm = ondemand:有请求才创建进程,空闲超过一定时间自动回收。适合内存非常紧张、流量又很稀疏的应用,比如低并发的管理后台或定时脚本。缺点是最坏情况下每个请求都要经历一次进程创建的开销。
三种模式没有绝对好坏,关键看业务形态。对16核机器上的高并发业务,我推荐优先尝试static,或者把dynamic的伸缩区间调窄。static能让每次请求的分发路径最短,也最好做容量预估。如果担心流量波动,dynamic也可以接受,但min和max的区间不能拍脑袋,至少要让空闲worker数覆盖日常高峰。
2.2 max_children不是越大越好:按内存和IO等待倒推
max_children决定了这个池子最多能同时容纳多少请求。很多人第一反应是“16核就开16个”,也有人一拍脑袋开200,结果内存被吃光。每个PHP worker的内存占用取决于框架和业务代码,一个常见的现代PHP框架在高负载下,单worker内存可能到80-150MB。假设16核机器配32G内存,操作系统、Nginx、MySQL、Redis还要分掉一部分,真正给PHP的可能只有20G,那么按100MB一个worker粗略计算,理论上限约200个。
但数学上可行不代表实践中合理。正确思路要分两层约束:内存层决定硬上限,CPU调度层决定合理区间。因为worker大部分时间在等IO,为了让CPU不闲着,关键指标是“正在Running状态的worker数”足够接近核心数。假设一个请求平均耗时500ms,其中纯CPU计算只有30ms,那么一个worker只有6%的时间在占用CPU。按这个模型,要让16个核心都有活干,大约需要16/0.06约266个在跑的请求。但实际业务不可能每个请求都这么闲,而且200多个worker本身的内存压力、上下文切换压力都是灾难。
所以更稳的经验是:先设一个保守值,比如max_children = 核心数 * 4,也就是16核机器先给64,压测后观察CPU使用率、worker的Busy比例和排队长度,再上下微调。对纯CPU密集型的计算任务(图片处理、批量加解密),worker数可以降到接近核心数;对IO密集型的Web API,64到128都是常见区间,前提是内存扛得住。
2.3 一份16核服务器的FPM参考配置
下面这份配置是我在一台16核32G、跑常规Web API业务(Nginx+PHP-FPM+MySQL+Redis分离部署)时用过的框架,读者可以以此为起点调整。
[www] user = www-data group = www-data listen = 127.0.0.1:9000 listen.backlog = 65535 pm = dynamic pm.max_children = 64 pm.start_servers = 16 pm.min_spare_servers = 8 pm.max_spare_servers = 32 pm.max_requests = 1000 request_terminate_timeout = 30 request_slowlog_timeout = 5s slowlog = /var/log/php-fpm/slow.log几个参数背后的逻辑值得说明。start_servers设为16,让机器在启动时就有16个worker可以直接对应16个核,避免流量进来后再慢慢创建。pm.max_requests=1000非常关键,它让每个worker处理够1000个请求后自动退出重建,这是最廉价的防内存泄漏手段。很多团队不设这个参数,长期运行的FPM内存像温水煮青蛙一样涨上去,最终触发OOM。request_slowlog_timeout配合slowlog,能让你定位“每个worker到底卡在哪个脚本哪一行”,这是排查慢请求的基础设施,强烈建议开。
注意:以上配置只是基准,pm.max_children务必用你业务的单worker峰值内存做一次倒推。压测时观察php-fpm状态页里的max children reached,如果触顶,再加;如果内存告警,就先砍。
3. 真正吃CPU的不是PHP语法,是IO等待、连接数和共享状态的坑
3.1 慢SQL和第三方接口如何让你的worker集体“假死”
排查“CPU利用率低但请求超时”问题时,大部分时候问题不在PHP进程配置,而在worker被IO卡住。一台机器上所有PHP worker同时请求同一个慢SQL,这条SQL跑2秒,期间64个worker全在等MySQL返回,CPU使用率可能只有个位数,但QPS已经归零。
这种情况最有效的定位手段就是FPM的慢日志。打开之后过一阵子去看,能看到类似“executing slow request”的具体脚本文件和行号。之前排查过一个线上案例,慢日志里反复出现同一个Excel导出的方法,导出10万行数据要在PHP里循环生成文件,耗时将近20秒,每个请求都像钉子一样钉死一个worker。这种情况就算把核数加到32也没用,因为瓶颈是单请求内部的串行计算和等待。
第三方接口的串行调用是另一个隐形杀手。一个请求里依次调用三个外部HTTP接口,每个耗时300ms,总耗时就是900ms,而这段等待时间里worker既不计算也不释放。解决办法要么改成并发调用(curl_multi、Guzzle的异步请求、协程方案),要么把这类耗时操作从同步请求链路里摘出去,丢到队列由后台任务处理。别指望靠增加worker数来解决串行等待,worker只是把“等待”复制了多份,等待本身一点没减少。
3.2 数据库连接数打满:多开worker的连锁反应
worker开多了,另一个容易爆的雷是数据库连接数。PHP-FPM模式下每个worker持有自己独立的MySQL连接,连接生命周期和worker基本一致。假设你某天为了榨干CPU把max_children调到96,那MySQL可能同时面对96个连接,如果应用里还有后台任务、管理后台,连接数很容易逼近甚至超过max_connections上限。
MySQL的每个连接都要占内存和线程,连接数打满后,业务侧的表现不是“CPU跑满”,而是频繁报连接超时、Too many connections,然后FPM的worker继续在等待连接上排队,形成雪崩。处理手段通常有几条:一是压worker数,别超过业务实际所需;二是在MySQL侧调大max_connections并加强监控;三是引入连接代理层做连接池复用。对于已经上了常驻内存方案的项目,连接池可以直接做在应用层,这也是常驻内存方案相对FPM的明显优势之一。
3.3 Session锁、APCu缓存:多进程下的共享状态问题
多进程模型下的一个经典坑是PHP Session的文件锁。session_start()默认会对session文件加锁,直到脚本结束或调用session_write_close()才释放。如果同一个用户的浏览器同时发起多个请求(比如页面加载时AJAX并行、快速点击),这些请求会被同一把Session锁堵住,串行执行。16核机器上甚至能出现一个用户的几个请求同时堵住多个worker的场景,CPU根本没有机会跑满。
解决办法很直接:一是把Session存储从文件改到Redis,避免文件锁;二是如果当前脚本不需要写Session,在session_start之后立刻session_write_close(),把锁释放掉。这个优化在高并发抢购、秒杀类场景收益非常明显。
另一个容易被误解的是APCu。APCu是进程内共享内存缓存,注意是“进程内”。FPM的多个worker进程各有各的内存空间,写进APCu的数据只有当前worker自己能看见,其他worker完全不知情。这带来两个后果:一是内存被重复占用,多份缓存各自为政;二是如果你拿APCu做跨进程的计数器、短周期状态同步,数据就会不一致。这种场景统一改成Redis之类的外部存储才对。很多人一提到“内存缓存”就默认它是全局的,在FPM模型下这个默认是错的。
3.4 请求生命周期里的重复劳动:每个请求都在重新“启动”一次PHP
这一点很少被人算进性能账本,但它确实存在。PHP代码本质上每次请求都要走一遍“模块初始化-请求初始化-执行脚本-请求关闭-模块关闭”的生命周期。也就是说,即使你用的是一个大型框架,每次请求也都要重新加载类定义、重新注册自动加载器、重新初始化容器。opcache能缓存编译后的字节码,但框架本身的一次性初始化逻辑省不掉。
这带来的结果是:为了支撑一定并发,你必须开足够多的worker来抵消“每个请求从头初始化”的开销。同样的业务逻辑如果换成常驻内存方案,类定义和连接创建在进程启动阶段就完成了,后面每个请求只需执行真正的业务部分。这就是下一章要展开的内容——为什么有人最终选择抛弃FPM模型。
4. 换个思路:从FPM切换到常驻内存进程模型,CPU利用率的物理上限变了
4.1 FPM模型的天花板和常驻内存方案的突破方向
FPM模式的本质问题在于进程生命周期太短:一个请求结束后,worker虽然还活着,但它为这个请求创建的类实例、连接、状态全部清理干净,下一个请求来了再重新造一轮。这些重复劳动不仅拉长了请求时间,也逼迫你通过增加worker数来对抗开销。而worker数受内存约束,内存又反过来限制了你能够并行处理的请求数量。
常驻内存方案(例如Swoole这类框架)的思路完全不同:进程启动时把框架、类定义、服务容器都加载好,连接在进程内长期复用;请求来了只是在当前进程的上下文里执行一段业务逻辑,执行完清理对象状态,但进程本身不退出。这个模型下,单个进程能处理的请求量不再是“一个请求结束才接下一个”,而是可以在一个进程内部并发调度大量请求。
4.2 Worker进程、协程和Task进程是怎么把16核“喂饱”的
这类框架的进程模型大致分成几层:Master管理进程负责进程生命周期和信号处理;Reactor线程通过epoll事件循环接收网络连接,把读写事件派发给下层;Worker进程处理真正业务逻辑;Task进程承接耗时的同步任务。Worker进程数量通常设置为CPU核心数或核心数的1.5倍,每个Worker内部再通过协程并发处理成百上千个连接。
关键在于协程:当某个协程发起数据库查询或外部HTTP请求、进入等待状态时,框架会自动切换去执行其他就绪的协程。对一台16核机器来说,只需要开16个左右的Worker进程,每个Worker里挂几百个协程,同一时刻可以同时推进几千个请求的执行进度。这种模式下,CPU不会被单个请求的IO等待浪费掉,因为总有别的协程在就绪队列里等着上CPU。
对比会更直观:FPM模型中一个请求占用一个worker,IO等待期间整个worker被“占着茅坑不拉屎”;常驻内存加协程模型里,IO等待可以让位给其他请求,实际并行能力和连接数相关,CPU利用率可以稳定保持在高位。当然,这也意味着业务代码要适应常驻内存生命周期:静态变量、全局变量不再随请求结束自动清理,数据库连接、Redis连接要在进程内做好复用和异常处理,发布和维护时需要平滑重载机制。常驻内存方案的收益到底多大,取决于你的IO占比。IO占比越高收益越明显;如果业务全是纯计算、没有IO等待,那常驻内存帮不了太多忙,因为它主要解决的是“等待的时候CPU空转”。
4.3 不是所有项目都该上常驻内存
讲了这么多常驻内存的收益,也要说句公道话:如果你的业务就是典型的增删改查,请求量没大到FPM扛不住,老老实实FPM加Redis缓存就够了。常驻内存带来的部署复杂度、内存泄漏排查难度、团队学习成本都是实打实的额外负担。我见过团队为了“榨干CPU”草率引入常驻内存,结果业务代码里满是静态变量残留状态,上线后数据互相串,比优化前还痛苦。
我的建议是分步走:先按第2章和第3章把FPM调好,把慢请求、连接数、Session这些基础问题解决掉,压测看看到底能不能达标。如果瓶颈确实在并发能力和请求生命周期开销上,再考虑常驻内存。对16核的机器而言,FPM能挖掘的潜力通常比很多人以为的大得多,先把基础盘做对,别急着换赛道。
5. 让每个worker待在该待的核上:CPU亲和性与系统层优化
5.1 绑核的意义:缓存局部性和上下文切换
前面说的都是进程调度层面的事,让多个worker并行跑在多个核上。但Linux内核的CFS调度器会动态地把进程分配到任何空闲核心上,这导致同一个worker进程可能一会儿在CPU0上跑、一会儿被调度到CPU7上跑。每次迁移,L1/L2缓存里的热数据就要作废并重新加载,对高吞吐场景确实有损耗。CPU亲和性就是把这个进程固定到某几个核上,让它不在核间来回乱跑。类比到现实,一个员工如果有固定工位,工具和资料都摆在手边,效率通常比每天换座位高。
设置亲和性有两种常见方式。一种是在启动PHP-FPM的master进程时用taskset绑定,子进程默认继承亲和性:
taskset -c 0-15 /usr/sbin/php-fpm8.1 -D另一种是systemd方式,在php-fpm的service文件里配置CPUAffinity,然后重启服务。要注意:16核服务器不等于一定要把16个核全部绑定给PHP。实际上Nginx、MySQL、Redis之类的进程也需要核心。我的做法是把0-13号核心留给PHP-FPM,14-15号留给Nginx和管理工具,避免互相抢核。如果你用htop观察时发现某些核长期满负荷而另一些核空闲,优先怀疑亲和性配置,而不是立刻认定PHP有bug。
5.2 绑定核不等于无脑绑:一个折腾出来的教训
绑核这件事容易矫枉过正。曾有团队为了让16个worker各占一个核,强行把64个worker按4组分别绑定到4个固定核段。结果流量低谷时部分核空闲,流量尖峰时又因为worker被钉死在其他核上、无法利用空闲核,反而出现排队和延迟。Linux默认调度器本来就具备核间迁移能力,这不是缺陷,而是负载均衡机制。绑核的收益来自缓存局部性,代价是失去调度弹性。所以一般建议是:要么不绑,要么给整组进程绑定全部可用核心;精细到“每个进程一个核”反而容易引火烧身。
如果你确实想给不同业务的进程池做隔离,正确的做法是在同一台机器上启动多个FPM实例,分别监听不同socket、绑定不同核心段,而不是在同一个池里做细颗粒度绑核。多实例方案适合一台机器上同时跑两个差异很大的业务,彼此互相隔离,资源利用上更干净。
5.3 内核参数、连接队列和文件描述符:不调这些等于白忙
多核利用还有一个前提:请求得顺畅地到达PHP进程,并且PHP进程能同时保持足够的并发连接。有几个人容易遗漏的系统层参数值得检查。
# 网络连接队列 net.core.somaxconn = 65535 net.ipv4.tcp_max_syn_backlog = 65535 # 文件描述符上限 ulimit -n 65535Nginx转发到FPM走网络socket,内核的somaxconn默认值往往只有128左右。当并发请求一下子涌进来,TCP连接请求会超过accept队列上限,后面的请求直接被内核丢弃或握手失败。现象就是压测时并发一上去就大量连接超时,CPU完全没跑满,却不知道原因。一个简单验证方法:看Nginx错误日志里的连接重置记录,或压测工具里超时数量突增。
还有一点要注意:PHP-FPM的listen.backlog参数和内核的somaxconn取最小值才是真正的连接队列上限。只改PHP配置不改内核参数,队列上限还是被内核卡住。我建议把两边都调大,避免其中一个是短板。
5.4 opcache、realpath缓存和发布方式的细节
OpCache对PHP性能的贡献不用多说,但在多核利用的语境下有个容易弄错的点:revalidate_freq设多大合适。生产环境如果通过发布系统部署到新目录并清空opcache,revalidate_freq可以设为0,让它每次检查文件时间;如果团队习惯直接在线上机器拉代码,revalidate_freq=0会导致每次请求都要stat一遍文件时间,磁盘IO反而上去。更聪明的做法是优化发布流程,发布时主动重置opcache或平滑重启FPM,再配合revalidate_freq=0。
realpath缓存也值得调。Composer自动加载机制会大量调用file_exists和文件包含,realpath_cache_size默认1M可能不够用。在php.ini里设置:
realpath_cache_size = 4096K realpath_cache_ttl = 600可以显著减少磁盘路径解析。这个优化在文件多的框架项目上立竿见影,很多压测里看到的“CPU不高但磁盘IO高”问题,一部分就来自路径解析。整体而言,系统层的优化思路是:让请求更快更顺地到达PHP worker,让worker运行中尽量少做重复的路径解析和include操作。这些工作清理得越干净,CPU才越可能用在真正的业务计算上。
6. 用压测和数据说话:怎么判断16颗核心是否真的被用起来了
6.1 压测工具选型和一条可复现的压测思路
工具上没有绝对标准,我用过的组合里,简单顺手的是wrk,一个命令行就能跑出延迟分布;ab适合快速看单接口吞吐和并发影响;JMeter适合带断言、带复杂业务流程的回归场景。我个人压测习惯是先做warmup,预热一两分钟让opcache和FPM进程池进入稳定状态,然后再开始计时。时序上建议从低并发逐渐往上加:50并发跑2分钟、100并发跑2分钟、200并发跑2分钟,每档记录QPS、P95延迟、CPU利用率和worker状态。如果并发升高后QPS几乎不涨,说明系统已经到了瓶颈,这时候别盲目继续加压。
一个容易犯的错误是压测机性能不够,导致压测工具自己先成为瓶颈,出来的数据全被客户端拉低。多核机器压测时建议把压测工具跑在另一台机器上,并且观察压测机CPU是否过高。我见过有人对着16核服务器压测,结果wrk所在的小机器CPU先满了,压出来的数字全废,还反过来怀疑服务器有问题。
6.2 该盯哪些指标:状态页、运行队列和上下文切换
PHP-FPM自带状态页,需要在池配置里开启:
pm.status_path = /status然后通过Nginx放出来或直接curl访问。重点看三类数据:active processes(当前正在处理的worker数)、max children reached(自启动以来是否碰过上限)、以及listen queue(等待队列长度)。如果listen queue持续大于0,说明worker不足或worker正在被慢请求拖住;如果max children reached频繁增长,说明max_children确实顶到天花板,要评估是加配置还是查慢请求。
系统层面vmstat 1能快速判断多核利用状况。重点看r列(运行队列)和cs列(上下文切换)。r列明显大于核心数,说明CPU有排队;cs列每秒几十万的级别,说明正在大量切换进程,可能worker数开得过多或锁竞争激烈。htop里按1键可以看到16个核心各自的负载,如果负载分布非常不均,优先检查亲和性和是否存在单一热点进程。还有一个经验是:别把“CPU必须到90%”当目标。PHP业务是典型的IO密集,整体CPU在30%-50%而QPS和延迟都达标,是非常正常的事。硬把CPU撑到80%以上,往往意味着worker开得太猛,接下来就是内存和连接数的连环雷。
6.3 排查瓶颈的一条判断链
最后分享一套我常用的排查链路。遇到“16核机器性能不对”这类问题,先去FPM状态页看worker是Busy还是Idle:全部Busy但CPU不高,说明请求在等待外部依赖,这时候加worker没有意义,要查慢SQL、外部接口和锁;全部Idle但请求排队,说明请求根本没进到PHP层,检查Nginx连接、内核队列、网络参数和FPM监听队列;worker一部分Busy且CPU也没跑满,说明并发源不够,压测时加大并发或排查业务侧是否限流;反之如果CPU已经跑得很高但业务还是慢,再考虑opcache命中率、框架启动开销,以及是否值得上常驻内存。
这套链路的好处是强迫你先定位再动手。很多人一上来就调FPM参数,发现没效果又换常驻内存,其实问题压根不在PHP层。我参与过的一个项目就出现过这种情况:业务方一直咬定“PHP用不好16核”,排查到最后发现是数据库连接池配小了,大量worker堵在获取连接这一步,CPU自然跑不满。等数据库层调整完,同一个FPM配置下CPU使用率和QPS同时翻倍,从头到尾PHP代码一行没改。
整篇讲下来,最核心的一句话就是:PHP不是不能用多核,而是很多人没用对进程模型。我在实际优化中习惯先把FPM当成默认选项认真调,把慢请求和外部依赖的账算清楚,再考虑换常驻内存这种更重的选项。最后分享一个小技巧:给FPM状态页配一个简单的监控告警,当listen queue大于0持续超过阈值时自动提醒,很多“CPU跑不满但请求卡死”的隐患都能提前发现。