502/504根因排查:PHP-FPM进程耗尽的实战修复
发布日期: 2026/08/09 阅读总量: 0

凌晨1点47分,502报警 那台服务器是台4核8G的京东云主机,跑着Nginx 1.24.0 + PHP-FPM 8.2 + Laravel 11。平时每秒三四百请求,稳得很。 结果周一凌晨,线上突然开始大量502。打开Nginx错误日志:
tail -f /var/log/nginx/error.log
刷过去的全是同一句话:
[error] 12345#0: *678910 connect() failed (111: Connection refused) while connecting to upstream, client: 1.2.3.4, server: api.example.com, request: "POST /api/order/create HTTP/1.1", upstream: "fastcgi://unix:/var/run/php-fpm.sock:"
`111: Connection refused`——PHP-FPM已经把连接拒了。我第一反应是重启FPM:
systemctl restart php-fpm
sleep 2
curl -I https://api.example.com/health
好了,恢复了。但过了不到10分钟,又502了。这才意识到不是偶发故障,是资源被吃满了。 那晚折腾到3点半,换了三种排查工具,最后定位到根因:PHP-FPM的进程数配置严重不合理,导致进程池被占满。这篇文章把完整的排查链路、方案对比和代码实现全部写出来。

502 vs 504,先分清是哪一端的问题 很多人把502和504混在一起排查,其实这两个错误码指向完全不同的故障点:
错误码 含义 发生在哪一层 典型原因
502 Bad Gateway Nginx无法连接上游(PHP-FPM/Java/Node) TCP连接建立阶段 FPM进程池耗尽、FPM崩溃、socket文件权限错误、FPM未启动
504 Gateway Timeout Nginx成功连接上游,但等待响应超时 HTTP请求处理阶段 慢SQL、死锁、外部API调用卡死、fastcgi_read超时设置过短
排查方向: - 502重点看FPM进程数量和状态 - 504重点看应用执行耗时和上游接口耗时 我遇到的是502,聚焦FPM进程池。

问题定位:三条命令 + 一个压测工具

第一步:看FPM进程池实时状态 PHP-FPM自带状态接口,先打开它。编辑`/etc/php-fpm.d/www.conf`(PHP 8.2.5):
; 开启状态页
pm.status_path = /php-fpm-status
然后在Nginx配置里放行这个路径:
location ~ ^/php-fpm-status {
    fastcgi_pass unix:/var/run/php-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    # 内网监控用,外网必须加访问控制
    allow 127.0.0.1;
    allow 10.0.0.0/8;
    deny all;
}
重载配置后,直接curl状态页:
curl http://127.0.0.1/php-fpm-status
输出关键行:
pool: www
process manager: dynamic
start time: 28/Aug/2024:00:12:33 +0800
start since: 5312
accepted conn: 45239
listen queue: 0
max listen queue: 0
listen queue len: 128
idle processes: 0
active processes: 30
total processes: 30
max active processes: 30
max children reached: 42
注意最后一行:`max children reached: 42`。这是在说:**进程数已经被顶到上限42次了**。总进程数30,说明`pm.max_children`配置的就是30,但这台4核8G的机器,FPM进程平均吃掉150MB内存,30个进程就是4.5GB,加上Nginx、MySQL、Laravel框架本身的内存开销,机器直接进入swap。

第二步:同时看系统内存
free -h
当时输出:
              total        used        free      shared  buff/cache   available
Mem:          7.6Gi       7.1Gi       156Mi       12Mi       452Mi       213Mi
Swap:          2.0Gi       1.9Gi       92Mi
内存只剩156MB,swap都用了1.9GB。这就是根因一。

第三步:用wrk压测复现 重启FPM后,用wrk 4.2.0压测接口,20秒内就能复现:
wrk -t4 -c200 -d30s --latency https://api.example.com/health
结果:
Running 30s test @ https://api.example.com/health
  4 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.92s     3.15s    11.53s    87.32%
    Req/Sec    87.14     69.42   339.00     66.24%
  Latency Distribution
     50%    1.23s
     75%    2.87s
     90%    5.32s
     99%   10.11s
  4576 requests in 30.01s, 1.02MB read
  Socket errors: connect 0, read 0, write 0, timeout 1308
  Non-2xx or 3xx responses: 1308
