504 Gateway Timeout 排查实战:分清 PHP-FPM 池满与慢请求,用 slowlog 调用栈定位根因

504 Gateway Timeout 到底是谁超时了

Nginx 返回 504,很多站长第一反应是"网站太慢",于是去优化数据库、加缓存,结果 504 照旧。这是因为 504 的含义被误读了:它不是"请求太慢",而是上游(通常是 PHP-FPM)在约定时间内没有把响应头交回来。搞清楚是哪一层先放弃等待,问题就解决了一半。本文按"判层 → 量池 → 读栈 → 治本"的顺序,把 504 的排查流程讲透。

三层超时时间,谁先到谁说了算

一个动态请求至少穿过三个超时设置,504 出现的时刻,取决于它们的相对大小:

  • fastcgi_read_timeout(Nginx 层,默认 60s):Nginx 等待 FastCGI 上游返回数据的最大间隔
  • request_terminate_timeout(PHP-FPM 层,默认不设或等于 max_execution_time):FPM 强杀工作进程的硬上限
  • max_execution_time(PHP 层,默认 30s):脚本自身的时间预算

典型故障是:PHP 层设了 max_execution_time = 0(不限制),FPM 层 request_terminate_timeout 也留空,但 Nginx 层保留默认 60s。一个执行 90 秒的接口,会在第 60 秒被 Nginx 判死,用户看到 504,而 PHP 进程还在后台跑,白占资源。反过来,如果 FPM 的 request_terminate_timeout 设成 30s 而 Nginx 是 60s,用户会先看到 502 Bad Gateway——因为 FPM 把进程杀了,Nginx 收到的是空响应。

记住这个对照关系:

  • Nginx 先超时 → 504,PHP 仍在跑
  • FPM 先杀进程 → 502,PHP 已死
  • PHP 自己超时 → 返回 500 或框架自己的错误页

正确的配置顺序是让 Nginx 的等待时间略大于 FPM 的硬上限,这样超过预算的请求由 PHP 层干净地抛错,而不是由 Nginx 粗暴切断:

# /etc/php/8.2/fpm/pool.d/www.conf
request_terminate_timeout = 55s
request_slowlog_timeout  = 10s
slowlog                  = /var/log/php-fpm/slow.log

# nginx.conf
fastcgi_connect_timeout 5s;
fastcgi_send_timeout    60s;
fastcgi_read_timeout    60s;

要注意 PHP 的 max_execution_time 在 FPM 下只计算 CPU 时间,不包含 sleep()、数据库等待和外部网络请求这类"阻塞但不耗 CPU"的时间。这就是为什么很多脚本明明设了 30 秒限制,却能跑几分钟——它一直在等 I/O,CPU 时间根本没用掉。真正能拦住这类请求的是 FPM 的 request_terminate_timeout,所以这个值必须显式设置,不能依赖 PHP 层。

第一步排查:确认 504 是 FPM 忙还是慢

在改任何超时之前,先搞清楚 504 的成因是"连接池被占满"还是"单个请求太慢"。这两个问题的解法完全相反,搞错了只会雪上加霜。

看 FPM 的状态页。开启方式是在 pool 配置里加:

pm.status_path = /fpm-status

然后在 Nginx 里只对本地放行:

