1. 凌晨的告警:90个php-fpm全部busy,CPU钉死在100%
凌晨1点47分,监控弹出一条告警:api-02生产服务器 CPU 100%持续5分钟。值班同事打开服务器看了一眼,php-fpm进程列表拉了一屏,90个进程全部处于busy状态,每个进程CPU占用95%以上。
第一反应是重启php-fpm,重启后好了3分钟,然后再次飙满。这不是偶发流量,是代码或配置层面的问题。
这台服务器配置:PHP 8.1.22(FPM)、Laravel 10、MySQL 8.0.35、Nginx 1.24,单机4核8G,部署了一个对外API服务,日常QPS约300。
当时请求耗时从正常的80ms直接飙到10秒以上,Nginx日志里大量upstream timed out。整个服务处于半瘫痪状态。
这篇文章不是讲理论,是记录我当时完整的排查链路、每一步的验证数据,以及踩了哪些坑。直接说结论:根因是外部API调用没有超时控制,加上php-fpm慢日志没开,导致问题藏了很久。但排查过程远比这个结论有参考价值。
2. 第一轮排查:先确认是PHP层还是外部依赖
排查这种问题,优先级是:先看进程状态 → 再分流量 → 再看慢日志 → 最后strace。逐步缩范围,别跳步。
第1步:确认php-fpm进程状态
# 查看php-fpm进程状态,确认是否真的全部busy
ps aux | grep php-fpm | grep -v grep | awk '{print $8, $3, $4, $11, $12}' | head -50
输出结果里全部是R(Running)状态,CPU 90%以上,不是D(不可中断睡眠)状态。D状态一般是IO卡死,R状态说明进程在疯狂消耗CPU,是代码死循环或内部阻塞重试。
第2步:看PHP-FPM状态页
# 先确认php-fpm状态页是否开启(php.ini或pool配置里)
# 如果没开,先临时开一下
# /etc/php/8.1/fpm/pool.d/www.conf 中添加:
# pm.status_path = /status
curl http://127.0.0.1/status?full
关键输出解读:
| 参数 | 当前值 | 正常值 | 说明 |
|---|---|---|---|
| listen queue | 256 | 0-3 | 请求排队严重,FPM处理不过来 |
| max listen queue | 128 | — | 已经触发过排队上限 |
| max active processes | 91 | — | 进程被全部占满 |
| max children reached | 2 | 0 | 出现过进程数到达上限 |
active processes和listen queue同时飙高,说明所有FPM worker都在干活,但没人能干完。这种情况多半是卡在某个外部调用上,而不是普通的高负载。
3. 方案对比:三种排查手段
当时并行尝试了三种手段。直接列对比:
| 手段 | 定位能力 | 耗时 | 结论 |
|---|---|---|---|
| php-fpm slow log(慢日志) | 定位卡住的具体PHP代码文件和行号 | 需要等1-2分钟产生样本 | 推荐首选,成本最低 |
| strace -c 聚合跟踪 | 定位阻塞在哪个系统调用 | 3分钟内有明确结论 | 推荐,能区分网络IO、文件IO、锁等待 |
| opcache状态检查 | 定位字节码缓存是否失效导致重复编译 | 1分钟 | 必要,先用它排除缓存问题 |
我建议的顺序是:先opcache → 再慢日志 → 最后strace。opcache最便宜,慢日志最直接,strace最暴力。
4. 第二步:opcache状态检查 —— 排除缓存失效
原因:PHP 8.1默认opcache开启,但如果配置了opcache.validate_timestamps=0且代码部署后没reload,会有大量缓存未命中。先花1分钟确认。
php -i | grep opcache
# 关键参数检查
# opcache.enable => On
# opcache.memory_consumption => 128
# opcache.max_accelerated_files => 10000
# 如果命中率低,说明缓存配置有问题
# 通过脚本看实际命中率:
<?php
// opcache_status.php 直接浏览器或CLI运行
$status = opcache_get_status();
if ($status) {
$hits = $status['opcache_statistics']['hits'];
$misses = $status['opcache_statistics']['misses'];
$total = $hits + $misses;
$rate = $total > 0 ? round($hits / $total * 100, 2) : 0;
echo "命中率: {$rate}% (hits: {$hits}, misses: {$misses})\n";
echo "内存使用: {$status['memory_usage']['used_memory']} / {$status['memory_usage']['free_memory']} free\n";
}
实测结果:命中率98.7%,内存使用35M / 128M,排除opcache问题。耗时1分钟。
5. 第三步:php-fpm慢日志 —— 一针见血
检查慢日志是否开启:
grep 'slowlog\|request_slowlog_timeout' /etc/php/8.1/fpm/pool.d/www.conf
结果:没开。这就是第一个坑——默认配置慢日志是关闭的,生产环境必须手动打开。
修改配置(/etc/php/8.1/fpm/pool.d/www.conf):
; 开启慢日志,超过2秒的请求记录堆栈
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 2s
; 放到slowlog里的调用栈深度
request_slowlog_trace_depth = 50
重启php-fpm,等2分钟,看慢日志:
tail -100 /var/log/php-fpm/slow.log
输出关键行:
[02-Jan-2025 01:52:11] [pool www] pid 17632
script_filename = /var/www/api/public/index.php
[0x7f1a2c3b4d50] /var/www/api/vendor/guzzlehttp/guzzle/src/Handler/CurlFactory.php:234
[0x7f1a2c3b4d20] /var/www/api/vendor/guzzlehttp/guzzle/src/Handler/CurlFactory.php:169
慢日志显示一堆请求全部卡在Guzzle的CurlFactory。这就是根因线索:代码里用Guzzle发外部HTTP请求,这个请求没有设置超时,导致PHP-FPM worker全部卡在等待外部响应上。
慢日志逻辑很简单:request_slowlog_timeout触发后,PHP-FPM会向该worker发送SIGSEGV信号来获取PHP调用栈(该操作不影响业务请求),然后把栈写入slow.log。这是排查PHP-FPM卡顿最直接的工具。
6. 第四步:strace验证 —— 坐实阻塞点
慢日志锁定了Guzzle的CurlFactory,再用strace验证一下阻塞在哪个系统调用上:
# 选择几个CPU高的php-fpm进程ID,用strace聚合3秒数据
strace -f -c -p $(pidof php-fpm | tr ' ' ',') -o /tmp/strace_out.txt -T -S syscall 2>&1
# -c 聚合统计
# -S syscall 按系统调用排序
# -T 显示耗时
# 更直接的方式:抓单个进程的实时调用
strace -p 17632 -t -e trace=network,read,write,connect,poll,select -o /tmp/strace_single.log
# 3秒后Ctrl+C,看日志
tail -50 /tmp/strace_single.log
输出结果:进程反复阻塞在poll([{fd=38, events=POLLIN}], 1, 30000),等待socket可读,读超时设置30秒。wraps了一个TCP连接始终没有响应。用ss -tanp | grep php-fpm确认了连接的目标IP——一个第三方物流API。
到这里,真相大白:代码里发起的第三方物流API请求没有设置超时,Guzzle默认的timeout是0(无限等待),再加上第三方接口故障不响应,90个FPM worker全部被挂起,新请求进不来,CPU因为连接重试和内存拷贝飙到100%。
7. 解决:代码层修复 + 配置层兜底
找到根因,修复分两层做。
修改1:代码层 —— Guzzle增加超时控制
<?php
// app/Services/LogisticsApiClient.php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\TimeoutException;
class LogisticsApiClient
{
private Client $client;
public function __construct()
{
$this->client = new Client([
'base_uri' => 'https://api.logistics.example.com',
// 连接超时3秒(TCP连接建立)
'connect_timeout' => 3.0,
// 整个请求超时5秒(连接+发送+等待响应)
'timeout' => 5.0,
// 读取响应超时,防止下载大文件卡死
'read_timeout' => 5.0,
// 最多重试2次,加上第一次请求共3次
'retries' => 2,
]);
}
public function query(string $trackingNo): array
{
try {
$response = $this->client->get('/api/track', [
'query' => ['tracking_no' => $trackingNo],
// 单次请求覆盖超时
'timeout' => 3.0,
'connect_timeout' => 2.0,
]);
return json_decode($response->getBody()->getContents(), true);
} catch (TimeoutException $e) {
// 记录日志,返回降级数据,不要让异常穿透到上层
\Log::warning('物流API超时', ['tracking_no' => $trackingNo]);
return ['status' => 'timeout', 'data' => []];
} catch (ConnectException $e) {
\Log::error('物流API连接失败', ['error' => $e->getMessage()]);
return ['status' => 'unreachable', 'data' => []];
}
}
}
修改2:php-fpm配置 —— 防止单请求拖死全局
; /etc/php/8.1/fpm/pool.d/www.conf
; 单个请求最长执行时间(分钟),Laravel队列任务可以适当拉长
; 常规API建议30s,防止死循环
request_terminate_timeout = 30s
; 慢日志必须开
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 2s
; 动态进程管理,避免高峰期无进程可用
pm = dynamic
pm.max_children = 30
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 15
; 每个请求最多处理500个请求后自动回收,防止内存泄漏
pm.max_requests = 500
修改3:Redis缓存 —— 第三方API响应降级缓存
第三方API不稳定是常态,不能每次都打上游。增加一个5分钟的短缓存做兜底:
<?php
public function queryWithCache(string $trackingNo): array
{
$cacheKey = 'logistics:track:' . $trackingNo;
// 先从Redis读
if ($cached = \Cache::get($cacheKey)) {
return $cached;
}
// 加锁,防止单号并发请求全部打到第三方API
$lock = \Cache::lock($cacheKey . ':lock', 3);
try {
if ($lock->get()) {
$result = $this->query($trackingNo);
if (isset($result['status']) && $result['status'] === 'success') {
\Cache::put($cacheKey, $result, 300); // 5分钟
}
return $result;
}
// 等待锁,最多2秒
\Illuminate\Support\Sleep::sleep(1);
return \Cache::get($cacheKey) ?? ['status' => 'degraded', 'data' => []];
} finally {
$lock->release();
}
}
8. 压测验证 —— 修复前后的数据对比
修复后,用wrk对线上API做了3轮压测,每轮压测包括:500并发、持续60s,服务器4核8G。取中位数:
| 指标 | 修复前 | 修复后 | 变化 |
|---|---|---|---|
| QPS | 112 | 358 | ↑ 219% |
| 平均响应时间 | 3214 ms | 243 ms | ↓ 92.4% |
| P95响应时间 | 5021 ms | 418 ms | ↓ 91.7% |
| FastCGI队列积压 | >500 | 0 | 完全清空 |
| php-fpm进程busy数 | 90 | 2-5 | 恢复正常 |
修复后持续观察48小时,CPU水位稳定在25-35%,php-fpm active processes峰值19个,再也没有触发过max_children。
压测命令(后续可以在测试环境复现验证):
# wrk压测,模拟真实场景:50%请求带物流查询参数
wrk -t4 -c500 -d60s --script=./post.lua http://api.example.com/api/order
9. 这次踩过的坑(避坑指南)
这次故障处理过程中,有些坑是排查工具本身带来的,有些是配置坑。全部列出来,不是凑字数,都是实际发生过的:
坑1:php-fpm慢日志默认关闭,等于慢性自杀
生产环境FPM安装后默认slowlog是空的,request_slowlog_timeout也为0(关闭)。没有慢日志,进程卡死的时候你只能靠猜或者strace。这次就是因为没开慢日志,多花了20分钟strace才定位。改完配置记得reload。
坑2:strace -c 聚合可能让进程卡死
对高负载php-fpm进程做strace要小心。第一次我用strace -f -c -p直接挂到全部进程上,命令执行后大约2秒,所有php-fpm进程全部卡住了。原因是-f会跟踪子进程,再加上-c聚合模式下信号处理有延迟。正确做法:先挑1-2个pid,用-t -e trace=network单独看,确认是网络问题后再用聚合。高负载环境下strace生产操作要提前评估风险并准备好回滚方案。
坑3:Max requests配置不当导致进程内存泄漏累积
这次上线后发现pm.max_requests=0(无限),PHP进程长期不回收,内存涨到2.5G后才被kill。虽然这次不是根因,但属于放大故障的因素。建议设置pm.max_requests=500,防止内存泄漏累积。如果业务高峰期进程回收太频繁,可以调到1000-2000。
坑4:Guzzle的timeout和connect_timeout完全不同
只设置timeout不设置connect_timeout,TCP连接本身的超时用的是PHP默认值(约120秒)。如果目标IP不可达(防火墙丢包),一个请求可能卡2分钟。这次第三方API故障是IP可达但服务不响应,但如果配置了connect_timeout,连接阶段的阻塞会被提前拦截。必须两个都设。
坑5:压测时不要用ab,要用wrk
ab在高并发下会自身成为瓶颈(单线程模型),我压测时先用ab跑500并发,ab自己先报了Cannot assign requested address,P95数据完全失真。换wrk后数据才靠谱。压测工具本身要符合场景。
坑6:杀掉php-fpm进程要分池操作
这次操作中另一个同事直接pkill -9 php-fpm想把所有进程清掉重启,直接导致了所有请求450(nginx 504),这个操作太过激进。实际应该用kill -USR2 $(cat /var/run/php-fpm.pid)优雅重载。如果必须要强杀,不要全杀,分批杀。
10. 后续优化监控
这类问题不能只靠告警才知道。事后加了一套监控指标,在prometheus + grafana上做了接入:
# prometheus/php-fpm-exporter配置示例(exporter用hipages/php-fpm_exporter)
scrape_configs:
- job_name: 'php-fpm'
static_configs:
- targets: ['10.0.1.2:9199']
metrics_path: '/metrics'
params:
# 需要FPM状态页访问权限
auth: ['your-token']
关注的指标:
php_fpm_active_processes—— 当前活跃进程数,超过pm.max_children的80%就要告警php_fpm_listen_queue—— 请求排队数,持续大于10说明处理能力不足php_fpm_slow_requests—— 慢请求数,pod重启后归零,但可以看增长速率php_fpm_max_children_reached—— 达到max_children的次数,出现则说明进程数不够或上游阻塞- 配合nginx的
$request_time和$upstream_response_time,可以快速区分FPM自身慢还是上游慢
同时在外部API调用端增加了一个AOP切面日志,记录每次外部请求的耗时分布。这样下次第三方API再抖动,grafana面板上能直接看到是哪一个外部接口的p95在飙升。
11. 总结两个核心认知
这次故障处理给了我几条实战经验,分享出来:
第一:PHP-FPM进程飙高的大概率是「等」出来的,不是「算」出来的。CPU高很多时候是优雅的假象,真正原因是大量worker阻塞在等待外部IO上,然后操作系统不断做上下文切换、内存拷贝,导致CPU上去了。strace看系统调用就能快速分辨。
第二:所有外部调用必须有超时、有降级、有缓存,缺一不可。超时保命,降级保体验,缓存保上游。原代码Guzzle全部用的默认配置(无超时),等于把整个服务的可用性绑在了一个不受你控制的第三方API上。修复后由于超时快速失败,加上Redis缓存兜底,即使第三方API再次抖动,也能保证当前服务只受影响2%的请求,而不是整体雪崩。
抓进程高不复杂,关键是按顺序排查:状态页 → opcache → 慢日志 → strace。每一步都有明确产出,不会白跑。核心是把慢日志和opcache监控提前配好,别等故障时再装工具。