Nginx 499/504 上游超时链路排查实战:从 $upstream_response_time 日志字段到 PHP-FPM 慢日志定位

为什么你的日志里全是 499 和 504,却找不到那一行代码

很多站长的日常是这样的:打开 Nginx 的 access.log,发现 4xx、5xx 一堆,其中 499 和 504 尤其多,但去应用日志里翻,什么也没看到。于是开始瞎猜——是数据库慢了?是 PHP 挂了?是 CDN 的问题?最后换了一台服务器,问题照旧。

499 和 504 这两个数字,其实是在讲一个非常具体的链路故事,只要你能读懂 $request_time$upstream_response_timeproxy_read_timeout 这三样东西,定位时间通常不超过二十分钟。这篇文章就把它拆开讲透。

先搞清楚这几个状态码分别是谁产生的

这是最关键的一步,也是最容易搞混的一步。状态码的制造者是不同的角色,不是同一个地方产生的。

  • 499:由 Nginx 自己生成。含义是"客户端在服务器返回响应之前就关闭了连接"。注意,这个码是 Nginx 独有的,不在标准 HTTP 状态码里(它来自 Nginx 内部对 client side 的判定)。
  • 502 Bad Gateway:Nginx 连上了上游,但上游返回的东西它看不懂,或者上游进程直接崩了 / 端口没监听。
  • 504 Gateway Timeout:Nginx 连上了上游,也把请求发过去了,但等超过 proxy_read_timeout(默认 60 秒)还没等到响应头,于是 Nginx 主动掐断连接并返回 504。

所以你会发现一个很反直觉的事实:499 和 504 经常是同一个问题的两个阶段。用户等了 30 秒,慢慢失去耐心,浏览器/App 主动断开 → Nginx 记一条 499。如果用户再坚持一会儿,Nginx 的 proxy_read_timeout 到期 → 记一条 504。你在日志里看到的 499 和 504 混在一起,很可能只是同一批慢请求的不同"死法"。

第一步:让日志带上上游耗时,否则一切分析都是空谈

默认的 Nginx 日志格式只有一个 $request_time,这是从 Nginx 收到第一个字节到发完响应的总耗时。它不区分"慢在 Nginx"还是"慢在上游"。所以第一步必须改日志格式。

log_format timed '$remote_addr - $remote_user [$time_local] "$request" '
                 '$status $body_bytes_sent "$http_referer" '
                 '"$http_user_agent" '
                 'rt=$request_time urt=$upstream_response_time '
                 'uaddr=$upstream_addr ustat=$upstream_status '
                 'host=$host reqid=$request_id ';

access_log /var/log/nginx/access.log timed;

改完之后 nginx -t && nginx -s reload,等几分钟再来看日志。几个字段的含义必须记牢:

  • rt$request_time):客户端视角的总耗时,单位秒,精度毫秒。
  • urt$upstream_response_time):Nginx 与上游(PHP-FPM / 后端)交互的总耗时。如果有多次子请求或多个上游,会以逗号分隔出现多个值。
  • uaddr:实际连到的上游地址和端口。这一列在排查"请求到底打到哪台机"时价值极高。
  • ustat:上游返回的状态码。如果日志里 ustat 是 200 而最外层 status 是 504,说明上游其实成功返回了,只是太慢,Nginx 已经放弃等待。

第二步:用 awk 做三个判断,把病因分到三类

下面这几条命令直接抄走去用,比装任何可视化面板都快。

判断一:慢在 Nginx 还是慢在上游

比较 rt 和 urt 的差值。如果 rt 远大于 urt(比如 rt=45 urt=0.2),说明上游很快,慢在 Nginx 到客户端这一段——通常是客户端网络差、带宽打满、或者响应体太大传输慢。这种情况下换 PHP 配置毫无意义。

awk '{for(i=1;i<=NF;i++){if($i~/^rt=/){rt=substr($i,4)};if($i~/^urt=/){urt=substr($i,5)}} if(rt!=""&&urt!=""&&urt!="-"){d=rt-urt; if(d>5) print d, rt, urt, $7, $9}}' /var/log/nginx/access.log | sort -rn | head -20

反过来,如果 rt ≈ urt 且两者都很大(比如都是 30 秒以上),那就是上游真的慢,去看 PHP-FPM 和数据库。

判断二:把 499 和 504 关联到同一个 upstream