location = /fpm-status {
    allow 127.0.0.1;
    deny all;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

访问后关注几个数字:

curl -s http://127.0.0.1/fpm-status?full
# 关键行:
# active processes: 25
# listen queue: 0
# max listen queue: 18
# slow requests: 3

判读规则很直接:active processes 等于 pm.max_children,且 max listen queue 大于 0,说明请求排队了——这是连接池不足。如果 active processes 远小于上限,但 slow requests 很大,说明是个别请求太慢拖住了工作进程。注意 max listen queue 是历史峰值,要看它是否持续增长;配合定时采样,能看出排队趋势:

for i in $(seq 1 10); do
  curl -s http://127.0.0.1/fpm-status | grep -E 'active|listen queue'
  sleep 2
done

情况 A:连接池不足,怎么算 max_children

调 pm.max_children 不能拍脑袋,它直接决定内存上限。先量出单个 PHP-FPM 进程的平均占用:

ps -ylC php-fpm8.2 --sort:rss | awk '{sum+=$8; n++} END {print "avg KB:", sum/n}'
# 例如输出 avg KB: 45000,即单进程约 45MB

再看服务器可用内存(不是总内存,要扣掉 MySQL、Redis、系统本身):

free -m
# 假设总 4GB,MySQL 占 800MB,系统 300MB,可用给 PHP 的约 2900MB

于是 max_children = 2900 / 45 ≈ 64。贸然把这个值调到 200,后果是内存耗尽触发 OOM Killer,dmesg | grep -i oom 里会看到 PHP 进程被批量干掉——这比 504 更难排查,因为进程无声无息地消失,日志里只有内核的 OOM 记录。

池模式也要选对。低并发站点用 pm = static 最稳(进程常驻,无启动开销);流量波动大的选 pm = dynamic,配合合理的 pm.start_servers、pm.min_spare_servers、pm.max_spare_servers。经验值是让 start_servers 约等于 max_children / 4,min_spare 和 max_spare 分别取 1/4 和 1/2。以 64 为例:

pm = dynamic
pm.max_children      = 64
pm.start_servers     = 16
pm.min_spare_servers = 16
pm.max_spare_servers = 32
pm.max_requests      = 1000

pm.max_requests 值得单独说一句:它让每个工作进程处理一定请求数后自动重启,能回收 PHP 的内存泄漏。默认是 0(不重启),长期运行会看到进程 RSS 缓慢上涨——这也是"网站跑几天后变慢"的常见原因。设成 500 到 2000 之间,代价是偶尔的进程重启开销,收益是内存稳定。

情况 B:慢请求,用 slowlog 精确定位

上面配置里已经打开了 request_slowlog_timeout = 10s,任何超过 10 秒的请求都会被记录到 slowlog,并附带PHP 函数调用栈。这是最有价值的一份证据:

[02-Oct-2026 03:14:22]  [pool www] pid 28134
script_filename = /www/wwwroot/example.com/api/report.php
[0x00007f] curl_exec() /www/wwwroot/example.com/lib/http.php:88
[0x00007f] fetch_remote() /www/wwwroot/example.com/api/report.php:42
[0x00007f] + 30.002 sec

这份栈告诉你两件事:慢在第 42 行调用 fetch_remote(),而 fetch_remote() 卡在 curl_exec()——是一个外部 HTTP 请求没有设置超时。注意行首的 [0x00007f] 是内存地址占位符,实际排查看的是文件名与行号。修复方式是在 cURL 里显式加超时:

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 3,     // 连接超时
    CURLOPT_TIMEOUT        => 8,     // 总超时,必须有
]);
$resp = curl_exec($ch);

没有 CURLOPT_TIMEOUT 的外部调用是 504 的头号元凶。PHP 默认的 socket 超时可以长达几分钟,一个卡死的外部接口就能拖垮整个 FPM 池——因为每个等待的请求都独占一个工作进程,池子很快见底。同理,用 file_get_contents() 抓远程 URL 时也要传 stream_context 里的 timeout,否则同样会无限等待。

如果 slowlog 里反复出现的是数据库调用而不是 HTTP,那么问题在 SQL 上。可以用 MySQL 的慢查询日志(slow_query_log = ON,long_query_time = 1)交叉验证,用 EXPLAIN 看是否走了全表扫描。一个没加索引的 WHERE user_id = ? 在百万行表上扫几秒,配合并发就足以把池打满。

数据库层面的隐藏超时

还有一种慢请求来自 MySQL 锁等待。当多个请求同时更新同一行时,后来的请求会一直等锁,直到 innodb_lock_wait_timeout(默认 50 秒)才报错。这个时长刚好能把 FPM 池拖到溢出。

