网站502/504根因排查:一个512错误引发的血案
发布日期: 2026/08/16 阅读总量: 0

一个周五晚上的事故,我差点背了P0

那天晚上22:17,监控群突然炸了。用户反馈站点打不开,我打开浏览器一看,满屏的 502 Bad Gateway,间或夹杂几条 504 Gateway Timeout

第一反应是PHP-FPM挂了,SSH登上去 systemctl status php8.3-fpm,显示 active (running)。再看Nginx错误日志:

2025-06-13 22:15:43 [error] 15842#15842: *982345 connect() failed (111: Connection refused) while connecting to upstream, client: 10.0.3.21, server: api.xxx.com, request: "POST /v1/pay/check HTTP/1.1", upstream: "fastcgi://unix:/run/php/php8.3-fpm.sock:", host: "api.xxx.com"

典型的 connect() failed (111: Connection refused),但PHP-FPM明明活着。这不对劲。

我做了个愚蠢的操作——重启PHP-FPM,502消失了。当时以为解决了,结果第二天同一时间,事故重演。负载才3.2,内存还有40%空闲,怎么会这样?

这一周,我把从Nginx到PHP-FPM、从TCP协议栈到内核参数的整个链路翻了个底朝天。这篇文章是这次事故的完整复盘。

先搞明白:502和504到底差在哪

排查之前先做区分。很多人把502和504混为一谈,这两个虽然都是Nginx返回的网关错误,但语义完全不同:

错误码含义发生在哪一层含义
502 Bad Gateway上游服务器返回了无效响应TCP连接层连接建立失败、被拒绝、或收到垃圾数据
504 Gateway Timeout在上游响应超时时间内没收到任何数据HTTP响应层连接建立了,但上游处理超过Nginx设定的时间阈值

用一句人话说:502是压根没连上,504是连上了但对方磨蹭。

排查方向完全不同。先看错误日志的关键字:

# 502常见报错模式
connect() failed (111: Connection refused)      # 端口拒绝
connect() failed (110: Connection timed out)    # 连接超时
no live upstreams while connecting to upstream  # 上游池全挂
recv() failed (104: Connection reset by peer)   # 连接被对端重置

# 504最常见报错模式
upstream timed out (110: Connection timed out)  # 等待上游响应超时

错误日志在 /var/log/nginx/error.log,排查的第一件事就是看这个文件,不是猜。

排查方案对比:一个晚上试出来的两条路

我先后走了两条完全不同的排查路线,效果天差地别。

方案A:看状态、重启服务、加超时时间(当时选择)

传统运维的思路,也是很多文章的解法:

  • 发现问题就 systemctl restart php8.3-fpm,看起来解决了
  • 怕超时就把 fastcgi_read_timeout 从30秒加到300秒
  • 扛不住就扩容,加服务器

结果:第二天同一时间继续502。重启掩盖了问题,没有解决任何根因。

方案B:从内核协议栈到应用层做全链路排查(后来的选择)

放弃“哪里坏了修哪里”的思路,从TCP连接的最底层开始回溯:

  • 客户端的请求进来后,先进入Nginx的 listen 队列
  • Nginx accept之后,通过fastcgi协议连接PHP-FPM
  • PHP-FPM的 listen 队列接收连接,从进程池分配worker处理
  • 处理完返回响应给Nginx,Nginx再返回给客户端

任何一个环节卡住,都会表现为502/504。用这个思路排查,最终发现问题的根子在 listen 队列溢出和PHP-FPM配置缺陷,两个问题叠加在一起。

结果:修复后连续60天零502。方案A用了3天没解决,方案B花了8个小时找到了根。

排查工具准备:这些命令提前准备好

下面这些工具是排查502/504的标配,建议提前装好:

# Ubuntu/Debian系
apt install -y net-tools tcpdump lsof strace htop sysstat dstat iotop
# CentOS/RHEL系
yum install -y net-tools tcpdump lsof strace htop sysstat dstat iotop

# 压测工具,没有wrk就用ab
apt install -y wrk

对应的版本号(本次排查看用的环境):

组件版本
Nginx1.24.0
PHP8.3.13(PHP-FPM)
Linux内核5.15.0(Ubuntu 22.04)
MySQL8.0.35
Redis7.2.4

排查实战:从内核到PHP-FPM逐层过

第一步:确认Nginx accept队列是不是已经满了

