运营同学在后台点了“导出月度报表”,转圈三十秒后页面上出现 504 Gateway Timeout;过一会儿他自己刷新,数据又出来了。另一个场景更典型:每天晚上八点促销开始,前两分钟总有用户看到 504,之后就恢复正常。这类问题的共同点是——后端不是坏了,只是慢了,而 Nginx 等不及。
504 和 502 的区别必须分清:502 是连接没建立或连接被上游提前关闭,504 是连接建上了、请求也发出去了,但在规定时间内没等到响应。所以 504 的排查重心不在“通不通”,而在“为什么慢”以及“超时阈值设得合不合理”。本文会先把 Nginx 侧三个超时参数的边界讲清楚,再顺着链路往下查 PHP-FPM、数据库、外部 API 和资源瓶颈,最后给出一套分层的超时设计与优化方案。
一、先搞清楚:Nginx 的三个超时分别管什么
很多人一遇到 504 就把 proxy_read_timeout 调到 600s,这是典型的“掩盖问题”。先理解三个参数各自覆盖的阶段:
| 参数 | 计时起点与终点 | 默认值 | 超时后现象 |
|---|---|---|---|
| proxy_connect_timeout | 与上游建立 TCP 连接的时间(含 socket connect) | 60s | 502(连接失败) |
| proxy_send_timeout | 向上游发送请求的时间,指两次写操作之间的间隔,不是整个请求发送耗时 | 60s | 504 |
| proxy_read_timeout | 向上游发出请求后,等待响应的时间,指两次读操作之间的间隔 | 60s | 504 |
| fastcgi_connect_timeout | FastCGI 建连超时 | 60s | 502 |
| fastcgi_send_timeout | 向 FastCGI 发请求超时 | 60s | 504 |
| fastcgi_read_timeout | 等待 FastCGI 响应超时 | 60s | 504 |
关键点:proxy_read_timeout 计的是两次成功读之间的间隔。如果后端每 50 秒吐一个字节,理论上永远不会触发 504——这也是为什么某些大文件下载明明跑了十几分钟却没超时。反过来,如果后端整整 60 秒没有任何输出,Nginx 就会判定超时。
1.1 一套通用的超时配置
# 推荐放到 /etc/nginx/conf.d/proxy_params.conf,用 include 引入 proxy_http_version 1.1; 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_set_header Connection ""; proxy_connect_timeout 5s; # 建连通常毫秒级,5s 足够,太长会拖住 worker proxy_send_timeout 60s; proxy_read_timeout 60s; # 默认就是 60s,慢接口单独覆盖 proxy_buffering on; # 开启缓冲,让后端尽快释放进程 proxy_buffer_size 16k; proxy_buffers 8 16k; proxy_busy_buffers_size 32k;
针对确实很慢的接口,不要全局放宽,而是单独在 location 里覆盖:
server {
listen 80;
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/conf.d/proxy_params.conf;
}
# 报表导出确实需要 5 分钟,只在这里放宽
location /api/report/export {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/conf.d/proxy_params.conf;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off; # 大文件流式返回,避免写满临时文件
}
# WebSocket 长连接
location /ws/ {
proxy_pass http://127.0.0.1:9001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
FastCGI(PHP)场景对应这样写:
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.1-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_connect_timeout 5s;
fastcgi_send_timeout 60s;
fastcgi_read_timeout 60s;
fastcgi_buffering on;
}
# 后台导出脚本单独放宽
location = /admin/export.php {
fastcgi_pass unix:/run/php/php8.1-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 300s;
fastcgi_buffering off;
}
二、第二层:PHP-FPM 与应用自身的超时
只调 Nginx 不改 FPM,会出现“Nginx 等了 300 秒,但 PHP 在 30 秒就被杀了”的荒谬情况。整条链路的超时必须由外向内递增。
# /etc/php/8.1/fpm/pool.d/www.conf request_terminate_timeout = 300s ; 与最慢接口对齐,0 表示不限制 request_slowlog_timeout = 5s ; 超过 5s 记慢日志 slowlog = /var/log/php8.1-fpm.slow.log pm = dynamic pm.max_children = 50 pm.start_servers = 10 pm.min_spare_servers = 5 pm.max_spare_servers = 20 pm.max_requests = 500 listen.backlog = 2048
# php.ini 侧 max_execution_time = 300 ; CLI 之外的脚本最大执行时间 max_input_time = 60 memory_limit = 256M default_socket_timeout = 60 ; file_get_contents 等 socket 操作的默认超时
2.1 超时链路由外向内的推荐关系
| 层级 | 参数 | 推荐值(普通接口 / 慢接口) |
|---|---|---|
| 客户端/浏览器 | 前端 fetch timeout | 10s / 320s |
| CDN / SLB | 回源超时 | 30s / 300s |
| Nginx proxy | proxy_read_timeout | 60s / 300s |
| PHP-FPM | request_terminate_timeout | 65s / 300s(略大于 Nginx) |
| PHP 运行时 | max_execution_time | 60s / 290s(略小于 FPM) |
| 数据库驱动 | PDO / mysqli read timeout | 5s / 60s |
| 外部 API | curl CURLOPT_TIMEOUT | 2s-5s,必须设,且要有熔断 |
口诀:外层比内层宽松。如果外层更严格,用户只会看到 504,而真实原因被完全掩盖,日志里什么线索都没有。
三、第三层:定位“慢”到底慢在哪
超时只是表象。真正要做的是找出耗时分布。按下面顺序量:
3.1 用 Nginx 日志量化后端耗时
默认的 main 日志格式不含耗时字段,先定义一个带 $upstream_response_time 和 $request_time 的格式:
log_format timed '$remote_addr [$time_local] "$request" '
'$status $body_bytes_sent '
'rt=$request_time urt=$upstream_response_time '
'upstream=$upstream_addr';
access_log /var/log/nginx/access.log timed;
# 找出最慢的 20 个请求
awk '{print $0}' /var/log/nginx/access.log | sort -t= -k4 -rn | head -20
# 统计 urt 超过 5 秒的请求数
awk -F'urt=' '{split($2,a," "); if (a[1]+0 > 5) c++} END {print "超过5秒的请求:", c}' /var/log/nginx/access.log
$request_time 是从收到客户端第一个字节到写完响应的总耗时,$upstream_response_time 是等待上游的耗时。两者对比极有价值:
- urt ≈ rt:时间全耗在后端,去优化代码/数据库。
- rt 远大于 urt:后端很快,但写给客户端很慢——慢客户端、大响应体、未开缓冲,考虑开启
proxy_buffering或上 CDN。 - urt 为
-:请求根本没到上游,多为 Nginx 层限流、rewrite 或命中缓存。
3.2 PHP 慢日志定位到行
# 开启后,超过阈值的请求会打印堆栈 tail -n 50 /var/log/php8.1-fpm.slow.log # 典型输出 # [0x00007f...] curl_exec() /data/www/app/Lib/Pay.class.php:128 # [0x00007f...] PDOStatement::execute() /data/www/app/Model/OrderModel.class.php:56
如果慢日志里反复出现 curl_exec,说明卡在外部 API;出现 PDO::execute、mysqli_query,说明卡在 SQL。两条路的处理方式完全不同。
3.3 数据库侧的慢查询
# 实时看当前在跑的长事务 mysql -e "SELECT id,user,host,db,command,time,state,LEFT(info,80) FROM information_schema.processlist WHERE command!='Sleep' AND time>3 ORDER BY time DESC LIMIT 20;" # 慢日志是否开启 mysql -e "SHOW VARIABLES LIKE 'slow_query_log%'; SHOW VARIABLES LIKE 'long_query_time';" # 临时开启(重启失效,持久化要写 my.cnf) mysql -e "SET GLOBAL slow_query_log=ON; SET GLOBAL long_query_time=1;"
四、第四层:资源与连接瓶颈
后端代码没问题、SQL 也很快,但高峰期依然 504,那就要看机器和连接数了。
# CPU / 负载 / IO
top -b -n 1 | head -20
vmstat 1 5
iostat -x 1 3
# 内存与 swap:si/so 持续大于 0、free 低于总量 10% → 已在换页,性能断崖
free -m
vmstat 1 5 | awk '{print $7,$8}'
# 连接数
ss -s
ss -lntp | grep -E '9000|3306'
netstat -an | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
# 文件句柄
ulimit -n
cat /proc/$(cat /run/nginx.pid)/limits | grep 'open files'
# OOM
dmesg -T | grep -i -E 'out of memory|killed process'
判断标准:
load average长期高于 CPU 核数 × 2,说明 CPU 排队严重。%iowait持续高于 20%,且await大于 20ms,说明磁盘是瓶颈,多为慢查询导致的大量随机读。si/so持续不为 0,说明在用 swap,PHP 响应时间会成倍增加,先降pm.max_children或加内存。- MySQL
Threads_connected接近max_connections(默认 151)时,应用侧会卡在等待连接上,最终被 Nginx 判为 504。
五、504 排查与治理步骤清单
- 确认范围:是所有请求还是特定接口?是否只在峰值出现?静态资源是否正常?(静态正常即 Nginx 本身没问题。)
- 看错误日志:
grep 'upstream timed out' /var/log/nginx/error.log | tail -20,注意后面的(110: Connection timed out)与是哪个 upstream。 - 量化耗时:配置带
urt的日志格式,找出最慢的 URL 列表。 - 对号入座:urt 大 → 后端;rt 远大于 urt → 网络/客户端;两者都小却 504 → 查 connect 阶段。
- 核对超时链路:从 CDN/SLB 到 Nginx、FPM、PHP、数据库、外部 API 逐级核对,确保外层 ≥ 内层。
- 开启 PHP slowlog,拿到具体文件和行号。
- 查数据库:processlist 找长事务,慢日志找全表扫描,必要时加索引或改分页方式。
- 查外部依赖:第三方支付、短信、物流 API 必须设置连接超时与读超时,并加熔断降级,否则对方抖动会连累你整站 504。
- 查资源:CPU、内存 swap、磁盘 IO、文件句柄、数据库连接池。
- 结构性改造:确属耗时的任务(导出、批处理、生成缩略图)改为异步队列 + 轮询/通知,HTTP 请求只负责提交任务并返回任务 ID。这是根治 504 的正确姿势。
5.1 从“等得起”到“不用等”:架构层面的三种改法
| 方案 | 做法 | 适用场景 | 代价 |
|---|---|---|---|
| 异步化 | 请求入队(Redis/RabbitMQ),立即返回任务 ID,前端轮询结果 | 导出、批处理、报表、发券 | 需要 worker 与任务表 |
| 缓存预热 | 定时任务提前算好结果写 Redis,用户请求直接读缓存 | 首页、排行榜、大屏 | 数据有秒级/分钟级延迟 |
| 分页/流式 | 大结果集分页返回或边算边输出(flush) | 大列表查询 | 改造成本中等 |
| 单纯调大超时 | 只改 proxy_read_timeout | 临时止血 | worker 被长期占用,并发能力下降,容易雪崩 |
特别注意最后一行:调大超时是止血不是治疗。Nginx 的一个 worker 连接被一个 300 秒的慢请求占住,能服务的并发就直接少一个。并发一高,worker 全部被慢请求占满,正常请求也开始排队,最终整站不可用——这正是“促销开始两分钟 504”的形成过程。
小结
504 Gateway Timeout 的正确处理顺序是:先量化(urt/rt)→ 再定位(slowlog / processlist / 外部 API)→ 后调参(由外向内递增)→ 最后做架构改造(异步化、缓存、分页)。绝不能一上来就把超时调到 600 秒,那只会把问题藏得更深,并在下一次流量高峰引发更严重的雪崩。
避坑清单:
proxy_connect_timeout建议保持 5s 以内,它管的是建连,设大了只会浪费 worker。- 慢接口单独 location 覆盖超时,绝不动全局默认值。
- PHP 侧
request_terminate_timeout要略大于 Nginx 的fastcgi_read_timeout,否则 PHP 先被杀,Nginx 日志里只剩 502 而非 504,误导排查。 - 所有对外 HTTP 调用必须设
CURLOPT_CONNECTTIMEOUT(建议 2s)与CURLOPT_TIMEOUT(建议 5s),缺一个都可能拖垮整站。 - 开启
proxy_buffering让后端快速释放;流式大文件才关掉缓冲并配合X-Accel-Buffering: no。 - 日志里一定要有
$upstream_response_time,没有它,504 排查全靠猜。 - 高峰前检查
ulimit -n与worker_connections,文件句柄耗尽时的表现和超时非常像。
关键词回顾:504 Gateway Timeout、proxy_read_timeout、proxy_connect_timeout、fastcgi_read_timeout、request_terminate_timeout、PHP 慢日志、异步化改造。
企业QQ咨询




