Nginx 502/504根因排查:从超时到进程崩溃
发布日期: 2026/08/02 阅读总量: 0

一次凌晨的告警

凌晨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 GatewayTCP层能建连,但上游无响应,或连接直接被拒/重置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 refused110 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

压测数据:调参前后对比

以下是同环境下的实测输出:

指标调参前调参后
QPS312518
错误率4.2%0.0%
p(95) 延迟2.8s940ms
内存峰值3.91GB3.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改造后
QPS518950
p(95) 延迟940ms380ms
错误率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。