Nginx接收新连接,走的是TCP三次握手之后的accept队列(也叫全连接队列)。队列满了,新连接的握手就会卡住,表现为客户端看到502。

# 用ss命令查看Nginx监听端口的队列情况(重要指标)
ss -lntp | grep :80
# 输出示例(重点看Send-Q和Recv-Q两列):
# State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port
# LISTEN  1024    511      *:80                *:*

# Recv-Q: 当前accept队列里等待处理的连接数
# Send-Q: accept队列的最大长度(不是511就是内核参数限制,取较小值)

Recv-Q 持续等于或接近 Send-Q 的值,说明accept队列一直在打满。我当时的输出是 Recv-Q=511, Send-Q=511,全满。

看Nginx进程是否忙于accept:

# 如果work_connections配置不够,错误日志会报worker_connections are not enough
grep "worker_connections" /var/log/nginx/error.log | tail -5

根本原因之一net.core.somaxconn 默认值是4096(旧内核可能是128),但Nginx的 listen backlog 默认是511。全连接队列最大值 = min(somaxconn, backlog)。我的内核somaxconn被改成了128,导致队列上限只有128,流量一冲就满。

修复sysctl参数:

# /etc/sysctl.d/99-nginx-optimize.conf
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 65535
fs.file-max = 1000000

# 让配置生效
sysctl -p /etc/sysctl.d/99-nginx-optimize.conf

第二步:检查PHP-FPM的listen队列

Nginx通过unix socket连PHP-FPM(fastcgi协议),PHP-FPM也有一个accept队列。unix socket的队列长度由 net.core.somaxconn 和PHP-FPM的 listen.backlog 配置共同决定。

# 查看PHP-FPM当前队列溢出情况(内核计数器)
cat /proc/net/netstat | grep ListenOverflows -A1
# 输出:
# TcpExt: ... ListenOverflows ...
# TcpExt: ... 58703 ...

# 这个数字是Linux内核启动以来所有listen socket溢出的累计次数
# 如果58703这个数在持续增长,说明你的listen队列在疯狂丢连接

这是我的核心证据:ListenOverflows计数器高达5.8万,还在增长。这些被丢弃的连接,Nginx那边就是 Connection refused

同时用 ss 看php-fpm socket的相关状态:

# 确认php-fpm.sock是否存在、权限是否正确
ls -la /run/php/php8.3-fpm.sock
# srw-rw---- 1 www-data www-data 0 Jun 13 22:00 /run/php/php8.3-fpm.sock

# 如果Nginx的worker进程不是www-data用户,就会报permission denied
# 修改nginx.conf中user指令,或者把php-fpm的listen.owner设为nginx用户

修复PHP-FPM配置(关键!):

; /etc/php/8.3/fpm/pool.d/www.conf
; 进程池配置(修改后重启php8.3-fpm)

user = www-data
group = www-data

listen = /run/php/php8.3-fpm.sock
; 重要:listen队列长度,max(128, somaxconn)即可
; 默认值是backlog=-1,代表使用系统默认值
listen.backlog = 65535

; 进程管理:动态模式
pm = dynamic
; 最大子进程数,这个取决于你的内存大小
; 每个PHP-FPM进程约占用40-60MB内存
; 4GB内存机器建议设50,8GB设100,16GB设200
pm.max_children = 100
; 启动时创建的进程数
pm.start_servers = 20
; 空闲时保持的最小进程数
pm.min_spare_servers = 10
; 空闲时保持的最大进程数
pm.max_spare_servers = 30

; 每个请求最多执行秒数,超过会被杀掉
request_terminate_timeout = 30

改完别忘了重启:

systemctl restart php8.3-fpm
# 确认起来了
systemctl status php8.3-fpm --no-pager

第三步:Nginx配置全面排查

Nginx的配置直接决定502/504的容忍度。以下配置项是排查重点。

worker_processesworker_connections决定Nginx能同时处理多少连接。单worker最大连接数默认512,但这个数字经常不够用,改到位:

# /etc/nginx/nginx.conf 关键配置
user www-data;
worker_processes auto;  # 等于CPU核数,不要手动指定数字
worker_rlimit_nofile 65535;  # 文件句柄数限制,必须提高