grep -E ' (499|504) ' /var/log/nginx/access.log | awk '{print $9, $7}' | sort | uniq -c | sort -rn | head -20

如果 499 和 504 集中在同一个 URL 前缀上(比如都是 /search 或者 /feed),那答案就很明确了:是一个具体接口慢,而不是整站都慢。定向优化那一个接口即可。

判断三:统计上游超时的分布

grep ' ust=504 ' /var/log/nginx/access.log | awk -F'uaddr=' '{print $2}' | awk '{print $1}' | sort | uniq -c | sort -rn

如果某个上游 IP 的超时次数远高于其他,说明那台后端有问题(磁盘 IO 抖动、swap 换页、被 OOM 打过)。多台负载均衡的情况下这一条特别有用。

第三步:499 的真正来源清单

499 不是 Nginx 的 bug,它是一个信号。按出现频率从高到低排列,原因通常是这几类:

  1. 上游响应超过用户耐心。移动端 App 的 API 超时通常是 10~15 秒,浏览器虽然能等到 60 秒,但用户早就点了别的链接。这是最常见的场景,跟着 504 一起出现。
  2. 配置的超时不合理fastcgi_read_timeoutproxy_read_timeout 设成了 300 秒,但客户端 30 秒就放弃。结果是客户端走了,PHP 还在傻跑,白白占用一个 FPM 进程直到跑完。这会造成假性拥堵——看 pm.max_children 全满,以为是并发不够,其实是在等一批已经没人要的结果。
  3. 响应体太大而带宽小。下载大文件、导出 CSV、输出未压缩的 feed,此时 urt 很小、rt 很大,用户中途取消。
  4. 代码里有 flush() 但前面有慢查询。用户看到白屏不动就关了。
  5. 爬虫主动断开。搜索引擎爬虫有抓取超时预算,超时后它会直接断开,留下的就是 499。所以 499 多,也可能是 SEO 层面的一个警报:页面太慢,爬虫正在放弃你的站

第四步:504 的排查必须从 PHP-FPM 的队列看起

504 是 Nginx 等不到上游的响应头。注意是响应头,不是响应体。PHP-FPM 只有在脚本执行完毕、要开始输出时才会返回响应头(除非你用了 flush() / fastcgi_buffering off)。所以一个查询跑了 61 秒的页面,必然 504。

正确的排查顺序:

1. 检查 FPM 进程池是否排满

# 看 FPM 的状态页(需要开启 pm.status_path)
curl -s http://127.0.0.1/fpm-status?full | head -30

# 或者直接看进程占用
ps -eo pid,etimes,cmd | grep 'php-fpm: pool' | awk '{print $2, $0}' | sort -rn | head -20

etimes(已运行秒数)是关键。如果有大量进程已经跑了几十秒还没退出,说明它们卡在某个慢操作上。再看 listen queue 字段,如果它持续非零,说明 FPM 全忙,新请求在排队——这才是"整站都 504"的根因。

2. 开启 FPM 慢日志,抓出那一个脚本

; /etc/php/8.2/fpm/pool.d/www.conf
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
request_terminate_timeout = 120s

重载后慢日志会带完整 PHP 调用栈。这是全流程里信息量最大的一步,一眼就能看出是卡在 mysqli_queryfile_get_contents 还是 curl_exec。特别注意 file_get_contentscurl_exec——"站内发 HTTP 请求外部服务"是 504 的头号制造者,因为外部服务一慢,整个 PHP 进程就跟着挂住。

3. Nginx 与 PHP 的超时必须成对设置

这是个经典坑:PHP 侧 request_terminate_timeout = 300s,Nginx 侧 fastcgi_read_timeout = 60s。结果每个慢请求都会先被 Nginx 掐断产生 504,而 PHP 进程还要空转 240 秒。正确原则是:让最外层最先超时

客户端超时 (10~15s)
  < Nginx fastcgi_read_timeout (20~30s)
    < PHP max_execution_time (25~35s)
      < FPM request_terminate_timeout (30~40s)
        < 上游外部服务超时 (5~10s,必须最短)

注意上面这条链:外部 HTTP 请求的超时必须比所有环节都短。如果代码里请求第三方 API 却不设置 curl 超时(默认无限等),那 504 就一定会来,而且会连累整个站点。

第五步:一个真实的 499 暴涨排查记录