1308个请求失败,错误率28.6%。而且注意延迟:p50是1.23秒,p99超过10秒——这不是正常的健康检查接口该有的表现。

第四步:看FPM日志确认崩溃规律
grep -E "WARNING|ERROR|max_children" /var/log/php-fpm.log | tail -20
日志里的规律是:
[28-Aug-2024 01:23:44] WARNING: [pool www] server reached pm.max_children setting (30), consider raising it
[28-Aug-2024 01:26:12] WARNING: [pool www] server reached pm.max_children setting (30), consider raising it
每2-3分钟就顶到上限一次。

三种方案对比

方案一:无脑调大max_children

这种方案最直接,也是很多人第一反应。
pm = dynamic
pm.max_children = 80
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30
后果:80个进程 × 150MB = 12GB内存,8G的机器直接OOM。机器直接卡死。 **结论:不可行**,除非加内存到16GB以上。但加内存还要考虑CPU——FPM进程多起来后,CPU跑满照样503。这台机器CPU核数只有4,80个FPM进程同时跑,上下文切换开销会拖垮整个系统。

方案二:调高Nginx超时时间

proxy_connect_timeout 75s;
proxy_read_timeout 300s;
fastcgi_connect_timeout 75s;
fastcgi_read_timeout 300s;
fastcgi_send_timeout 300s;
这个方案解决的是504,不是502。502是连接都建立不了,超时设置再长也没用。而且调高`fastcgi_read_timeout`会让用户等待时间更长,体验更差。 **结论:治标不治本**,只能掩盖问题。

方案三:动态调优 + 健康检查自动摘除(最终方案)

分两步: 1. **合理设置PHP-FPM进程模型**:调整为`static` + 设置进程回收,充分利用现有8G内存,同时避免进程无限积压 2. **健康检查 + 自动摘除**:增加一个轻量的health endpoint,定时检测FPM状态,连续N次失败就自动重启FPM并将节点从Nginx upstream摘除 **结论:这是根因解法。**

最终方案:完整代码实现

第一步:调整PHP-FPM配置

以PHP 8.2 + 4核8G + Laravel 11为例,最终配置如下:
; /etc/php-fpm.d/www.conf (PHP 8.2.5)
; 使用static模式,因为流量曲线是稳定的
; static模式FPM启动时就创建固定数量的worker,避免了dynamic模式频繁创建/销毁进程的开销
pm = static

; 关键计算:8G内存,系统预留2G,MySQL预留2G,Nginx+其他预留0.5G
; 剩余3.5G给PHP-FPM,每个worker约150MB
; 3500MB / 150MB ≈ 23,留一点buffer,取20
pm.max_children = 20

; 每个worker处理500个请求后自动回收,防止内存泄漏累积
pm.max_requests = 500

; 慢日志阈值——方便定位哪些请求拖慢了FPM
slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout = 5s
注意:`pm.max_children = 20`比原来的30还小。这反直觉对吧?后面有数据对比。

第二步:配置Nginx超时和健康检查端点

Nginx 1.24.0配置:
# /etc/nginx/conf.d/api.conf
upstream php_backend {
    # 用unix socket而不是TCP,减少TCP握手开销
    server unix:/var/run/php-fpm.sock;
}

server {
    listen 80;
    server_name api.example.com;

    # 健康检查端点:轻量,不依赖数据库
    location = /health {
        access_log off;
        fastcgi_pass php_backend;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /var/www/html/api/public/health.php;
        # 健康检查必须快速失败,不能拖死FPM
        fastcgi_connect_timeout 2s;
        fastcgi_read_timeout 2s;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
        fastcgi_pass php_backend;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

        # 业务接口超时:连接5s,读取30s
        # 502是连接失败,调超时没用;504才是等待超时
        fastcgi_connect_timeout 5s;
        fastcgi_read_timeout 30s;
        fastcgi_send_timeout 30s;

        # 开启keepalive连接复用,减少FPM握手压力
        keepalive 32;
    }
}

第三步:健康检查脚本

#!/bin/bash
# /usr/local/bin/fpm-healthcheck.sh
# 功能:检测FPM健康状态,连续3次失败自动重启FPM
# 配合cron每30秒执行一次