events {
    # 每个worker最大连接数,实测单机4核8G,并发5000没问题
    worker_connections 8192;
    # 使用epoll事件模型(Linux 2.6+默认,写上更明确)
    use epoll;
    # 允许接收多个新连接,减少系统调用次数
    multi_accept on;
}

http {
    # 隐藏Nginx版本号,安全加固
    server_tokens off;
    # 连接超时时间,默认60秒,如果客户端网络差可以适当加长
    keepalive_timeout 65;
    # 这些超时时间是504的根源,后面详细说
    proxy_connect_timeout 75s;
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    fastcgi_connect_timeout 75s;
    fastcgi_read_timeout 300s;
    fastcgi_send_timeout 300s;
    # 如果上游返回缓冲不够,会产生upstream sent too big header报错
    fastcgi_buffer_size 32k;
    fastcgi_buffers 8 32k;
    fastcgi_busy_buffers_size 64k;
}

有些坑藏在server块里。如果你用proxy_pass而不是fastcgi_pass,那要检查是否带了URI:

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

    # 注意:这里如果写成了
    # proxy_pass http://backend_server/;
    # 末尾带 / 会变更原始URI,location中配置的重写规则会失效

    location /api/ {
        proxy_pass http://backend_server;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_redirect off;
    }
}

如果是PHP站点:

# PHP-FPM反向代理配置(常见写法)
server {
    listen 80;
    server_name www.xxx.com;
    root /var/www/html;

    location ~ \.php$ {
        # 关键:用unix socket连接,比TCP连接快10%左右
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

第四步:压测验证到底是哪里松动

用wrk压测,逐层确认问题根因是否还有残留。这个方法能把“感觉没事了”变成“数据证明没事了”。

# 压测命令:模拟1000并发,持续60秒,压一个PHP接口
wrk -t12 -c1000 -d60s --timeout 10s http://127.0.0.1/test.php

# 压测结果示例(问题存在时):
# Running 60s test @ http://127.0.0.1/test.php
#   12 threads and 1000 connections
#   Thread Stats   Avg      Stdev     Max   +/- Stdev
#     Latency     1.12s     2.01s    12.55s    86.33%
#     Req/Sec     0.00    445.00     1.00k     0.00%
#   3125 requests in 60.05s, 1.03MB read
#   Non-2xx or 3xx responses: 2376
#   Socket errors: connect 0, read 1342, write 0, timeout 2098

# 压测结果示例(修复过后):
# Running 60s test @ http://127.0.0.1/test.php
#   12 threads and 1000 connections
#   Thread Stats   Avg      Stdev     Max   +/- Stdev
#     Latency   380.45ms  122.53ms   2.14s    84.23%
#     Req/Sec     1.23k    210.45     2.10k    70.00%
#   73890 requests in 60.05s, 22.31MB read
#   Non-2xx or 3xx responses: 0
#   Socket errors: connect 0, read 0, write 0, timeout 0

压测数据的解读:修复前,60秒内处理3125个请求,其中2376个返回非2xx;修复后,73890个请求,全部2xx,零错误。差距是23倍

第五步:确认上游PHP-FPM的处理能力瓶颈

如果PHP-FPM进程池全忙,新请求就要排队。当排队时间超过Nginx的 fastcgi_read_timeout,就是504。

要区分是PHP代码瓶颈还是PHP-FPM配置瓶颈,用strace看系统调用:

# 跟踪某个php-fpm工作进程的系统调用,看卡在哪
# 先找到php-fpm的worker PID(列出来的是主进程和worker进程)
pgrep -u www-data -f php-fpm

# 跟踪输出到日志文件(不要直接输出到终端,会影响性能)
strace -p 2386 -f -T -t -o /tmp/strace_php.log -e trace=network,read,write,select,poll,epoll_wait

# 分析日志,如果发现大量epoll_wait超时无响应,且卡在read()调用上
# 说明PHP在处理请求时被阻塞,极可能是慢SQL或外部API调用

# 看最终统计
grep -E "read\(|select\(|poll\(|epoll_wait" /tmp/strace_php.log | awk '{print $NF}' | sort | uniq -c | sort -rn | head -20

如果确认有慢请求,就得看PHP代码。最常见的问题:数据库慢查询、Redis阻塞、外部HTTP请求没设置超时时间。

这里贴一个典型的坑——PHP的HTTP客户端不设超时:

<?php
// 错误示例:没有设置超时时间,Guzzle默认无限等待
$client = new GuzzleHttp\Client();

try {
    $response = $client->post('https://third-party-api.com/v1/check', [
        'json' => ['order_id' => '202506131234']
    ]);
} catch (\Exception $e) {
    // 如果第三方API拖了5分钟才超时,你的PHP-FPM就占着茅坑5分钟
}

// 正确示例:设置连接超时2秒、请求超时5秒
$client = new GuzzleHttp\Client([
    'timeout' => 5,
    'connect_timeout' => 2,
]);
try {
    $response = $client->post('https://third-party-api.com/v1/check', [
        'json' => ['order_id' => '202506131234'],
        'timeout' => 10,  // 单个请求最多10秒
        'connect_timeout' => 3,
    ]);
} catch (\Exception $e) {
    // 10秒内没返回就放弃,不占进程坑
}
?>

第六步:通过监控数据复盘根因

排查要基于数据,不是感觉。我恢复讲当时的关键证据链:

# 1. 查看当时php-fpm的慢日志(如果开了的话)
# /etc/php/8.3/fpm/pool.d/www.conf 中的配置:
slowlog = /var/log/php8.3-fpm.log.slow
request_slowlog_timeout = 5s

# 查看慢日志,找出什么接口在拖死FPM
tail -100 /var/log/php8.3-fpm.log.slow

当时慢日志的内容:

[25-Jun-2025 12:00:03] [pool www] pid 2386
script_filename = /var/www/html/pay/check.php
[0x00007f3a8f4d61c8] curl_exec() /var/www/html/pay/check.php:31
[0x00007f3a8f4d6078] check_order_status() /var/www/html/pay/check.php:18

一条curl_exec卡了89秒。原因:代码调外部支付接口没设超时,该接口偶发性响应慢,拖垮了PHP-FPM的worker进程,worker数量耗尽后,新的连接没进程处理,直接502。

效果数据:修完之后到底发生了什么变化

这次排查修复,改动如下:

  • 内核参数net.core.somaxconn从128调到65535
  • PHP-FPM的listen.backlog设为65535
  • PHP-FPM的pm.max_children从静态15改为dynamic,上限提到100
  • PHP业务代码所有HTTP请求加上超时时间
  • Nginx的fastcgi_read_timeout从默认60s改为300s,连接不上时的快速失败阈值从默认60s改为5s

修复前后压测数据对比(服务同样的PHP接口,1000并发持续60秒):

指标修复前修复后提升
总请求数31257389023.6倍
非2xx响应23760100%消除
Socket错误34400100%消除
平均延迟1.12s380ms降66%
P99延迟12.55s2.14s降83%
CPU空闲率3%42%39个百分点

线上监控数据:修复后连续60天,502/504告警次数从每天200+降为0。

ListenOverflows计数从58703并在持续增长,修复后该计数器完全停止增长。

避坑指南:这些坑我全都踩过

坑1:重启大法让你永远找不到根因

502/504一出就重启PHP-FPM或Nginx,症状会瞬间消失。但这不是修复,是重置。下次流量一来,同样的问题还会发生。找到根因之前,克制住重启的冲动。

想看当前有没有问题,先看计数器而不是先重启。

坑2:把listen队列当成内存问题

听人建议调大 net.core.somaxconn 之前,我用free -m看内存,以为有空闲内存就没事。accept队列溢出和内存毫无关系。 队列长度由内核参数和Nginx的backlog参数决定,跟系统负载和内存占用没有直接关系。

坑3:改完配置忘了reload/restart

改了 nginx.conf 没执行 nginx -s reload,白忙活。改了 sysctl.conf 没执行 sysctl -p,也白忙活。PHP-FPM的pool配置改完不重启PHP-FPM,不生效。每次改完配置立即验证。

# 验证Nginx配置是否正确
nginx -t
# 平滑加载配置(不会中断服务)
nginx -s reload
# 重启PHP-FPM
systemctl restart php8.3-fpm

坑4:只看CPU、内存,没看队列溢出次数

有些502发生时,CPU、内存、磁盘IO全部正常。你以为“明明所有资源都空闲,为什么会502?”——因为连接没有进入处理阶段就被丢弃了,根本没消耗资源。/proc/net/netstat中的ListenOverflows是排查502的王牌指标,千万要记得看。

坑5:压测时不清理连接状态

压测完马上重新压,会发现结果好很多。原因是TIME_WAIT状态的连接被复用了。如果不清除,压测结果会有欺骗性。压测前清一下:

# 清空被TIME_WAIT占用的连接(会断开所有tcp连接,生产环境慎用)
# 测试环境随便搞,生产环境不要执行
sysctl -w net.ipv4.tcp_tw_reuse=1
# 或者压测时用不同的源端口段,避免复用TIME_WAIT

坑6:上游代码没有超时控制

如果你排查的是504,首先要怀疑的就是上游代码。PHP代码里调API、连数据库、读Redis,任何一步没有设置合理的超时时间,慢请求就会堆积成山。给每个外部调用加上超时:MySQL的 mysql.connect_timeout、Redis的 timeout 参数、HTTP客户端的 timeout 选项。

坑7:Nginx upstream健康检查没配

如果你用upstream做负载均衡,健康检查一定要配。否则某台后端挂了,Nginx还会继续往它转发请求,握手失败就是502。配了健康检查,Nginx会自动踢掉故障节点。

upstream backend_cluster {
    # 负载均衡方式:最少连接数,配weight加权
    least_conn;
    server 10.0.1.10:9000 weight=5 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:9000 weight=5 max_fails=3 fail_timeout=30s;

    # keepalive长连接,减少TCP握手开销(必须有下面的配置才生效)
    keepalive 32;
}

server {
    location /api/ {
        proxy_pass http://backend_cluster;
        # 不配proxy_http_version 1.1,上面的keepalive不生效
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

坑8:512错误和DNS有关

还有一个冷门情况,Nginx做反向代理时,upstream配置的域名解析可能失效或超时。如果Nginx解析不了后端域名,返回的也是502。排查时不妨 dig 一下后端的域名,看看解析有没有问题。

最终排查流程图:照着走一遍

// 排查决策树(用JS的注释风格写,方便你理解走向)

/*
 * 收到502/504告警
 *   |
 *   1. 看Nginx错误日志 /var/log/nginx/error.log
 *   |--- 是502还是504?
 *   |
 *   2a. 如果是502(连接失败):
 *   |   |
 *   |   看错误关键字:
 *   |   |--- "Connection refused"
 *   |   |   |--- PHP-FPM活着吗?
 *   |   |   |   |--- 活着:查listen队列溢出
 *   |   |   |   |   |--- 看/proc/net/netstat的ListenOverflows
 *   |   |   |   |   |--- 调大somaxconn + listen.backlog
 *   |   |   |   |--- 挂了:重启php-fpm,查为什么挂(OOM?日志?)
 *   |   |
 *   |   |--- "Connection timed out"
 *   |   |   |--- 防火墙挡了?查iptables、安全组
 *   |   |   |--- 后端主机宕机了?ping + 端口连通性测试
 *   |
 *   2b. 如果是504(上游超时):
 *   |   |
 *   |   看是哪个上游:
 *   |   |--- PHP-FPM?看慢日志和进程状态
 *   |   |--- 后端API?看它有没有慢查询、外部调用
 *   |   |--- MySQL/Redis?看慢日志、阻塞命令
 *   |
 *   3. 解决后:压测验证(wrk 1000并发60秒)
 *   |--- 0错误,0超时,平均延迟下降,才算修好
 */

最后补充:监控才是根本

这起事故导致我在监控体系里加了三块:

  • 监听队列溢出计数器(ListenOverflows)的实时监控,超过阈值就报警
  • Nginx access log 的5xx状态码百分比监控,超过1%就报警
  • PHP-FPM的slow log接入日志分析平台,慢请求自动聚合通知

没有监控,故障只能在线上暴雷之后被人发现。有了监控,才能在你睡觉的时候替你盯着。

总结的总结:502/504不可怕,抓根因

这次事故的真正根因是三层叠加:

  1. 内核参数 net.core.somaxconn 被改小到128,accept队列经常打满
  2. PHP-FPM的 listen.backlog 没有显式设置,跟着系统默认值走,也跟着变小
  3. PHP业务代码调用第三方接口没有设置超时,偶发性慢请求拖死了整个FPM进程池

三个问题单独存在都不致命,叠在一起就到了临界点。

如果你正在被502/504困扰,不要急着打补丁。先把错误日志看清,把队列溢出数查了,把上游超时设了。按这篇文章的排查路径走,8小时内能找到根因。