说个具体例子,比抽象讲原理有用。

某内容站上线了"相关文章"功能,做法是在文章页里同步调用一次站内搜索接口。上线第二天,监控显示 499 从日均 200 涨到日均 9000,同时 FPM 的 max_children 长期跑满。

排查过程:

  1. 改日志格式,加 urt。grep ' 499 ' | awk 一看,499 全部集中在文章页,且 urt 平均 8 秒、rt 平均 9 秒——慢在上游。
  2. 开 FPM 慢日志,调用栈显示卡在 curl_exec。搜索接口是站内 HTTP 调用,而这个接口本身要查数据库。
  3. 关键发现:文章页 → 搜索接口 → 数据库,形成一个同步串行链路。文章页本来只需要 200ms,硬生生被拉长到 8 秒。用户等不了 8 秒,全部由浏览器主动断开,Nginx 记 499。
  4. 而为什么 FPM 会满?因为用户虽然断开了,但 PHP 不知道,仍然在等 curl 返回,每个进程被占用 8 秒。QPS 只要到 10,就需要 80 个并发进程。

修复方案只有两条,任意一条都能解决:

# 方案A:给 curl 加超时(缓解,必须做)
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, 2);

# 方案B:改为异步/缓存(根治)
# 相关文章列表在文章保存时预计算并写入缓存,页面只读缓存
# 已发布内容的相关推荐几乎不变,完全没必要每次实时算

改完当天 499 回落到日均 100 以内。这个案例的核心教训:499 暴涨几乎总是"同步调用外部依赖 + 没设超时"的组合。内容站尤其要警惕,因为内容站的每个页面都可能触发这种调用。

第六步:不要只看 Nginx,还要看内核和 TCP

有时候 499/504 的根因根本不在应用层。这几个方向值得一查:

SYN 队列溢出

netstat -s | grep -i -E 'listen|overflow|retrans'
# 或
nstat -az | grep -i -E 'ListenOverflows|ListenDrops|TCPTimeouts'

ListenOverflows 持续增长说明 accept 队列被打满,客户端连接被内核丢弃。这时用户端看到的是连接超时,Nginx 里则可能连日志都没有。调 net.core.somaxconn 和 Nginx 的 listen 80 backlog=2048

关于 keepalive 的隐形杀手

如果 Nginx 到上游用了 keepalive 长连接,而 keepalive_timeout 比上游空闲关闭时间还长,Nginx 会往一个已经被上游关掉的连接上发请求,表现为**偶发的 502,而不是 504**。特征非常明显:502 率很低(0.1% 左右)但从不归零,且分布随机。解决方法是让 Nginx 的 keepalive_timeout 略短于上游的 idle timeout。

连接数与文件描述符

cat /proc/sys/net/core/somaxconn
ulimit -n
ss -s

ss -s 输出里的 timewait 数量如果上万,说明短连接太多。开 net.ipv4.tcp_tw_reuse = 1(客户端侧安全)配合上游 keepalive 才是正解,不要碰 tcp_tw_recycle,它在新内核里已被移除,且在 NAT 环境下会造成随机丢包。

小结:一张可执行的排查流程

  1. 改日志格式,加上 $upstream_response_time$upstream_addr$upstream_status。没有这一步,后面全是猜。
  2. 用 awk 比较 rt 与 urt,判断慢在传输段还是上游段。rt 大 urt 小 → 带宽/响应体;rt ≈ urt 且都大 → 上游。
  3. 开 PHP-FPM 慢日志(request_slowlog_timeout = 5s),拿到调用栈。这一步的投入产出比最高。
  4. 检查 FPM max_children 与进程 etimes,确认是"真并发不足"还是"假性拥堵"。
  5. 核对超时链:客户端 < Nginx < PHP < FPM < 外部服务,最外层最短原则。
  6. 给代码里所有外部 HTTP 调用加 1~2 秒的连接与总超时,无例外。
  7. 最后才去查内核:somaxconn、SYN 溢出、timewait、文件描述符。

499 和 504 不是需要"消除"的噪音,而是系统在告诉你链路上哪一段最慢。把日志字段补齐,把超时链理顺,你会发现这两类错误绝大多数都指向同一个朴素的结论:不要把用户的等待,交给你控制不了的环节。

Last modification:September 21st, 2026 at 08:24 pm

Leave a Comment