Nginx 502 与 504 别混着修:分清「连上了谈崩」和「对方不回」,附超时层级配置

一、先把 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 有两种含义,必须分清:

  1. php-fpm 服务真的挂了(OOM、配置语法错误导致启动失败)。用 systemctl status php7.4-fpm 一看便知。
  2. 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.service

2.2 上游返回了超过缓冲区的大响应头

这类 502 特别隐蔽,因为 PHP 明明执行完了。日志关键字是 upstream sent too big header while reading response header from upstream

触发条件通常是:登录后写入了一个巨大的 Cookie(比如把用户会话、权限列表、多站点配置全塞进 Cookie),或者后端返回了超长的 Set-CookieLink 头。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_moduleproxy_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 offX-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_connectionsulimit -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 代价。

Last modification:September 23rd, 2026 at 08:24 pm

Leave a Comment