-- 查看当前锁等待
SELECT * FROM performance_schema.data_lock_waits\G
-- 查看阻塞源
SELECT * FROM sys.innodb_lock_waits\G

治本手段是缩短事务:不要在事务里做网络请求、文件读写或大批量循环更新。如果业务上确实需要长时间持锁,把 innodb_lock_wait_timeout 调到 5 到 10 秒,让请求快速失败重试,比默默等待 50 秒更健康。

缓存与静态资源也会触发 504

如果 504 只在大流量时出现静态资源上,问题可能不在 PHP,而在上游代理。当 Nginx 作为反代时,proxy_read_timeout 才是对应参数,很多人只改了 fastcgi_read_timeout 就以为万事大吉。区分方法:看 504 的 URL 后缀。动态页(.php 或无后缀路由)走 FastCGI,静态资源(.js/.css/图片)走 proxy 或直接由 Nginx 提供——后者几乎不会 504,出现 504 说明前面还有一层代理,需要在那层找超时配置。

调整超时为什么常常治标不治本

把 fastcgi_read_timeout 从 60 秒改到 300 秒,用户确实不再看到 504 了,但这只是把等待时间从 60 秒拉长到 300 秒。期间那个工作进程一直被占用,如果并发请求都做同样的事,池子会在更长的窗口里持续枯竭,最后表现为整个站点无响应——比快速返回 504 更糟。真正的思路应该是:要么让请求变快,要么让请求快速失败并降级。

对于确实无法在几秒内完成的重任务,正确做法是异步化:请求进来先写一条任务记录并立即返回"处理中",由后台 worker(如 cron 或队列消费者)去执行,前端轮询或推送结果。这样 Web 层永远只处理毫秒级的请求,FPM 池不会被长任务占据。很多"导出报表""批量抓取"类的 504,本质上都是同步处理重活导致的,无论把超时调多大都是错的解法。

另一条路是降级:给外部依赖加上兜底。例如调用第三方接口失败时返回缓存数据或空结果并提示"稍后重试",而不是让请求阻塞到超时。这类改动通常很小,但对稳定性的提升远大于调参数。

把关键指标加进监控

最后,与其被动等用户报 504,不如主动监控几个数字:

  • FPM 队列长度:从 /fpm-status 抓 listen queue,连续大于 0 就告警
  • slow requests 计数:增长说明有请求在变慢,是 504 的前兆
  • Nginx 5xx 比例:从 access log 统计,awk '$9 ~ /^5/ {c++} END {print c}'
  • 可用内存:逼近上限时 OOM 会杀进程,比 504 更突然

把这四个数字画成一条曲线,你会发现在 504 大量出现之前,队列和 slow requests 早就开始抬头了。提前十分钟看到趋势,比事后翻日志高效得多。

排查顺序总结

  1. 看到 504 先确认是 Nginx 超时还是 FPM 杀进程(504 vs 502)
  2. 打开 FPM status 页,判断是池满还是个别慢请求
  3. 池满 → 按内存算 max_children,看 max listen queue 的趋势
  4. 慢请求 → 读 slowlog 的函数栈,定位卡住的调用点
  5. 外部 HTTP 调用一律显式设置 CURLOPT_TIMEOUT
  6. 检查数据库锁等待,缩短事务范围
  7. 最后才调整超时数值,并保证 Nginx > FPM > PHP 的层级关系

记住一句话:504 是上游没在规定时间内响应,不是网站整体慢。先定位是哪一层放弃了等待,再去优化那一层,效率比盲目加缓存高得多。对个人站长来说,与其把超时调到 300 秒掩盖问题,不如花十分钟读一遍 slowlog——后者才真正解决问题。超时数值只能让你看不到错误,不能让你变快;而 slowlog 里那一行行调用栈,才是通往真正瓶颈的路标。

Last modification:October 2nd, 2026 at 10:24 pm

Leave a Comment