1. 一次线上卡死现场:从报警到定位的24小时
先从一个真实场景说起。去年某天下午,我负责的一个电商后台突然出现少量接口超时报警,刚开始只是几个慢请求,三五秒后自动恢复,大家没太当回事。但十分钟后,报警数量指数级增长,线上部分页面直接白屏,重启PHP-FPM后恢复,过半小时又复发。
当时第一反应是数据库扛不住了,结果打开慢查询日志一看,大批SQL执行时间不到10毫秒。又怀疑是Redis连接被打满,检查后发现连接数也正常。最后靠strace逐个查看PHP-FPM子进程的系统调用,才发现问题根源:某个Worker进程卡在了一个外部接口的file_get_contents()调用上,已经持续了63秒。而这个接口的超时时间被设置为0,也就是永不超时。
这个案例完美诠释了PHP默认I/O模型的死穴:同步阻塞。一个请求卡住,整个Worker进程就闲置在那里干等,既不处理其他请求,也不主动放弃,其他请求只能排队等待空闲Worker。在PHP-FPM这种预派生进程模型下,Worker进程数量是固定的(由pm.max_children控制),一旦几个Worker同时被慢请求占住,新请求就全部堆积,最终形成雪崩。
这篇文章不是讲某一个陌生框架,也不是什么新潮技术,恰恰是我们每天都在用的PHP和PHP-FPM最基础、最容易被忽略的机制。我会从Worker进程模型聊到阻塞发生的底层原理,再给出定位问题和解决问题的完整思路。适合所有使用PHP-FPM部署业务的开发者,尤其是对"为什么一个慢请求能拖垮整个服务"这个问题困惑过的人。
2. 解剖PHP-FPM的Worker进程模型:默认就是"一个萝卜一个坑"
2.1 FPM启动时发生了什么
PHP-FPM启动后,会按照配置文件里的pm相关指令创建一批子进程。以最常用的dynamic模式为例:
pm = dynamic pm.max_children = 50 pm.start_servers = 10 pm.min_spare_servers = 5 pm.max_spare_servers = 20FPM主进程(Master)负责监听网络端口(默认9000),管理子进程生命周期。子进程(Worker)才是真正执行PHP脚本的实体。每个Worker进程在同一时刻只能处理一个请求,从接收到请求开始,到输出完整响应结束,这一个Worker就属于这个请求独占。
用餐厅做类比:Master是餐厅经理,只管接电话和安排服务员。每个Worker就是一个服务员,一次只能服务一桌客人。客人点完菜开始吃,服务员就得在旁边候着,直到客人结账走人才能服务下一桌。如果有几桌客人故意不走,门口排队的客人再多,也没服务员去接。
这里的关键点是:Worker数量上限是固定的。pm.max_children = 50意味着整个FPM最多同时处理50个并发请求。第51个请求进来,只能在内核的accept队列里排队,直到某个Worker释放。
2.2 为什么PHP不采用"一个进程内多线程处理并发"的方案
很多人会问:既然一次只处理一个请求效率太低,为什么PHP-FPM不用线程来提升并发能力?或者像Node.js那样用事件循环?
历史原因占大头。PHP 4时代对线程的支持很不稳定,PHP核心团队最终选择了多进程方案而不是多线程。多进程的好处是内存隔离,一个请求崩了(Segmentation Fault)或者内存溢出,只会杀掉自己所在的Worker进程,不影响其他Worker,Master会立即fork一个新的Worker替补。这种健壮性对于Web场景非常宝贵。
但代价也很明显:进程内无法共享内存状态。你说把MySQL连接缓存到内存里供下一个请求复用?做不到,每个请求结束后该进程的内存状态就全部销毁了(实际上PHP的生命周期决定了所有资源在请求结束时会释放,这是另一块话题)。所以PHP-FPM只能重复地建立和销毁数据库连接、重复加载文件、重复解析类,效率和资源开销都不理想。
2.3 OPCache是例外,但它管不了I/O
顺带提一下OPCache。很多人觉得OPCache开了之后PHP不就"常驻内存"了吗?不是的。OPCache只是把PHP脚本编译出来的字节码缓存在共享内存里,避免了每次请求重复"读取源码->词法分析->语法分析->编译成opcode"这个过程。它解决的是CPU密集型开销,和I/O阻塞完全是两码事。
请求进来,OPCache让PHP脚本从编译执行变成了直接执行,但执行过程中如果遇到file_get_contents、curl_exec、mysqli_query这些I/O操作,进程照样要挂起等待。OPCache管不到这一步。
3. 庖丁解牛:一个请求从进入FPM到释放Worker的完整旅程
3.1 请求的入口与分发
以最常见的Nginx + PHP-FPM部署为例,完整的调用链是这样的:
- Nginx接收HTTP请求,根据location规则,将
.php结尾的请求通过FastCGI协议转发给PHP-FPM监听的地址(如unix:/tmp/php-cgi.sock或127.0.0.1:9000)。 - FPM Master进程接收到FastCGI连接,从空闲Worker池里挑一个Worker,把请求交给它。
- Worker进程开始执行:初始化PHP环境,加载配置文件(
php.ini)、扩展(php-fpm.conf中的extension),加载OPCache缓存,然后按照URL找到对应的PHP脚本。 - PHP脚本开始执行,此时会根据代码逻辑产生各种I/O操作。
- 所有输出收集完毕后,Worker通过FastCGI协议把响应内容返回给Nginx。
- Nginx将响应返回给客户端,连接关闭,Worker回到空闲池,等待下一个请求。
这个过程看起来简单,但每一步都有阻塞点。
3.2 Worker进程执行PHP脚本时的内幕
在PHP的执行模型里,请求生命周期包含几个阶段:RINIT(请求初始化)、RSHUTDOWN(请求关闭)、MINIT(模块初始化)、MSHUTDOWN(模块关闭)。其中MINIT和MSHUTDOWN只执行一次(FPM启动和结束),RINIT和RSHUTDOWN在每次请求都会执行。
关键来了:RSHUTDOWN阶段,PHP会释放本次请求产生的所有资源,包括数据库连接、文件句柄、Session锁等。这意味着即便你想在两次请求之间复用一个持久化连接,PHP默认也不会让你这么做。每次请求都是全新开始,这也是PHP-FPM模型资源开销大的重要原因。
3.3 卡住的本质:进程的状态机只有"运行"和"等待"
如果你用ps aux去查看一个正在处理请求的Worker进程,它的状态大概率是S(sleeping)而不是R(running)。为什么?因为CPU大部分时间都在等待I/O完成,进程被操作系统挂起,让出CPU时间片。
这个"等待"是整个文章的核心。操作系统对进程的行为管理非常基础:进程发起系统调用去读一个文件、读一个网络包,如果数据没有准备好,进程调用nanosleep或者select/poll/epoll_wait进入睡眠状态。等数据准备好了,内核会把进程唤醒,进程再继续执行。
问题在于:PHP脚本里的代码是顺序执行的。一个curl_exec()没有返回,下面100行代码就不会执行。这个Worker就在等待中被挂起。如果这个等待永无止境(比如没有设置超时时间的外部接口),这个Worker就永远挂在那个系统调用上。pm.max_children又限制了Worker总数,结果就是服务并发能力被一步步蚕食。
我用一个表格来直观展示不同I/O操作会卡在哪里:
| I/O操作 | 等待的系统调用 | 常见卡死原因 |
|---|---|---|
file_get_contents('http://...') | connect、recvfrom | 目标服务器无响应,无超时时间 |
curl_exec() | sendto、recvfrom、poll | 外部接口处理时间过长 |
mysqli_query() | read(读socket) | 数据库慢查询、锁等待 |
Redis->get() | read(读socket) | Redis阻塞命令(如KEYS *)、网络分区 |
session_start() | flock(等待文件锁) | 同一Session的另一个请求未释放锁 |
file_put_contents() | fsync、fdatasync | 磁盘I/O故障、存储饱瓶颈 |
3.4 一个真实的阻塞链路推演
假设我们有一个接口/order/create,它内部做了以下事情:
public function create() { // 1. 校验用户身份,查Redis $user = $this->redis->get('user:' . $this->uid); // 2. 查询商品库存,查MySQL $inventory = $this->db->query("SELECT stock FROM goods WHERE id = " . $id); // 3. 调用第三方物流接口 $shippingFee = $this->httpClient->post('https://api.shipping.com/calc', $payload); // 4. 生成订单,再次写MySQL $this->db->exec("INSERT INTO orders ..."); // 5. 清理购物车(Redis) $this->redis->del('cart:' . $this->uid); }如果第2步的MySQL查询因为某个大事务长时间未提交导致锁等待,这个Worker就卡在了read系统调用上。假设有50个并发请求都卡在同一个锁上,50个Worker全部占满,FPM彻底无法处理新请求。
更隐蔽的是第3步。如果第三方物流接口一直不返回(对方服务器出问题),而代码里没设置超时,这个Worker也会挂死。这种问题排查起来比数据库锁更难,因为外部服务不在你的监控范围内,你甚至不知道它已经出问题。
4. 卡住的根源:同步阻塞I/O与进程资源的边界
4.1 同步阻塞的含义:程序视角 vs 系统视角
从程序员的角度看,file_get_contents()隐藏了所有细节,好像一行代码马上就能拿到结果。但从操作系统的角度看,这个函数内部经历了大致如下步骤:
- DNS解析(可能阻塞几十毫秒到几秒)
- 发起TCP连接(可能阻塞几十毫秒到几秒)
- 发送HTTP请求(几乎瞬时)
- 等待响应(可能阻塞几秒到永不超时)
- 读取响应体(按TCP窗口大小分批次读取)
任何一个步骤的耗时,都直接占用Worker进程的生命周期。同步阻塞I/O的模型特征就是:I/O操作进行时,当前线程/进程的CPU时间被让出,但业务逻辑执行权也被冻结。你没法在等待I/O的同时去干别的活儿。
4.2 对比:事件驱动模型为什么没有这个问题
理解一下Node.js的事件循环模型,有助于更强烈地感知PHP-FPM模型的限制。Node.js是单线程的,但它利用事件循环机制,把所有I/O操作都丢给底层线程池(libuv),自己只负责调度。当某个I/O完成时,事件循环触发对应的回调函数,继续执行后续代码。
用餐厅类比:Node.js的服务员不站在某桌客人旁边等,而是把菜端上桌记录一下"客人A需要加水",然后转身去服务客人B。水烧好了、该加水了,再去A桌处理。一个服务员就能应对几十桌客人。
PHP-FPM不是没有异步能力,PHP作为语言层面有pcntl_fork(可以fork子进程),有stream_select(可以做非阻塞I/O监听),甚至可以用Swoole扩展实现协程。但默认的PHP-FPM就是同步阻塞模型,框架和业务代码都是基于"顺序执行、拿到结果再继续"的假设编写的。你要让PHP-FPM模式下运行的一堆同步代码变成异步,需要大量重构,成本极高。
4.3 为什么说"一个请求卡住,整个Worker进程闲置"
回到题目。"闲置"这个词用得特别精确。这个Worker进程并不是真的被占用——比如在做大量CPU计算,它是"空转等待",像是一个服务员站在客人旁边看着客人发呆,不能去服务别人,但实际上什么有用的活儿也没干。
ss -tnp可以看到这个进程的TCP连接,strace -p PID可以看到它卡在哪个系统调用上:
shell> strace -p 12345 recvfrom(3, 0x7ffc9b6e45f0, 8192, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable) poll([{fd=3, events=POLLIN}], 1, 30000这个poll等待30秒,如果设置的超时是30秒,然后循环再来一轮,无限等待。这就是卡住Worker的真面目。
4.4 进程参数和系统资源的联动
除了Worker数量本身,还有一些系统层的参数会加剧问题:
net.core.somaxconn和listen.backlog:控制accept队列长度。FPM主进程能排队的未处理连接数是有限的,默认backlog可能是128或512。一旦Worker全部占满且accept队列满了,新的连接会被内核直接拒绝,表现为"连接被重置"。pm.max_requests:每个Worker处理一定数量的请求后自动退出,由Master重新fork。这个参数的本意是防止内存泄漏累积,但设得过大可能导致一个Worker带病工作很久;设得过小会导致频繁创建销毁进程的额外开销。request_terminate_timeout:如果设置了这个值,Worker执行超过指定时间会被Master强制杀掉。听起来像救命稻草,但要注意:这个超时是"硬杀",如果这时正在操作数据库或写文件,可能导致状态不一致或者产生僵尸数据。
5. 实战排查:当请求卡住时,如何精准定位阻塞位置
5.1 第一时间该拉取的指标
发现FPM卡死后,先不要急着重启。重启虽然能快速恢复服务,但会丢掉现场,下次还会踩坑。正确操作是:
- 查看当前Worker数量和各状态:
ps aux | grep php-fpm正常情况有master process和若干pool www进程。如果大量进程同时存在且长时间不退出,说明有请求长时间占着Worker。
- 查看FPM状态接口。如果你在
php-fpm.conf里配置了pm.status_path,就可以通过curl获取:
curl http://127.0.0.1/phpfpm_status?fullfull参数会列出每个进程的执行状态(idle还是running),以及每个进程正在执行的脚本路径。
- 看系统负载和
mpstat:如果%iowait很高,很可能是磁盘I/O问题;如果%soft高,可能是网络包处理太多;如果是MySQL的CPU高,多半是慢SQL拖垮了MySQL,间接拖垮了PHP。
5.2 FPM慢日志:最省事的定位手段
PHP-FPM内置的慢日志功能是排查这个问题最直接的武器。配置如下:
slowlog = /var/log/php-fpm/www-slow.log request_slowlog_timeout = 5s当某个请求执行时间超过5秒,PHP-FPM就会在慢日志里记录当时的堆栈(配置文件里可设置request_slowlog_trace_depth控制深度)。堆栈直接告诉你PHP代码执行到哪里,卡在哪个函数调用。
不过慢日志只能覆盖"在PHP代码层面卡住"的场景。如果卡在系统调用上,比如curl_exec已经进入内核态等待网络数据,慢日志依然能记录堆栈让你知道是curl_exec这行(前提是PHP函数的调用栈能打出来)。实测下,curl_exec和file_get_contents等场景,慢日志记录到函数名是没问题的。
5.3 strace:微观视角看系统调用
慢日志告诉你卡在哪个PHP函数,strace告诉你卡在哪个系统调用,两者配合可以精确到具体进程在等什么:
# 查看PID为12345的Worker进程的系统调用 strace -p 12345 -t -T -e trace=network,read,write,select,poll,connect,recvfrom如果卡在connect上,说明TCP连接建立阶段超时;卡在recvfrom上说明发送请求后等不到响应;卡在poll上说明在等待更多的数据到来。-T参数可以显示每个系统调用的耗时,帮助你判断是不是长时间停留在这一个调用上。
5.4 数据库和缓存的侧写
如果pstack显示卡在PDO::query或mysqli_query,那就要确定是MySQL的问题还是PHP的问题:
SHOW FULL PROCESSLIST;这条命令会列出所有正在执行的SQL及其状态。重点关注State字段:Waiting for table metadata lock说明有DDL操作阻塞,Waiting for row lock说明有行锁冲突,Sending data说明在执行大查询。
如果是Redis,可以用slowlog get查看慢命令。特别注意KEYS *和HGETALL这类大key操作,阻塞Redis的同时也会引发PHP的等待。
5.5 一次完整的定位过程复盘
以开头那个案例继续展开:
- 我使用
ps aux | grep php-fpm发现10个Worker中有4个处于运行状态(R或者S,CPU时间在增长,但业务日志里没对应请求)。 - 使用
curl http://127.0.0.1/phpfpm_status?full查看状态页,发现4个进程对应的请求URI各不相同,但都超过30秒没结束。 - 查看
/var/log/php-fpm/www-slow.log,发现两个请求卡在file_get_contents(),一个卡在curl_exec(),一个卡在mysqli_query()。 - 查看MySQL进程列表,
mysqli_query对应的SQL其实只执行了1毫秒,问题在于PHP进程等待获取MySQL连接时阻塞了。原来数据库连接数已经满了,PHP申请不到新连接,只能排队等待。 - 分析
file_get_contents的目标URL,发现是第三方物流接口,对方的服务器已经宕机了。由于代码里没设置超时时间,它一直等不回来。 - 根源浮出水面:第三方接口宕机导致几个Worker被占住,这几个Worker持有的MySQL连接迟迟不释放,导致新的请求无法获取数据库连接,只能排队。新增的请求越来越多,排队连接也越来越多,最终所有Worker都被拖垮。
这种连锁反应很典型:单一阻塞点会连带到数据库连接、Redis连接等共享资源,形成连锁雪崩。
6. 对症下药:从代码到架构分层解掉这个死结
6.1 代码层:强制超时是底线
**绝对不允许任何外部资源调用不设超时。**这是PHP-FPM模式下最有价值的防护性编程习惯。
看一个反例:
$content = file_get_contents('http://third-party-api.com/get'); // 没有设置超时,对方不返回,这里永远卡住正例:
$ctx = stream_context_create([ 'http' => [ 'timeout' => 3, // 3秒超时 'ignore_errors' => true, ], ]); $content = @file_get_contents('https://third-party-api.com/get', false, $ctx);curl设置超时的推荐写法:
$ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 3, // 连接超时3秒 CURLOPT_TIMEOUT => 5, // 总超时5秒 ]); $result = curl_exec($ch);连接超时和总超时都要设置。连接超时只控制建立TCP连接的时间,如果服务器接受了连接但不返回数据,连接超时不生效,还需要总超时兜底。
6.2 数据库层:从连接池到SQL治理
MySQL连接获取也要设置合理的超时。PDO的构造方法支持超时参数:
new PDO($dsn, $user, $pass, [ PDO::ATTR_TIMEOUT => 3, ]);更推荐的方向是引入数据库连接池,让PHP-FPM进程申请连接时有明确的等待上限。在云原生环境里,这通常通过Proxy层(如ProxySQL、MaxScale)或专门的连接池服务来解决。
SQL层的治理包括:慢查询治理、索引优化、锁冲突检测。你在EXPLAIN一个SQL时发现type=ALL(全表扫描),就要警惕是不是业务高峰期拖垮了整个数据库。可以在my.cnf里设置:
slow_query_log = ON long_query_time = 1 log_queries_not_using_indexes = 16.3 架构层:把慢操作挪出请求链路
代码设了超时,只是止损,不是最优解。所有耗时的、不稳定的操作,都应该想办法挪出同步请求链路。
以刚才的物流费用计算为例,这个接口耗时没有准数,有时几十毫秒,有时几十秒,不应该让用户同步等待。可以改成:
- 用户提交订单,先把订单写入数据库,状态记为"待计算运费"。
- 把"订单ID"推入消息队列(Redis list / RabbitMQ / Kafka)。
- 一个后台任务进程(或者定时任务)从队列里取出订单,调用物流接口,把运费回填,更新订单状态。
- 用户端通过轮询或者刷新页面获取最新状态。
这样即便第三方接口宕机,吞掉的是后台任务的执行速度,对nginx和php-fpm的Worker完全无感。
6.4 Worker数量:调参的艺术
pm.max_children调大能解决问题吗?部分解决,但是有代价。
每个PHP-FPM Worker会占用一定的内存(实测Helicopter架构下,简单Laravel应用每个Worker约30-60MB,裸PHP脚本约10-20MB)。50个Worker就是1.5-3GB内存。如果机器只有4GB内存,再开50个Worker,内存交换到磁盘反而更糟。
推荐的调参思路是:
- 通过监控确定单个Worker的峰值内存占用,用
free -m看剩余内存,倒推合适的max_children。 max_children设计为"正常并发峰值的1.5倍"即可,留出缓冲但不要盲目翻倍。- 开启
pm.status_path,观察max_active_children指标,看实际达到的并发峰值,动态调整。 - 配合Nginx侧
limit_req对恶意请求或突发流量做限流,避免瞬时流量打满Worker池。
6.5 终极方案:突破PHP-FPM的模型局限
如果你的业务请求量大、依赖外部服务多、对超时容忍低,PHP-FPM模型就成为一个瓶颈了。此时可以认真评估这些方案:
- Swoole扩展:让PHP脚本运行在常驻内存的进程中,利用协程实现异步I/O。同一个Worker进程可以同时处理成千上万个并发请求,某个请求卡住不会阻塞其他请求。但对现有代码的侵入性比较大,需要项目里没有不能用协程的阻塞代码(实际很难)。
- ReactPHP / Amp 等纯PHP异步框架:同样能实现事件驱动,但生态不够完善,生产级使用经验少。
- 将高IO业务用Go/Rust重构:常见做法是保留PHP-FPM做管理后台等低并发业务,将高并发的部分用Go重构成独立微服务,PHP通过HTTP或RPC调用。这个迁移成本可能较高,但在长期收益上是值得的。
而且,更高层次的架构思路是:不要把所有的稳定性和性能诉求都压在PHP这一层,前面用Nginx+OpenResty做限流和超时控制,后面用消息队列削峰填谷,中间的服务做到无状态可水平扩展。PHP-FPM的同步阻塞模型可以不完全被看作是缺陷,而是把它放在一个"部分业务"的位置上,统筹架构来规避弱点。
7. 真实事故里的经验沉淀:我的防护清单
最后分享几条我在处理过很多类似事故后总结出的硬性规矩:
第一条,代码规范层面强制要求:所有外部I/O必须显式指定超时。我在团队里用PHP_CodeSniffer的Generic.PHP.Syntax段做静态检查只能查语法错误,超时问题需要Code Review重点盯。后来写了个简易脚本,扫描代码里所有file_get_contents、curl_init、mysqli_connect、new PDO的调用位置,要求每个后面必须有超时参数。
第二条,FPM状态页和慢日志默认打开。我以前觉得慢日志只会在出现问题的时候看,后来发现它在排查"某个时间段为什么接口慢"时价值极高。request_slowlog_timeout设成2-3秒,一旦有超过2秒的执行就能记录现场,两周后回查日志,大概率能提前发现隐患。
第三条,监控FPM的max_active_children且配置告警。如果发现活跃进程数持续排在max_children的80%以上,说明并发处理能力快到临界点了,不等卡死再处理。
第四条,不要依赖request_terminate_timeout做超时兜底。这个超时直接强杀进程,有可能导致MySQL连接被异常终止、事务没有提交或者回滚,产生不完整的脏数据。它适合作为最后的防御措施,不应该替代代码级别的超时设置。
做PHP这么多年,我越来越觉得:**每种技术都有它的边界,PHP-FPM的同步阻塞模型恰是它的一个明显边界。**知道边界在哪、怎么绕过边界、怎么在边界内把防护做到位,恰恰是"庖丁解牛"的可行性路径。理解这个模型,比盲目追求新框架新特性,更能在关键时刻让你冷静地止损。