PHP-FPM进程飙高:从僵死到复原的排查实战
发布日期: 2026/08/18 阅读总量: 0

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 queue2560-3请求排队严重,FPM处理不过来
max listen queue128已经触发过排队上限
max active processes91进程被全部占满
max children reached20出现过进程数到达上限

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。取中位数:

指标修复前修复后变化
QPS112358↑ 219%
平均响应时间3214 ms243 ms↓ 92.4%
P95响应时间5021 ms418 ms↓ 91.7%
FastCGI队列积压>5000完全清空
php-fpm进程busy数902-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监控提前配好,别等故障时再装工具。