STATUS_URL="http://127.0.0.1/health"
FAIL_COUNT_FILE="/tmp/fpm_health_fail_count"
MAX_FAILURES=3
LOG_FILE="/var/log/fpm-healthcheck.log"

log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE"
}

# 1. 请求health端点,要求1秒内返回200
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 1 --max-time 2 \
    -H "Host: api.example.com" "$STATUS_URL" 2>/dev/null)

if [ "$HTTP_CODE" = "200" ]; then
    # 成功:重置失败计数
    rm -f "$FAIL_COUNT_FILE"
    exit 0
fi

# 2. 失败:累加计数
FAIL_COUNT=0
if [ -f "$FAIL_COUNT_FILE" ]; then
    FAIL_COUNT=$(cat "$FAIL_COUNT_FILE")
fi
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "$FAIL_COUNT" > "$FAIL_COUNT_FILE"

log "health check failed, HTTP_CODE=$HTTP_CODE, fail_count=$FAIL_COUNT"

# 3. 连续失败达到阈值,重启FPM
if [ "$FAIL_COUNT" -ge "$MAX_FAILURES" ]; then
    log "ALERT: FPM unhealthy, restarting php-fpm..."
    systemctl restart php-fpm

    if [ $? -eq 0 ]; then
        log "php-fpm restarted successfully"
        # 冷却30秒再恢复计数
        sleep 30
        rm -f "$FAIL_COUNT_FILE"
    else
        log "ERROR: php-fpm restart failed"
        # 重启失败可以接告警通知,如钉钉/企业微信
        curl -s -X POST "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN" \
            -H 'Content-Type: application/json' \
            -d '{"msgtype":"text","text":{"content":"[P1] PHP-FPM重启失败,请立即处理"}}' > /dev/null
    fi
fi

exit 0
注册到cron,每30秒执行一次:
crontab -e
*/1 * * * * /usr/local/bin/fpm-healthcheck.sh
* * * * * sleep 30; /usr/local/bin/fpm-healthcheck.sh

第四步:健康检查的PHP文件

这个文件必须够轻,不能加载整个Laravel框架,否则健康检查本身会加重FPM负担:
 'ok',
    'time' => date('c'),
    'memory_bytes' => $memory,
    'uptime' => file_get_contents('/proc/uptime') ?? null,
];

// 检查磁盘空间
$diskFree = disk_free_space('/');
if ($diskFree !== false && $diskFree < 500 * 1024 * 1024) {
    http_response_code(500);
    $status['status'] = 'disk_low';
    $status['disk_free_bytes'] = $diskFree;
    echo json_encode($status);
    exit;
}

http_response_code(200);
header('Content-Type: application/json');
echo json_encode($status);

第五步:压测验证新配置

调整配置并重启FPM+nginx:
systemctl restart php-fpm nginx
sleep 3
# 确认进程数
ps aux | grep php-fpm | grep -v grep | wc -l
# 输出 20

# 确认内存占用
ps aux | grep php-fpm | grep -v grep | awk '{sum+=$6} END {print sum/1024, "MB"}'
然后重新用wrk压测:
wrk -t4 -c200 -d30s --latency https://api.example.com/health

效果数据:调整前后对比

压测结果对比(wrk -t4 -c200 -d30s,同一台机器,同一接口)

指标 调整前(max_children=30, dynamic) 调整后(max_children=20, static) 变化
请求总数 4576 40215 +778%
失败请求 1308 0 -100%
错误率 28.6% 0% -28.6 pct
平均延迟 1.92s 302ms -84%
p99延迟 10.11s 752ms -92.6%
QPS 152 1340 +781%
FPM内存占用 4.5GB(30进程×150MB) 3.2GB(20进程×160MB) -28.9%
max_children reached 42次/小时 0次 -100%
注意几个点: 1. **QPS提升781%不是因为max_children从30变成了20**,而是因为之前进程池被占满后,新的请求全部排队等待,导致延迟飙到10秒以上。现在没有排队了,每个worker都能快速响应 2. **static模式比dynamic模式快**。dynamic模式下,FPM会动态创建和销毁进程,每次创建需要fork+分配内存,开销很大。static模式下进程是常驻的,请求来了直接处理。实测同配置下static比dynamic快8-12% 3. **内存占用反而降低了28.9%**。因为dynamic模式在请求高峰时会创建大量进程,这些进程不会再销毁(pm.max_spare_servers兜底),内存一直高水位。static模式固定20个进程,内存可控

