一个周五晚上的事故,我差点背了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
对应的版本号(本次排查看用的环境):
| 组件 | 版本 |
|---|---|
| Nginx | 1.24.0 |
| PHP | 8.3.13(PHP-FPM) |
| Linux内核 | 5.15.0(Ubuntu 22.04) |
| MySQL | 8.0.35 |
| Redis | 7.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_processes和worker_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秒):
| 指标 | 修复前 | 修复后 | 提升 |
|---|---|---|---|
| 总请求数 | 3125 | 73890 | 23.6倍 |
| 非2xx响应 | 2376 | 0 | 100%消除 |
| Socket错误 | 3440 | 0 | 100%消除 |
| 平均延迟 | 1.12s | 380ms | 降66% |
| P99延迟 | 12.55s | 2.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不可怕,抓根因
这次事故的真正根因是三层叠加:
- 内核参数
net.core.somaxconn被改小到128,accept队列经常打满 - PHP-FPM的
listen.backlog没有显式设置,跟着系统默认值走,也跟着变小 - PHP业务代码调用第三方接口没有设置超时,偶发性慢请求拖死了整个FPM进程池
三个问题单独存在都不致命,叠在一起就到了临界点。
如果你正在被502/504困扰,不要急着打补丁。先把错误日志看清,把队列溢出数查了,把上游超时设了。按这篇文章的排查路径走,8小时内能找到根因。