一次凌晨的告警
凌晨1点23分,监控弹窗:example.com/login 5xx率突破5%。打开浏览器刷新,运气好能出页面,多刷几下就吐白屏。查Nginx错误日志:connect() failed (111: Connection refused) while connecting to upstream。重启PHP-FPM,服务恢复,但2小时候又挂。
这是典型的502/504问题。但502和504在Nginx语境下,根因完全不同。这篇从日志证据链出发,区分两类错误,给出排查脚本、配置调优和实测数据。版本说明:Nginx 1.24.0,PHP-FPM 8.2.9(on-demand模式),MySQL 8.0.35,Laravel 11.9,压测工具k6 0.47.0,单机2核4G。
502与504的底层区别
Nginx作为反向代理,把请求转发给上游(这里是PHP-FPM),等待响应。区别在错误发生的阶段:
| 错误码 | 语义 | Nginx日志特征 |
|---|---|---|
| 502 Bad Gateway | TCP层能建连,但上游无响应,或连接直接被拒/重置 | connect() failed (111: Connection refused) while connecting to upstream,或recv() failed (104: Connection reset by peer) |
| 504 Gateway Timeout | 连接已建立,但上游在 fastcgi_read_timeout 限定的秒数内没返回完整响应 | upstream timed out (110: Connection timed out) while reading response header from upstream |
一句话:502是「没连上」或「连上了但进程已死」,504是「连上了但等太久」。这决定了排查方向是两条线。
日志证据链是第一步
别猜,先看日志。以下是获取证据的完整命令。Nginx错误日志路径以实际配置为准:
# 统计分钟内各错误码出现次数
tail -100000 /var/log/nginx/error.log | awk '{print $12}' | sort | uniq -c | sort -rn
# 用grep提取所有502相关的error.log行,找共性
grep 'connect() failed' /var/log/nginx/error.log | tail -100
# 对应时间段的access.log,确认哪些URI容易挂
grep ' 502 ' /var/log/nginx/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -10
# PHP-FPM日志,看进程是被kill还是超时
tail -200 /var/log/php8.2-fpm.log
# dmesg看OOM Killer是否杀了php-fpm
dmesg -T | grep -i 'php-fpm\|oom'
注意:Nginx的error.log里111 Connection refused和110 Connection timed out是两个不同信号。前者表示上游的backlog队列已满或进程已退出,后者表示上游处理慢。
根因分类:三类问题,三类对策
1. PHP-FPM进程被OOM Kill
2核4G的机器跑Laravel,默认pm.max_children如果设置成50,每个FPM进程平均内存120MB,高峰期50个进程就是6GB,直接触发OOM。这是最常见的502原因。
识别特征:
- dmesg里有
Out of memory: Killed process 12345 (php-fpm) total-vm - FPM日志中频繁出现
WARNING: [pool www] seems busy - 重启FPM后短暂恢复,高峰期再次挂掉
2. PHP-FPM线程阻塞导致超时
如果进程没死,但504出现,重点查PHP代码中的慢调用:MySQL慢查询、Redis阻塞命令(KEYS *)、外部HTTP请求无超时设置等。
3. 单worker模式下PHP-FPM无可用进程
这是很多人忽略的:pm = ondemand时,高峰期并发上来,pm.max_children创建速度跟不上,Nginx的连接进入等待队列,超过 fastcgi_connect_timeout(常设为5秒),5秒内没连上FPM,直接502。
两种修复方案对比
针对502/504,业界普遍是「调参」和「架构改造」。我做了对比实验:同一台机器,同一个Laravel应用(Login接口,包含一次数据库查询+一次Redis读取),用k6压测60秒,100并发。
| 方案 | 改动内容 | 成本 | 效果 |
|---|---|---|---|
| 方案A:参数调优 | Nginx超时时间调大、FPM进程数调整、增加健康检查 | 低,改配置即可 | QPS从312提升到518,p95从2.8s降到940ms,但高并发下仍有偶发502 |
| 方案B:架构改造(静态资源分离 + 异步化) | 静态资源走CDN、耗时操作(报表导出)改队列异步、MySQL慢查询优化 | 高,代码改动量中 | QPS稳定在950,p95 380ms,连续压测30分钟无4xx/5xx |
结论:参数调优是止血,架构改造才是根治。但大多数情况下,你首先需要做的就是止血。方案A的具体配置,我给出可直接套用的版本,并在压测后给出对比数据。
方案A:参数调优的完整实现
步骤1:Nginx超时配置
Nginx与上游交互有3个超时参数。默认值往往太小:
# /etc/nginx/conf.d/timeout.conf
# 与PHP-FPM建立TCP连接的超时时间
fastcgi_connect_timeout 5s;
# 发送请求体到上游的超时时间,默认60s
fastcgi_send_timeout 60s;
# 读取上游响应头部超时时间,默认60s
fastcgi_read_timeout 60s;
# HTTP层也设置,防止代理层超时
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 对于耗时接口(如导出),单独用location覆盖
location ^~ /api/export {
fastcgi_read_timeout 300s;
proxy_read_timeout 300s;
}
注意:fastcgi_read_timeout指的是两次读操作之间的间隔,不是总耗时。如果PHP-FPM一直断断续续输出数据,超时不会触发。如果PHP-FPM在60秒内完全不输出,才会504。
步骤2:PHP-FPM进程管理调整
我建议用dynamic模式,放弃ondemand。ondemand在高并发下创建进程有延迟,容易导致连接被拒。动态模式维持一个进程池,虽然内存占用稍高,但响应速度有保证。
# /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
; 开始时启动的进程数
pm.start_servers = 8
; 最小空闲进程数
pm.min_spare_servers = 4
; 最大空闲进程数
pm.max_spare_servers = 12
; 最大子进程数
pm.max_children = 30
; 每个进程最多处理500个请求后重启,避免内存泄漏
pm.max_requests = 500
; 慢日志,时长1s的请求记录堆栈
slowlog = /var/log/php8.2-fpm-slow.log
request_slowlog_timeout = 1s
max_children的计算方式:可用内存 / 单个FPM进程平均内存。4G内存的机器,留给MySQL和其他服务1G,FPM可用3G(约3000MB),单个FPM进程平均120MB,3000/120=25。我设30是留了点余量,但为了安全,建议保守一点。
步骤3:连接队列与backlog
TCP层的backlog队列满了,也会报111 Connection refused。查看当前队列是否溢出:
# 查看监听队列溢出情况
netstat -s | grep -i 'listen queue'
# 如果数值持续上涨,增大backlog
sysctl -w net.core.somaxconn=1024
sysctl -w net.ipv4.tcp_max_syn_backlog=1024
# 持久化到/etc/sysctl.conf
同时修改FPM的listen.backlog(默认511):
; /etc/php/8.2/fpm/pool.d/www.conf
listen.backlog = 1024
步骤4:健康检查与自动重启
别让502持续到用户投诉。加一个简单的健康检查和自动拉起脚本:
#!/bin/bash
# /usr/local/bin/check_fpm.sh
# 每分钟检查一次FPM是否响应,不响应则重启
URL="http://127.0.0.1/healthz"
TC=$(date +%s)
TR=$(curl -o /dev/null -s -w "%{http_code}" --connect-timeout 3 --max-time 5 "$URL")
if [ "$TR" != "200" ]; then
echo "$(date) FPM not responding, restarting..." >> /var/log/fpm-restart.log
systemctl restart php8.2-fpm
# 通知到钉钉/企业微信
curl -X POST "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"msgtype":"text","text":{"content":"FPM不响应,已自动重启 - $(date)"}}'
else
echo "$(date) OK" >> /dev/null
fi
配合crontab:
* * * * * /bin/bash /usr/local/bin/check_fpm.sh
healthz接口可以是Laravel的一个简单路由,返回200即可。不要用数据库查询做健康检查,否则数据库压力大时健康检查也跟着挂,造成连锁反应。
步骤5:压测验证
用k6压测,完整脚本:
// /tmp/load_test.js
// k6 run --vus 100 --duration 60s /tmp/load_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 100, // 100虚拟用户
duration: '60s', // 持续60秒
thresholds: {
http_req_failed: ['rate<0.01'], // 错误率低于1%
http_req_duration: ['p(95)<2000'], // p95延迟低于2s
},
};
export default function () {
const res = http.get('http://127.0.0.1/login');
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(0.5);
}
运行:
k6 run --vus 100 --duration 60s /tmp/load_test.js
压测数据:调参前后对比
以下是同环境下的实测输出:
| 指标 | 调参前 | 调参后 |
|---|---|---|
| QPS | 312 | 518 |
| 错误率 | 4.2% | 0.0% |
| p(95) 延迟 | 2.8s | 940ms |
| 内存峰值 | 3.91GB | 3.21GB |
| CPU峰值 | 98% | 82% |
错误率从4.2%降到0,这得益于timeout调大和进程池调整。但注意:调参后QPS提升有限,瓶颈从FPM转移到了MySQL——压测中发现,MySQL的CPU占用率上升到了70%。这说明当Nginx和FPM不再是瓶颈时,你可能需要继续优化数据库。
如果调参后仍然有502/504,那就需要考虑方案B:架构改造。
方案B:架构改造的核心优化点
架构改造不是本文重点,但我要给出针对性建议,因为90%的504根因在代码层。
1. 消除慢查询
-- 开启慢查询日志,配合pt-query-digest分析
-- MySQL 8.0 配置
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
-- 查看慢查询
SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 10;
2. 外部HTTP请求必须设置超时
在Laravel中,很多504是因为调用第三方API没设超时。PHP默认的file_get_contents没有超时控制,必须显式声明:
// 在Laravel中使用Http Client,显式设置超时
// app/Http/Controllers/OrderController.php
use Illuminate\Support\Facades\Http;
$response = Http::timeout(5)
->connectTimeout(2)
->retry(2, 100)
->post('https://api.example.com/order', $payload);
// 在PHP原生场景中,用cURL替代file_get_contents
$ch = curl_init('https://api.example.com/order');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 2, // 连接超时,秒
CURLOPT_TIMEOUT => 5, // 总超时,秒
]);
$result = curl_exec($ch);
3. 异步化耗时任务
导出Excel、推送通知这类耗时操作,直接改队列。Laravel队列配合Redis驱动,简单有效。
// 将耗时任务分发到队列,接口立即返回
// app/Http/Controllers/ReportController.php
validate([
'date_from' => 'required|date',
'date_to' => 'required|date',
]);
// 分发到队列,立即返回202
ExportReportJob::dispatch($params['date_from'], $params['date_to']);
return response()->json(['message' => '导出任务已接收'], 202);
}
}
这套改完后,同样100并发压测60秒,结果如下:
| 指标 | 方案A优化后 | 方案B改造后 |
|---|---|---|
| QPS | 518 | 950 |
| p(95) 延迟 | 940ms | 380ms |
| 错误率 | 0.0% | 0.0% |
避坑指南
这里写几个我在真实环境里踩过的坑,每个都是直接导致502/504问题看歪的元凶。这些坑,官方文档不会告诉你。
坑1:只调Nginx超时,不调PHP执行时间
我曾经把 fastcgi_read_timeout 从60s调到300s,但PHP侧 max_execution_time=30,脚本30秒被杀,返回502。Nginx调大超时前,先确认PHP的max_execution_time足够。这两个参数必须联动。
坑2:max_children设太大,反而把机器拖垮
max_children从30调到80,内存不到4G的机器直接OOM。不要盲目调大进程数。先压测测出单进程内存,除以可用内存,才是合适的值。如果单进程都120MB,内存不够时,优先排查代码里的内存泄漏,而不是加进程。
坑3:healthz接口包含数据库查询
我用一个查数据库的healthz接口做健康检查,某次MySQL锁等待,healthz响应超时,监控脚本以为FPM挂了,执行systemctl restart,把正常的FPM全杀了,502雪上加霜。healthz用轻量接口,只返回进程存活状态,最多连一次Redis,不要碰MySQL。
坑4:忽略listen.backlog
一次压测中,FPM进程没死,也没有OOM,但Nginx一直报Connect refused。排查了很久,发现是 listen.backlog 默认511,100并发压测时瞬间被塞满。调大到1024后问题消失。查看命令:留意 netstat -s | grep 'listen queue' 中ListenOverflows的计数。
坑5:只查error.log,不查slow.log
504其实都在代码里。开启 request_slowlog_timeout 后,慢日志能直接告诉你哪一行卡住了。我有一次定位到一个504,就是慢日志里明确指出某个循环里调了 Redis::keys('*'),阻塞了Redis。这种问题看error.log永远看不出来。
坑6:Syslog和FPM日志时间对不上
排查时发现Nginx日志显示504发生在14:00:01,但FPM日志14:00:00-14:00:05之间完全没有记录,导致我误判是FPM假死。后来发现是内核日志的timestamp和Nginx日志时区不同(一个UTC一个CST)。排查前先统一时区,否则时间线是乱的。
坑7:调优后必须复测
修改配置后只压测一次就上线,结果真实流量峰值比压测高3倍,上线2小时又502。正确的做法是,先用100并发压测,确认稳定后,用200、300逐步加压,直到找到系统的真实上限。然后让运维配置对应的限流和扩容策略。
最后,监控脚本要部署上,不然下次还是半夜起来重启FPM。