一、先把 502 和 504 分清楚,别对着错的方向修一晚上
很多站长把 502 和 504 混着说成"服务器挂了",但这两个状态码指向的是完全不同的故障层,混在一起排查会浪费大量时间。
- 502 Bad Gateway:Nginx 成功连上了上游(PHP-FPM / 后端服务),但上游给出的响应无法解析,或者连接在响应过程中就断了。核心特征是"连上了,但谈崩了"。
- 504 Gateway Timeout:Nginx 连上了上游,也成功发了请求,但在超时时间内没等到完整响应。核心特征是"连上了,也谈了,对方迟迟不回"。
- 如果 Nginx 连都连不上(socket 不存在、端口拒绝连接),实际报的通常是 502 配一条
connect() failed,这种是第三类,要单独处理。
换句话说:502 优先查进程是否活着、协议是否对得上;504 优先查哪一步慢、超时阈值配得合不合理。方向完全不同。
二、502 的四个高频成因
2.1 PHP-FPM 进程根本没在跑
这是最朴素也最常见的一种。典型日志:
2026/09/23 09:02:11 [error] 1893#0: *5510 connect() to unix:/run/php/php7.4-fpm.sock failed (2: No such file or directory) while connecting to upstream, client: 203.0.113.9, request: "GET / HTTP/1.1", upstream: "fastcgi://unix:/run/php/php7.4-fpm.sock"No such file or directory 有两种含义,必须分清:
- php-fpm 服务真的挂了(OOM、配置语法错误导致启动失败)。用
systemctl status php7.4-fpm一看便知。 - php-fpm 活着,但 socket 路径变了。这是系统重启后最容易踩的坑:
/run是 tmpfs,重启即清空,而 fpm 的 pool 配置里listen = /run/php/php7.4-fpm.sock,Nginx 里也写死了同一个路径。只要 fpm 比 Nginx 晚启动几秒,Nginx 第一次访问就会报错——但之后会自动恢复。真正致命的是两者配置的路径不一致(比如一边写/run/php/php7.4-fpm.sock,另一边写/var/run/php-fpm.sock),这种永远不通。
确认路径一致的最快方式:
grep -E "^listen" /etc/php/7.4/fpm/pool.d/www.conf
grep -rE "fastcgi_pass" /etc/nginx/ | grep -v "#"
ls -l /run/php/同时建议把依赖关系显式声明,避免重启竞态:
# /etc/systemd/system/nginx.service.d/override.conf
[Unit]
After=php7.4-fpm.service
Wants=php7.4-fpm.service2.2 上游返回了超过缓冲区的大响应头
这类 502 特别隐蔽,因为 PHP 明明执行完了。日志关键字是 upstream sent too big header while reading response header from upstream。
触发条件通常是:登录后写入了一个巨大的 Cookie(比如把用户会话、权限列表、多站点配置全塞进 Cookie),或者后端返回了超长的 Set-Cookie、Link 头。Nginx 默认的 fastcgi_buffer_size 是 4k/8k,装不下就报错。
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
fastcgi_busy_buffers_size 64k;不过要强调:调大缓冲区是止血,不是治本。真正该做的是别往 Cookie 里塞那么大数据——Cookie 每个请求都会带上,白白浪费带宽,几百 KB 的 Cookie 会让每个请求慢几十毫秒。
2.3 上游超时被 kill 导致连接中断
当 php-fpm 的 request_terminate_timeout 到期,worker 被 SIGKILL 掉,Nginx 读到的是一个不完整的响应,判定为 502。这种 502 的特点是:页面前半部分可能已经发出来了,用户看到的是"页面加载到一半断了"。
; pool/www.conf
request_terminate_timeout = 60
; 打开慢日志,把慢脚本抓出来
request_slowlog_timeout = 5
slowlog = /var/log/php-fpm/slow.log慢日志是排查这类问题最值钱的工具,它会把出问题时的完整 PHP 调用栈打出来,一眼就能看到卡在哪个函数:
[23-Sep-2026 09:14:02] [pool www] pid 22113
script_filename = /var/www/example.com/search.php
[0x00007f] curl_exec() /var/www/example.com/inc/api.php:88
[0x00007f] fetch_remote_data() /var/www/example.com/search.php:41上面这个例子就是典型的"没有设超时时间的外部 API 调用",第三方接口一慢,自己的站就 502。修法是在 curl 里强制设超时:
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
CURLOPT_RETURNTRANSFER => true,
]);
$resp = curl_exec($ch);
if ($resp === false) {
error_log('upstream api failed: ' . curl_error($ch));
$resp = ''; // 降级,不要抛 500
}
curl_close($ch);2.4 后端协议不匹配 / 端口写错
反代非 PHP 服务时常见:把 HTTPS 后端当成 HTTP 来连,或者把 WebSocket 服务当普通 HTTP 反代。表现就是 502,日志相对明确:
upstream sent invalid response / SSL_do_handshake() failed / upstream prematurely closed connection判断后端到底是 http 还是 https,最快的方法绕开 Nginx 直接探一下(注意上面这些成因中,只有这一条可以通过直接连后端验证):
curl -sv http://127.0.0.1:8080/ 2>&1 | head -20
curl -skv https://127.0.0.1:8443/ 2>&1 | head -20
openssl s_client -connect 127.0.0.1:8443 -brief < /dev/null注意 distro 打包的 Nginx 如果没装 ngx_http_ssl_module,proxy_pass https:// 会直接启动失败或者静默走明文,别只看配置文件认为它生效了,一定要 nginx -V 2>&1 | tr ' ' '\n' | grep ssl 验一下。
三、504 的排查逻辑完全不同
3.1 先确认超时阈值配在哪一层
504 的本质是"等待超过阈值",那么第一步必然是确认阈值。一条请求链路上其实有四个超时:
client_body_timeout 10s; # 客户端上传 body 的间隔
send_timeout 10s; # 向客户端写响应的间隔
proxy_read_timeout 60s; # 读上游响应的间隔(反代核心)
fastcgi_read_timeout 60s; # FastCGI 同上
; PHP 侧
max_execution_time 30 # 脚本 CPU 时间
request_terminate_timeout 60 # fpm 硬性终止合理的层级关系是 PHP 内部 < FPM 硬终止 < Nginx 读上游。如果 Nginx 的 fastcgi_read_timeout 是 60s 而 fpm 的 request_terminate_timeout 是 120s,那么慢请求会出现 Nginx 先 504、PHP 还在后台跑、跑完把结果丢掉的情况——既浪费资源,又制造了"日志里有执行记录但没有访问记录"的迷惑现象。
3.2 慢在哪一段:用两个时间字段切三段
Nginx 日志格式里只要加上这两个变量,就能把响应时间切成三段:
log_format timed '$remote_addr "$request" $status '
'rt=$request_time '
'urt=$upstream_response_time '
'uct=$upstream_connect_time '
'uht=$upstream_header_time';
# 结果示例
# rt=61.003 urt=60.998 uct=0.001 uht=60.997
读法:
uct大(> 1s)→ 连上游就慢,通常是 backlog 满、FPM 排队、或后端 listen 队列溢出。uht - uct大、urt - uht小 → 上游"想"了很久才吐第一个字节,问题在后端处理逻辑或数据库。urt大但rt更大很多 → 时间花在传输/客户端侧,内容太大或客户端网慢。rt大、urt=-(空) → 请求根本没到上游,卡在 Nginx(比如限流、读 body、等待锁)。
这个三段切法比"看哪个页面慢"精确得多,因为同一个 URL 慢,可能是排队慢、也可能是查询慢,两者的修法完全相反。
3.3 数据库往往是真正的元凶
后端处理慢,十次有七次是 SQL。打开 MySQL 慢查询日志是第一步,但更有效的是直接在 SHOW FULL PROCESSLIST 上抓现行:发现 504 时立刻执行:
mysql -e "SHOW FULL PROCESSLIST\G" | grep -A3 -E "Sending data|Copying to tmp|Locked"State 字段很有信息量:Sending data 说明在扫描大量行、Copying to tmp table 说明排序/分组没走索引、Waiting for table metadata lock 说明有 DDL 或长事务在占锁。后者的典型场景是:某个备份脚本在业务高峰跑了 ALTER TABLE 或者一个忘记提交的事务一直挂着。
-- 找出长时间未提交的事务
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs,
trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY secs DESC LIMIT 5;同时要检查最大连接数。504 加剧时连接池耗尽是常见连锁反应:
SHOW VARIABLES LIKE 'max_connections';
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Max_used_connections'; -- 接近 max_connections 就是在排队3.4 反代场景:升级协议与长连接
如果 504 出现在 proxy_pass 的后端而那个后端是"慢但正常"的服务(比如导出 PDF、拉取第三方数据),有两种正解,不要只是把超时时间从 60s 调到 600s——那只会把 Nginx worker 白占十分钟。
第一种是异步化:请求立刻返回 202,后台任务处理,前端轮询查结果。这是内容站做批量导出、批量转码时的标准做法。
第二种是合理区分路径,只给真正需要的接口放开超时:
location = /api/export {
proxy_pass http://backend;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off; # 流式输出时必须关
add_header X-Accel-Buffering no;
}
location /api/ {
proxy_pass http://backend;
proxy_read_timeout 20s; # 其余接口收紧
}proxy_buffering off 和 X-Accel-Buffering: no 这对组合值得单独记一记:只要后端是流式输出(SSE、大文件下载、边算边发),开启缓冲会让数据堵在 Nginx 缓冲池里,客户端迟迟收不到,最终超时。这是很多人遇到"接口用 curl 直接调挺快,经过 Nginx 就超时"的原因。
四、一份速查对照表
日志关键字 指向
connect() failed (2: No such file) fpm 没起 / socket 路径不一致
connect() failed (11: Resource unavailable) FPM backlog 满,max_children 饱和
upstream sent too big header 响应头过大 / Cookie 膨胀
upstream prematurely closed connection fpm worker 被 terminate_timeout kill
upstream timed out (110) 后端处理超时,查慢日志与 SQL
upstream sent invalid response 协议不匹配(http/https、ws)
no live upstreams 后端全部健康检查失败
把 $upstream_response_time 和 $request_time 写进日志格式,是这次排查里投入产出比最高的一步。它不解决任何问题,但它让所有后续判断都从"猜"变成"读"。个人站长手上没有 APM 系统,这两个字段就是最低成本的 APM。
五、用一条命令先回答"是不是最近变慢的"
在深挖单次请求之前,先做个全局判断,能避免把偶发抖动当成持续故障。最省事的做法是从日志里算出每小时的状态码分布和平均上游耗时:
# 每小时 5xx 计数与平均 upstream 耗时(日志格式需含 $upstream_response_time)
awk '{ split($0, a, "urt="); if (a[2] != "") { split(a[2], b, " "); }
hour = substr($4, 14, 2);
if ($9 ~ /^5/) cnt[hour]++;
if (b[1] != "" && b[1] != "-") { sum[hour] += b[1]; n[hour]++ }
}
END { for (h in cnt) printf "%s时 5xx=%d 平均urt=%.2fs
", h, cnt[h], sum[h]/n[h] }' /var/log/nginx/access.log | sort
如果 5xx 只集中在某两三个小时,那就是流量峰值或某个定时任务的锅,去查 crontab;如果是全天均匀分布,说明是结构性容量问题,得从并发参数和慢查询入手。这两条路的修法完全不同,先分清楚再动手。
顺手也值得核对一下 Nginx 自身的 worker 配置。连接数不够时,症状会伪装成后端超时,但根源在前端:
worker_processes auto;
events {
worker_connections 1024; # 单 worker 上限;理论最大连接约 worker_processes × 1024
multi_accept on;
}
# 对照实际占用
ss -s
# 关注 TIME-WAIT 数量;过多说明短连接频繁,考虑开 upstream keepalive
worker_connections 与 ulimit -n 是联动的。如果文件描述符上限只有 1024 而 worker_connections 写了 4096,那么超过上限的部分根本不会生效。查法:
systemctl show nginx -p LimitNOFILE
grep -rE "worker_rlimit_nofile|worker_connections" /etc/nginx/推荐 worker_rlimit_nofile 设成略大于 worker_connections 的值(比如 8192 / 4096),并由 systemd 的 LimitNOFILE 兜住,避免出现配置文件看着合理、实际被系统限死在低位的假象。
六、上游 keepalive:一个常被漏掉的可观收益项
默认情况下,Nginx 到上游的每个请求都要重新握手一次 TCP。在高并发场景里,这会额外消耗端口、增加延迟,并且在 ss 输出中表现为大量 TIME-WAIT。开启 upstream keepalive 只需三行,但必须三行都写对:
upstream php_backend {
server unix:/run/php/php7.4-fpm.sock;
keepalive 32; # 每 worker 保持的空闲长连接数
keepalive_requests 1000; # 单连接最多复用多少次后重建
keepalive_timeout 60s; # 空闲连接保持时长
}
location ~ \.php$ {
fastcgi_pass php_backend;
fastcgi_keep_conn on; # 关键:不加这行,keepalive 不生效
include fastcgi_params;
}
最容易踩的坑是只写了 keepalive 却漏了 fastcgi_keep_conn on。少了最后这行,Nginx 仍然会在每次响应后关闭连接,你在配置里写了 keepalive 却完全感受不到收益,还可能误以为"开了也没用"。判定是否真的生效:
# 连续打请求,观察 TIME-WAIT 是否下降、上游连接是否被复用
ss -tn state established '( dport = :9000 or sport = :9000 )'
ab -n 200 -c 10 http://127.0.0.1/health 2>&1 | grep -E "Requests per second|Time per request"
对于走 unix socket 的 FPM 部署,keepalive 的收益不如 TCP 端口那样显著(省去了 TCP 握手,但没有 network 层的开销),但依然能减少 accept 排队。真正收益大的是 proxy_pass 到远端后端(比如独立的 Node 服务、Python API)的场景,那里每一次新建连接都要付出完整的 TCP 与可能的 TLS 代价。