Nginx 的 499 和 504 一起涨:一次超时预算的重排
一个导出接口同时制造了 499 和 504。客户端 30 秒、Nginx 60 秒、上游无限制,三层超时全错位,还叠加了重试放大。超时怎么分层、重试为何只对幂等请求开。
周四晚上九点,网关的告警开始响。过去一小时里 499 的比例从 0.3% 涨到了 6.8%,同时 504 也冒了出来,大概占 0.9%。这两类状态码平时都很少见,一起出现就有点意思了。
我先把这两个数字的含义摆清楚,因为后面所有的判断都建立在这个区分上。499 是 Nginx 自己定义的状态码,意思是「客户端在服务端返回响应之前就关闭了连接」——请求已经打到 Nginx,Nginx 也转发给了上游,但浏览器不等了。504 是上游在 proxy_read_timeout 内没有返回任何数据,Nginx 主动断掉。
一个是用户先放弃,一个是后端太慢。它们同时上涨,说明不是单纯的后端变慢,而是整条链路的等待时间超过了某一方的耐心。
一、先把日志拆开看
告警面板只给了比例,真正有用的是逐条日志。我在网关机器上按一分钟一个桶,把 499 和 504 的 URI 分布拉出来:
# 按 URI 统计 499 / 504,只看今天 20:00-21:00
zcat /var/log/nginx/access.log-20261105.gz 2>/dev/null | \
awk '$4 ~ /05\/Nov\/2026:20:/ || $4 ~ /05\/Nov\/2026:21:/' | \
awk '$9==499 || $9==504 {print $9, $7}' | \
sort | uniq -c | sort -rn | head -20
结果非常集中:/api/order/export 一个接口占了 499 的 81%,504 的 63%。这就好办了,不用在大盘上瞎猜。
再看这个接口的 $request_time 和 $upstream_response_time 分布。这两个变量一定要都打,缺一个就没法区分「是后端慢」还是「是客户端慢」:
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 uct=$upstream_connect_time '
'ua=$upstream_addr';
从日志里看,这些请求的 urt 普遍在 30 秒以上,而 rt 有的比 urt 还大一点点,有的明显小。rt 小于 urt 的那些,正是 499——客户端走了,Nginx 还在等上游,等上游返回了才发现没人接收。
二、30 秒这个数字是从哪来的
我们的 location 配置大概是这样的:
location /api/ {
proxy_pass http://backend_api;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 3;
proxy_next_upstream_timeout 10s;
}
proxy_read_timeout 60s,而 499 集中在 30 秒左右出现。30 秒这个数字不是 Nginx 给的,是用户给的。浏览器和 App 侧的 HTTP 客户端普遍设了 30 秒超时,超时就断开,Nginx 侧看到的就是 499。
所以真实的故事线是这样的:导出接口的表越来越大,P99 从 4 秒爬到 35 秒;用户端 30 秒就断了,产生 499;而 Nginx 还傻等着,等满 60 秒才认为超时,如果这时候上游返回了 502/504,proxy_next_upstream 还会重试——客户端早就走了,Nginx 却把同一个导出请求又发给下一台机器,白白再压 60 秒。
这是个放大效应。一个用户点一次导出,后端实际处理了 2 到 3 次。导出接口本身就是重查询,重复执行把数据库连接池占满,进而拖慢了所有接口,于是 504 开始在别的 URI 上出现。这就是那晚 504 不只出现在导出接口上的原因。
三、超时不是越大越好,要分层
我见过不少配置是把 proxy_read_timeout 直接调到 300s,逻辑是「反正调大就不会 504」。这个做法在导出接口上恰恰是反的:调大只会让 Nginx 更久地占着 worker 连接和上游连接,用户还是 30 秒就走,499 一点不会少,只是把资源占用时间拉长了三倍。
正确的做法是按接口的耗时特征分层,而不是全站一个值:
| 接口类型 | 典型耗时 | proxy_read_timeout |
客户端超时 | 重试策略 |
|---|---|---|---|---|
| 普通查询 API | 50–300 ms | 10s | 10s | 只对 GET 重试 1 次 |
| 写操作 API | 100–800 ms | 15s | 15s | 不重试 |
| 报表聚合 | 2–8 s | 30s | 60s | 不重试 |
| 文件导出 | 10–120 s | 180s | 异步任务,不等待 | 不重试 |
| 第三方回调 | 不确定 | 30s | 由对方定 | 不重试 |
关键在于三个超时的大小关系要固定:客户端超时 ≥ Nginx 超时 ≥ 上游自身超时。任何一层比下一层短,都会产生「上层已经放弃、下层还在干活」的浪费,而这种浪费在重试参与进来之后会成倍放大。
那晚我们的关系是颠倒的:客户端 30s < Nginx 60s < 上游无限制。三层全错位。
四、导出接口根本不该同步等
调参数只能止损,不能解决「导出要 90 秒」这个事实。HTTP 同步请求等 90 秒,无论超时怎么配都是脆的:中间任何一跳抖动、任何一次用户切后台,这次计算就白做了。
所以我们把导出改成了异步任务:
# 提交任务,立即返回 task_id
curl -s -X POST https://ispt.top/api/order/export \
-H "Content-Type: application/json" \
-d '{"range":"2026-10-01,2026-10-31","format":"xlsx"}'
# {"code":0,"data":{"taskId":"exp_8f3a91","status":"PENDING"}}
# 轮询进度
curl -s https://ispt.top/api/order/export/exp_8f3a91
# {"code":0,"data":{"status":"RUNNING","progress":47}}
# 完成后拿下载地址(带签名,5 分钟有效)
curl -s https://ispt.top/api/order/export/exp_8f3a91/download
# {"code":0,"data":{"url":"https://ispt.top/dl/...?sig=..."}}
改动不大,但收益是结构性的:接口 P99 从 35 秒降到 200 毫秒,499 直接归零;重试放大消失,数据库压力回到正常水位;用户能看到进度条,体验反而更好。
前端那边配的是 1.5 秒轮询加 90 秒总上限,超过上限提示「任务仍在后台执行,完成后会通知你」,不再让浏览器干等。
五、重试只对幂等请求开
proxy_next_upstream 是个容易被滥用的指令。它默认在 error 和 timeout 时换下一台上游重试,而 timeout 包含读超时——也就是说,一个已经让后端跑了 60 秒的写请求,会因为读超时被重发给另一台机器,同一个订单可能被扣两次款。
我们的整改是分 location 处理:
# 只读接口:允许对超时和 5xx 重试一次
location ^~ /api/query/ {
proxy_pass http://backend_api;
proxy_read_timeout 10s;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 12s;
}
# 写接口:绝不因为超时重试,只对连接失败重试
location ^~ /api/write/ {
proxy_pass http://backend_api;
proxy_read_timeout 15s;
proxy_next_upstream error;
proxy_next_upstream_tries 2;
}
proxy_next_upstream error 只覆盖「连不上」这一类,连接都没建立起来,请求肯定没被处理,重发是安全的。而 timeout 意味着请求已经送达、可能已经开始执行,这时候重发就是拿数据一致性赌运气。
顺带一个坑:proxy_next_upstream_tries 和 proxy_next_upstream_timeout 必须一起配。只配 tries,三台机器都超时的话总耗时就是 3 × 60s,比不重试还糟;只配 timeout,可能在第一台还没超时就放弃重试,等于没开。
六、499 要不要处理
有一类 499 是必须处理的,有一类不用管。
不用管的是「用户主动取消」。列表页翻到一半跳走、页面关闭、App 切后台,这些都会产生 499,只要比例不高(我们日常在 0.3% 左右)就是正常的。
要处理的是「用户等不及而放弃」,特征是集中在某个慢接口、且伴随 urt 很大。区别方法很简单:把 499 按 URI 分组,如果 80% 集中在一两个接口上,那就是后者。
我们对这类请求做了一件事:在 Nginx 层加了 proxy_ignore_client_abort off;(这是默认值,显式写出来是为了防止被人改掉),让 Nginx 在客户端断开时主动断开上游连接,而不是继续把响应读完。这个配置对导出这种大响应的场景能省下可观的带宽和上游处理时间。
更彻底的是应用层的取消传播。Spring 那边用 AsyncContext 或者 WebFlux 的话,客户端断开能触发 onCancel 回调,任务可以被真正中断。我们只在导出任务里做了这一层,因为它的成本最高,收益也最集中。
七、复盘:这次真正学到的东西
第一,499 和 504 要分开看。前者是客户端耐心耗尽,后者是后端超出预算。同时上涨通常意味着「后端变慢 + 客户端超时不变 + 中间层还在傻等」,而不是两个独立故障。
第二,日志必须同时记录 $request_time 和 $upstream_response_time。只有总耗时,你永远分不清是网关慢、网络慢还是上游慢;只有上游耗时,你发现不了「客户端已经走了」这件事。
第三,超时要有预算概念,三层(客户端 / 网关 / 上游)的大小关系必须固定,且网关层要小于客户端层。反了就会产生无效等待和重试放大。
第四,超时时间不该全站一个值。按接口耗时特征分层,本质上是承认不同接口有不同的 SLA,而不是假装它们一样。
第五,重试只对幂等请求开,且只对「确定没被处理」的失败(连接失败)开。对超时重试写请求,是在用数据一致性换可用性,这笔账通常不划算。
小结
- 499 是客户端提前断开,504 是上游超时。两者同时上涨,先怀疑「链路等待时间超过客户端耐心」,而不是后端单点故障。
- 用
$request_time和$upstream_response_time两个变量对照,才能区分客户端先走还是后端太慢;只有其中一个都会误判。 - 超时配三层:客户端 ≥ Nginx ≥ 上游。任何一层比下一层短,都会造成「上层放弃、下层白干」。
proxy_read_timeout调大不减少 499,只会拉长资源占用。慢接口要改成异步任务,而不是把超时堆到 300 秒。proxy_next_upstream只对幂等请求开timeout重试,写接口只留error;tries和timeout必须一起配,否则总耗时会翻倍。- 499 按 URI 分组,集中在一两个接口上就是真问题,分散在各处就是用户正常行为。
正在加载评论…