线上稳定运行30天结果

修复完成后的效果,从监控系统拉取的数据:
运行时长:30天
502次数:0
504次数:2(均为单次外部API超时触发)
FPM内存占用:稳定在3.2-3.5GB
FPM重启次数:1(健康检查脚本触发,原因是临时磁盘空间不足)
CPU使用率:峰值65%,日常30-40%
平均响应时间:220ms(压测数据是302ms,线上略低于压测)
避坑段落

避坑指南

以下5个坑,都是这30天里实际踩过的。

坑一:健康检查脚本里用了数据库查询

第一次写健康检查脚本时,我在health.php里查了一次数据库:
$pdo = new PDO('mysql:host=127.0.0.1;dbname=app', 'user', 'pass', [PDO::ATTR_TIMEOUT => 3]);
$pdo->query("SELECT 1");
结果数据库抖动的时候,健康检查挂起3秒,curl的`--max-time 2`超时,连续3次判定FPM不健康,自动重启了FPM。但实际上FPM完全正常。 **教训:健康检查必须够轻,不依赖任何外部服务**。最终版本只检查内存和磁盘。

坑二:调max_children只看内存,没算CPU 当时算的是8G内存 / 150MB = 50个进程没问题,但忽略了CPU只有4核。FPM的worker是CPU密集型——一旦PHP代码有慢逻辑,50个worker同时跑,CPU直接跑满。 **教训:`max_children`的上限是`min(内存上限, CPU核数 × 5)`**。4核CPU最多设置20-25个worker,多了CPU上下文切换就会吃掉性能。

坑三:max_requests设置过小导致频繁回收 `pm.max_requests`最初设的是100。然后发现FPM日志里有大量`NOTICE: [pool www] child 12345 started`的日志——每个worker处理100个请求就被回收了,创建新进程的开销反而拖慢了整体性能。 **教训:把`max_requests`设为500-1000比较合理**,既要防内存泄漏,又不能太频繁回收。100太激进,1000又有点多,500是平衡点。

坑四:Nginx upstream keepalive忘加了 调优后Nginx配置加了一个:
upstream php_backend {
    server unix:/var/run/php-fpm.sock;
    keepalive 32;  # 这个必须加
}
不加的话,Nginx和FPM之间的连接每次都要新建,TCP握手开销在压测的200并发下会额外损失约8%的QPS。加上后压测QPS从1240提升到1340。

坑五:FPM状态页没限制IP,直接被公网扫描攻击

PHP-FPM状态页的`allow`/`deny`配置一开始写的是:
location ~ ^/php-fpm-status {
    fastcgi_pass unix:/var/run/php-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    # 没写allow/deny
}
结果一天后被扫描器刷了几十万次,FPM日志刷屏,负载飙升。 **教训:状态页、非业务接口必须限制IP访问**。`allow 127.0.0.1;`是第一道防线,如果有多台内网机器需要访问,把`allow 10.0.0.0/8;`加进去。

总结一张常见原因排查对照表

现象 检查项 命令/日志位置
502,且Nginx报111 Connection refused FPM进程池是否耗尽 FPM状态页active processes = max_children
502,但FPM进程数正常 FPM是否崩溃,socket文件是否存在 ls -la /var/run/php-fpm.sock,FPM日志
502,重启FPM后恢复 FPM卡死/内存泄漏 查看FPM内存占用趋势,检查pm.max_requests
504,等待长时间后返回 应用执行时间超过fastcgi_read_timeout PHP-FPM慢日志,MySQL慢查询日志
504,请求瞬间返回 Nginx与FPM连接被拒或上游挂起 Nginx access log的upstream_response_time字段
间歇性502/504 MySQL连接池耗尽 MySQL SHOW PROCESSLIST; 查看连接数
这篇从实战角度写的,核心思路就一句话:**先分清502是连接建不上,504是等不到响应,然后根据具体错误信息找到是哪一层的问题,最后用数据和压测来验证修复方案**。别靠猜,看日志,看数据。