Nginx 499 大量出现:客户端断连、超时链与上游慢请求
当前位置:点晴教程→知识管理交流
→『 技术文档交流 』
一、问题背景1.1 三个现场现场一:499 占全部请求的 4%,但业务说"没感觉"。 某 SaaS 系统的 Nginx 访问日志里,499 稳定在每小时 3000 条左右,占总量 4%。业务侧的投诉是"偶尔提交订单要等很久,然后提示网络异常"。运维第一反应是查后端:应用日志里没有 5xx,CPU、内存、GC 都正常,数据库慢日志干净。于是结论变成"客户端网络问题,不是我们的"。 这个结论是错的。499 只说明连接是客户端关的,不说明为什么关。用户等 8 秒后 App 弹"网络异常"并主动断开,在 Nginx 眼里同样是 499,在应用眼里是"请求处理到一半客户端不见了"。责任方可能在客户端、在链路、在代理层超时配置、也可能在上游慢请求——499 本身不指向任何一方。 现场二:加了 CDN 之后 499 暴涨。 上线 CDN 后,回源请求里 499 从 0.2% 涨到 7%,而且集中在几个大文件下载接口。查 Nginx 的 原因是 CDN 的回源超时配置为 30 秒,而源站这个接口在冷缓存时确实要 40 秒以上。CDN 到点主动断链,源站记 499。链路里每一跳都有自己的超时,最短的那一跳决定了用户实际能等多久。 现场三:499 和 504 一起出现,比例 1:1。 大促期间某接口 499 与 504 数量几乎相等。504 是 Nginx 等上游超时( 也就是说,499 和 504 常常是同一个根因的两个截面:上游慢。区别只在于谁的耐心先用完。 1.2 为什么 499 容易被误判误判一:把 499 当成"客户端的问题"。 499 是 Nginx 自定义的状态码( 一个用户在电梯里、地铁里、Wi-Fi 切 4G 时断网,是客户端问题。但一个用户在 4G 满格的情况下等 30 秒后点了"取消",是服务端问题。两者在日志里长得一模一样。 误判二:只看 499 计数,不看耗时分布。 499 的绝对值没有意义,有意义的是"499 请求的 误判三:忽略 正常请求 误判四:调大 调大超时只是让 Nginx 等得更久,客户端该断还是断。对 499 来说,能起作用的通常是:降低上游 P99 耗时、缩短链路上游的超时、把长任务改成异步、给客户端更快的首字节(流式响应/进度反馈)。超时参数是兜底,不是解药。 1.3 本文的组织方式按"一条请求从进入到结束"的时间轴,把 499 拆成可验证的环节:
每个环节都给出:对应的配置项、默认值、异常时的日志特征、验证命令。这样排查时不需要背结论,按时间轴一段一段排除即可。 二、适用场景与不适用场景2.1 适用场景场景 A:499 占比异常升高,需要定位责任方。从日志字段出发,判断是客户端取消、链路超时、代理层超时还是上游慢。这是本文的主线。 场景 B:新建反向代理层,需要把超时体系设计对。超时不是拍一个数字,而是要和上游 P99、客户端耐心、链路各跳超时对齐。第 5.7 节给出对齐方法。 场景 C:接入 CDN / 云 SLB 后出现断连。需要理解"每一跳都有自己的超时",并找出最短的那一跳。 场景 D:大文件上传/下载、长轮询、SSE、WebSocket 场景的断连治理。这类长连接场景 499 高发,且默认配置几乎都不合适。 场景 E:499 与 502/504 混合出现,需要分清主次。先把"上游慢"这条主线切出来,再处理连接层问题。 2.2 不适用场景
三、核心知识3.1 499 到底在哪一行代码产生的Nginx 内部用 触发点一:读请求头/体时客户端断开。 error.log 里对应:
注意日志级别是 触发点二:响应还没开始发,客户端就断了。 此时 Nginx 已经在等上游。error.log 可能是空的(取决于 触发点三:响应发到一半,客户端断了。
3.2 日志字段的准确语义排查 499 的前提是把字段含义搞准。以下按 Nginx 官方文档的定义说明(版本 1.30.x 一致):
三个派生的判断式,建议直接写进排查手册:
关于 关于 3.3 超时链:一条请求会撞上多少个超时这是本文最需要记住的一张表。用户能等多久,等于这条链上所有超时的最小值,而不是 Nginx 的
看到问题了吗:默认配置下,Nginx 的每一个超时都是 60 秒,而客户端通常在 10~30 秒就放弃了。 这意味着默认配置下 Nginx 永远等不到自己的超时,它看到的全是 499。这就是"499 多、504 少"的配置层原因。 反过来,如果客户端耐心 120 秒而 Nginx 60 秒就断,用户看到的是 504,而应用可能还在跑。这时候要做的是让 Nginx 的超时略大于上游 P99.9,而不是无限大。 3.4 客户端为什么会断把 499 的原因分成五类,这是后续所有排查的骨架。 类型 1:用户主动取消。用户点了返回、切了页面、下拉刷新、关闭 App。特征是 类型 2:客户端超时。SDK 设置了 10s/30s,到点断。特征是 类型 3:链路中间设备断链。CDN、WAF、SLB、企业出口防火墙、NAT 网关。特征是同一条链路上的所有接口 499 同时上升,且断点时间与某设备超时值吻合。NAT 设备还有个隐蔽问题:长时间空闲的连接会被 NAT 表项回收,客户端再发数据时收到 RST。 类型 4:网络质量差。移动网络切换、弱网、丢包重传。特征是 499 与客户端地域/运营商强相关,且伴随 类型 5:上游太慢导致的"被动放弃"。这是最需要治理的一类。上游 P99 从 2s 涨到 40s,客户端等不住就断了,Nginx 记 499。特征是 499 与上游耗时曲线同步, 3.5 |
| 抓包现象 | 结论 | 下一步 |
|---|---|---|
| 客户端先发 FIN | 客户端主动关闭 | 查客户端超时/用户行为 |
| 客户端发 RST | 客户端进程被杀或 socket 被重置 | 查 App 崩溃、SDK 重连 |
| 中间设备发 RST(源 IP 不是两端) | 防火墙/NAT/WAF 干预 | 查设备会话表与超时 |
| 服务端发 RST 且无 FIN | Nginx reset_timedout_connection 或 lingering_close 生效 | 属正常行为,看是否超时触发 |
风险:生产抓包有 CPU 和磁盘开销。-s 128 只抓包头,-c 限制包数,抓完立刻删除。不要在核心链路上长时间抓全包。
如果 Step 3 显示大量 client_closed_first,且 Step 2 的耗时桶集中在 10~60 秒,基本可以确定是上游慢导致的被动放弃。
5.1 从 Nginx 侧看上游耗时分布
bash # 只看成功的 200 请求,取上游耗时的 P50/P90/P99
jq -r 'select(.status==200) | .upstream_response_time' /var/log/nginx/access.json.log \
| awk '{n=split($0,a,/[,:] */); v[n++]=a[length(a)]+0}
END{
asort(v);
printf "P50=%.3f P90=%.3f P99=%.3f MAX=%.3f N=%d\n",
v[int(n*0.5)], v[int(n*0.9)], v[int(n*0.99)], v[n], n
}'
asort 是 gawk 扩展,Ubuntu 的 mawk 不支持。用 sort -n 替代更稳:
bash jq -r 'select(.status==200) | .upstream_response_time' /var/log/nginx/access.json.log \
| awk -F'[,:] *' '{print $NF}' \
| sort -n \
| awk '{v[NR]=$1} END{printf "P50=%.3f P90=%.3f P99=%.3f MAX=%.3f N=%d\n",
v[int(NR*0.5)], v[int(NR*0.9)], v[int(NR*0.99)], v[NR], NR}'
5.2 定位慢在哪一段
对比 $upstream_connect_time、$upstream_header_time、$upstream_response_time:
bash jq -r 'select(.uri=="/api/v1/report/export" and .status==200) |
[.upstream_connect_time, .upstream_header_time, .upstream_response_time] | @tsv' \
/var/log/nginx/access.json.log | head -20
connect_time 大(>100ms)→ 建连慢,查上游 backlog、跨机房 RTT、TLS。header_time 大而 response_time - header_time 小 → 上游"算完才返回",首字节慢。这是最常见的"慢":应用把响应缓冲到最后一次性写出。header_time 小但 response_time 大 → 流式返回,传输慢。查带宽、上游写速度。5.3 上游侧确认
在应用侧按 X-Request-ID 找到对应请求,对比三个时间点:
text t1 应用收到请求(框架 access log 或拦截器)
t2 应用开始写响应
t3 应用写完响应
如果 t2 - t1 就占了 30 秒,说明业务逻辑慢(数据库、外部调用、锁)。如果 t3 - t2 很大,说明是响应体大或客户端读得慢(send_timeout 方向)。
5.4 常见上游慢的根因清单
| 根因 | 证据 | 验证方式 |
|---|---|---|
| 数据库慢查询 | 应用日志 SQL 耗时、SHOW FULL PROCESSLIST | 慢日志 long_query_time |
| 连接池耗尽 | 应用线程 dump 卡在 getConnection | jstack + 连接池指标 |
| 外部 HTTP 调用无超时 | 线程卡在 socket read | 线程栈 + 下游 RT |
| 锁竞争 | 线程 BLOCKED | jstack 统计锁 |
| GC 停顿 | GC 日志 pause 时间长 | -Xlog:gc* 或 GC 指标 |
| 同步日志写盘 | 磁盘 IO util 100% | iostat -x 1 |
| CPU 限流(cgroup) | nr_throttled 增长 | cat /sys/fs/cgroup/.../cpu.stat |
| 容器内存不足 swap/oom | dmesg OOM 记录 | kubectl describe pod |
5.5 上游慢的修复顺序
proxy_read_timeout 合理上限(如 15s),让 Nginx 快速失败返回 504,而不是让连接挂着。这样客户端能立刻重试或提示,而不是干等。limit_req、limit_conn),避免慢请求把 worker 连接占满。nginx # 止血配置:给慢接口设更短的读超时 + 限流
location = /api/v1/report/export {
limit_req zone=export burst=20 nodelay;
limit_conn addr 4;
proxy_pass http://backend_read;
proxy_read_timeout 15s;
proxy_next_upstream error timeout;
proxy_next_upstream_tries 2;
}
配合 http 块:
nginx limit_req_zone $binary_remote_addr zone=export:10m rate=2r/s;
limit_conn_zone $binary_remote_addr zone=addr:10m;
生效与验证:
bash nginx -t && nginx -s reload
# 验证限流生效(返回 503 即触发)
for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code} ' https://api.example.com/api/v1/report/export; done; echo
风险:limit_req 误伤正常用户。rate=2r/s 对导出接口是合理的,对普通查询接口就太严。必须先看现有 QPS 分布再定阈值,并且用 limit_req_status 429 让客户端能区分限流和故障。
如果 499 集中在某个整数秒,且该值小于 Nginx 的 proxy_read_timeout,就是链路错配。
6.1 列出所有跳的超时
text 客户端 SDK 超时: 8s
CDN 回源超时: 30s
WAF 空闲超时: 120s
SLB 七层请求超时: 60s
Nginx proxy_read_timeout: 60s
上游应用超时: 45s
上游数据库查询超时: 30s
6.2 对齐原则
text 客户端超时 < CDN/SLB 超时 < Nginx proxy_read_timeout < 上游应用超时
(最小) (最大)
理由:让最早放弃的一方是客户端(用户体验可控),然后逐层兜底。如果反过来(Nginx 先超时),用户会看到 504 而不是客户端自己的友好提示;如果上游应用超时比 Nginx 还长,Nginx 已经断了上游还在跑,资源浪费。
实践取值:客户端 8~10s(交互接口),Nginx 15~20s,上游应用 12~15s,数据库 5~10s。慢接口单独一套,不要全局放开。
6.3 检查 CDN/SLB 的实际超时
云产品超时通常在控制台,不在配置文件里。排查时要做的是:找到产品文档里的超时项,与实际 499 的耗时聚集点对齐。如果 499 聚集在 30.0s,而 CDN 回源超时配的是 30s,那就是它。
验证方法:临时把 CDN 回源超时调大到 90s,观察 499 的聚集点是否从 30s 移到 90s(或消失)。这是最直接的因果验证。
有一类 499 与"连接被复用后立即断开"有关。
现象:$connection_requests 大于 1 的请求中 499 比例明显偏高。
原因:客户端复用了连接,但服务端(或中间设备)已经把这条连接关了。客户端在"以为还活着"的连接上发请求,收到 RST,表现为请求失败;如果客户端已经发出请求头,Nginx 可能记 499 或 400。
排查:
bash # 对比 keepalive 复用请求与非复用请求的 499 率
jq -r 'select(.connection_requests != null) |
[(if .connection_requests>1 then "reused" else "new" end), .status] | @tsv' \
/var/log/nginx/access.json.log \
| awk -F'\t' '{t[$1]++; if($2==499) e[$1]++}
END{for(k in t) printf "%s: total=%d 499=%d (%.2f%%)\n", k, t[k], e[k], e[k]*100/t[k]}'
判断依据:reused 的 499 率显著高于 new,说明连接复用有问题。
常见根因:
keepalive_timeout 75s,但中间设备(NAT/SLB)的空闲超时更短(如 60s),设备先回收,客户端不知道。keepalive_requests 达到上限,Nginx 主动关连接,客户端正好在发新请求。修复:
nginx # 服务端 keepalive 略小于中间设备空闲超时
keepalive_timeout 55s;
keepalive_requests 1000;
# 上游连接池:让 Nginx 主动淘汰空闲连接
upstream backend_read {
server 10.20.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.20.1.12:8080 max_fails=3 fail_timeout=10s;
keepalive 64;
keepalive_requests 1000;
keepalive_time 300s; # 1.19.10+
keepalive_timeout 55s; # 1.15.3+
}
注意 keepalive_time 和 keepalive_timeout 是两个不同指令:前者限制连接总寿命,后者限制空闲时间。只配 keepalive 64 而不配这两个,连接可能被复用几百次直到对端关闭。
修复后必须用数据证明有效,而不是"感觉好了"。
8.1 对比同一时段的 499 率
bash # 按小时统计 499 率,观察修复前后趋势
jq -r '[.time[0:13], (.status|tostring)] | @tsv' /var/log/nginx/access.json.log \
| awk -F'\t' '{t[$1]++; if($2=="499") e[$1]++}
END{for(k in t) printf "%s total=%d 499=%d ratio=%.2f%%\n", k, t[k], e[k], e[k]*100/t[k]}' \
| sort
8.2 对比上游耗时
修复的目标如果是降耗时,就要看 P99 是否真的降了:
bash # 修复前后各取一小时,比较 P99
for h in 09 10; do
echo -n "hour=$h "
jq -r --arg h "2026-09-18T$h" 'select(.time|startswith($h)) | select(.status==200) | .upstream_response_time' \
/var/log/nginx/access.json.log \
| awk -F'[,:] *' '{print $NF}' | sort -n \
| awk '{v[NR]=$1} END{printf "P50=%.3f P99=%.3f N=%d\n", v[int(NR*0.5)], v[int(NR*0.99)], NR}'
done
8.3 业务侧验证
技术指标好转不等于业务好转。要确认:
判断依据:三者同时改善才算修好。只有 Nginx 499 下降而业务投诉不变,说明只是把 499 变成了 504(客户端还在等,只是 Nginx 先断)。
回滚触发条件:
回滚步骤:
bash # 1. 保留现场(复制当前配置和日志)
cp /etc/nginx/nginx.conf /tmp/nginx.conf.$(date +%Y%m%d%H%M)
cp -r /etc/nginx/conf.d /tmp/conf.d.$(date +%Y%m%d%H%M)
# 2. 恢复上一版配置(假设用 git 管理)
cd /etc/nginx && git diff --stat
git checkout -- nginx.conf conf.d/
# 3. 语法检查后平滑 reload
nginx -t && nginx -s reload
# 4. 观察 5 分钟
sleep 300
tail -n 2000 /var/log/nginx/access.json.log | jq -r 'select(.status>=500) | .status' | sort | uniq -c
注意:reload 不会中断现有连接,但新配置下已经被拒绝的请求无法挽回。如果改的是 limit_req 导致误杀,回滚后要主动通知业务方重试。
不能用 reload 回滚的情况:改了 worker_processes、worker_rlimit_nofile、listen 的端口/协议、ssl_protocols 的底层库行为。这些需要 restart,会有秒级中断,必须在窗口内做。
bash # ---- 状态与连接 ----
nginx -t # 配置检查
nginx -T # 打印合并后的完整配置
nginx -s reload # 平滑重载
nginx -s reopen # 重开日志文件(轮转后)
systemctl status nginx
systemctl reload nginx
# ---- 连接数 ----
ss -s # 汇总
ss -lntp | grep nginx # 监听队列
ss -ant state established '( sport = :443 )' | wc -l
ss -ant state time-wait | wc -l
# ---- 进程与文件 ----
ps -eo pid,ppid,rss,pcpu,cmd | grep 'nginx: worker'
ls -l /proc/$(pgrep -o nginx)/fd | wc -l # master 的 fd 数
cat /proc/$(pgrep -f 'nginx: worker' | head -1)/limits | grep 'open files'
# ---- 内核 ----
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse
nstat -az | grep -Ei 'ListenOverflows|ListenDrops|TCPAbortOn|SyncookiesSent'
dmesg -T | grep -Ei 'conntrack|nf_conntrack|out of memory'
# ---- 日志 ----
tail -F /var/log/nginx/error.log
grep -c 'prematurely closed' /var/log/nginx/error.log
grep 'prematurely closed' /var/log/nginx/error.log | tail -5
awk '$9==499' /var/log/nginx/access.log | wc -l
# ---- 上游探测 ----
curl -o /dev/null -s -w 'dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
https://api.example.com/healthz
curl --resolve api.example.com:443:10.20.1.11 -o /dev/null -s -w '%{http_code} %{time_total}\n' \
https://api.example.com/healthz # 绕过 DNS/LB 直连某台上游
# ---- 抓包 ----
tcpdump -i eth0 -nn -s 128 'tcp port 443 and host 203.0.113.44' -c 5000 -w /tmp/x.pcap
tshark -r /tmp/x.pcap -q -z conv,tcp
把前面各节的建议合成一份可直接参考的配置。注意这是模板,所有数值都要按自己的 P99 调整。
nginx # /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
worker_rlimit_nofile 100000;
events {
worker_connections 20000;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# ---------- 日志 ----------
log_format main_json escape=json '{'
'"time":"$time_iso8601","remote_addr":"$remote_addr",'
'"x_forwarded_for":"$http_x_forwarded_for","host":"$host",'
'"method":"$request_method","uri":"$uri","args":"$args",'
'"status":$status,"request_time":$request_time,'
'"upstream_addr":"$upstream_addr","upstream_status":"$upstream_status",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"upstream_response_time":"$upstream_response_time",'
'"request_length":$request_length,"body_bytes_sent":$body_bytes_sent,'
'"connection":"$connection","connection_requests":$connection_requests,'
'"server_protocol":"$server_protocol","ssl_protocol":"$ssl_protocol",'
'"user_agent":"$http_user_agent","request_id":"$request_id"'
'}';
access_log /var/log/nginx/access.json.log main_json buffer=64k flush=5s;
error_log /var/log/nginx/error.log warn;
# ---------- 基础 ----------
sendfile on;
tcp_nopush on;
tcp_nodelay on;
server_tokens off;
types_hash_max_size 2048;
# 客户端连接
keepalive_timeout 55s;
keepalive_requests 1000;
client_header_timeout 15s;
client_body_timeout 30s;
send_timeout 30s;
client_max_body_size 50m;
client_body_buffer_size 128k;
client_header_buffer_size 4k;
large_client_header_buffers 8 16k;
# 超时后的连接用 RST 关闭,避免堆积
reset_timedout_connection on;
lingering_close on;
lingering_time 10s;
lingering_timeout 5s;
# ---------- 压缩 ----------
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types text/plain text/css application/json application/javascript
application/xml text/xml image/svg+xml;
# ---------- 限流 ----------
limit_req_zone $binary_remote_addr zone=api:20m rate=50r/s;
limit_req_zone $binary_remote_addr zone=export:10m rate=2r/s;
limit_conn_zone $binary_remote_addr zone=conn:10m;
limit_req_status 429;
limit_conn_status 429;
# ---------- 上游 ----------
upstream backend_read {
least_conn;
server 10.20.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.20.1.12:8080 max_fails=3 fail_timeout=10s;
server 10.20.1.13:8080 max_fails=3 fail_timeout=10s;
keepalive 64;
keepalive_requests 1000;
keepalive_time 300s;
keepalive_timeout 55s;
}
upstream backend_write {
server 10.20.1.11:8080 max_fails=2 fail_timeout=10s;
server 10.20.1.12:8080 max_fails=2 fail_timeout=10s;
server 10.20.1.13:8080 max_fails=2 fail_timeout=10s;
keepalive 32;
keepalive_timeout 55s;
}
# ---------- 公共代理参数 ----------
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 X-Request-ID $request_id;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 20s;
proxy_buffering on;
proxy_buffers 8 16k;
proxy_buffer_size 16k;
proxy_busy_buffers_size 32k;
proxy_max_temp_file_size 1024m;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 30s;
include /etc/nginx/conf.d/*.conf;
}
nginx # /etc/nginx/conf.d/api.conf
server {
listen 443 ssl;
http2 on;
server_name api.example.com;
ssl_certificate /etc/nginx/ssl/api.example.com.pem;
ssl_certificate_key /etc/nginx/ssl/api.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:20m;
ssl_session_timeout 1h;
# 读接口
location /api/v1/ {
limit_req zone=api burst=100 nodelay;
limit_conn conn 50;
proxy_pass http://backend_read;
proxy_ignore_client_abort off;
}
# 写接口:不重试、不中断上游
location ~ ^/api/v1/(orders|payments|inventory)/ {
proxy_pass http://backend_write;
proxy_ignore_client_abort on;
proxy_next_upstream off;
proxy_read_timeout 15s;
}
# 导出类长任务:短超时 + 强限流,引导改异步
location = /api/v1/report/export {
limit_req zone=export burst=20 nodelay;
limit_conn conn 4;
proxy_pass http://backend_read;
proxy_read_timeout 15s;
}
# SSE / 流式:必须关 buffering,超时要长
location /api/v1/stream {
proxy_pass http://backend_read;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
chunked_transfer_encoding on;
# X-Accel-Buffering: no 由上游应用返回,Nginx 据此关闭缓冲;
# 不要在 Nginx 侧用 add_header 添加,那只会把头发给客户端,不影响自身行为
}
location = /nginx-health {
access_log off;
return 200 "ok\n";
}
}
逐项说明(只讲容易错的):
http2 on; 是 1.25.1 起的新写法,替代 listen 443 ssl http2;。1.25.1 之前必须用旧写法,否则 nginx -t 报错。proxy_set_header Connection ""; 是启用 upstream keepalive 的必要条件。没有它,Nginx 会带 Connection: close,连接池形同虚设。proxy_http_version 1.1; 同上。默认是 1.0,不支持 keepalive。reset_timedout_connection on; 让超时连接以 RST 关闭,快速释放内存。副作用是客户端可能看到 Connection reset by peer,对正常请求无影响。lingering_close on; 在关闭连接前先读完客户端剩余数据,避免 RST 导致客户端收不到已发出的响应。默认就是 on,显式写出来是为了提醒不要关掉。proxy_buffering on 对普通接口有利(释放上游连接更快),但对 SSE 必须关。limit_conn conn 50 按 IP 限并发,NAT 后的办公网会共享 IP,阈值要按"一个出口 IP 背后的用户数"估算,否则误伤。生效:
bash nginx -t
nginx -s reload
# 确认新 worker 已接管
ps -eo pid,lstart,cmd | grep 'nginx: worker' | head
验证:
bash # 1. 健康检查
curl -sS -o /dev/null -w '%{http_code}\n' https://api.example.com/nginx-health
# 2. 确认 upstream keepalive 生效(上游侧看连接数应远小于 QPS)
ss -tn state established '( dport = :8080 )' | wc -l
# 3. 确认日志格式
tail -n 1 /var/log/nginx/access.json.log | jq -e '.request_time' >/dev/null && echo "json ok"
回滚:配置文件用 git 管理,git checkout -- . 后 nginx -t && nginx -s reload。改 worker_rlimit_nofile 或 worker_connections 需要 restart 才能完全生效。
风险:
worker_connections 20000 配合 worker_rlimit_nofile 100000,如果系统 fs.file-max 不够会启动失败。检查 sysctl fs.file-max。limit_req 的 burst 太小会在流量突增时大量 429。proxy_read_timeout 20s 对慢接口是致命的,必须按 location 单独放宽,不能全局。| 数据源 | 能拿到 | 拿不到 |
|---|---|---|
stub_status | active/reading/writing/waiting、accepts、handled、requests | 状态码分布、耗时、上游信息 |
nginx-prometheus-exporter(基于 stub_status) | 上述指标转 Prometheus | 同上 |
| VTS 模块(第三方) | 按 server/upstream 的请求数、状态码、流量、耗时直方图 | 需要重新编译或装包 |
| access log + 日志管道 | 全部字段 | 实时性取决于管道 |
| error log | 断连原因、上游错误 | 需要 info 级别才全 |
关键结论:499 的计数不可能从 stub_status 拿到,必须走日志。很多团队配了 nginx-prometheus-exporter 就以为监控齐了,实际上状态码维度是空的。
yaml # Prometheus 告警规则示例
# 注意:nginx_log_* 指标来自日志管道(Vector/mtail/自建 exporter)聚合,不是 stub_status 导出器提供的
groups:
- name: nginx-499
rules:
- alert: Nginx499RateHigh
expr: |
sum(rate(nginx_log_requests_total{status="499"}[5m]))
/
sum(rate(nginx_log_requests_total[5m])) > 0.02
for: 10m
labels:
severity: warning
annotations:
summary: "499 占比超过 2%(当前 {{ $value | humanizePercentage }})"
description: "检查上游 P99 与链路超时配置"
- alert: Nginx499Burst
expr: |
sum(rate(nginx_log_requests_total{status="499"}[1m])) > 50
for: 3m
labels:
severity: critical
annotations:
summary: "499 突增(>50/s)"
- alert: NginxUpstreamP99High
expr: |
histogram_quantile(0.99,
sum(rate(nginx_log_upstream_response_seconds_bucket[5m])) by (le, upstream)
) > 5
for: 10m
labels:
severity: warning
annotations:
summary: "上游 P99 > 5s({{ $labels.upstream }})"
# 连接数水位:阈值按单机 worker_connections 总量估算后写死
# 例:worker_processes=8、worker_connections=20000,理论上限 160000,取 80% = 128000
- alert: NginxConnectionsSaturated
expr: |
nginx_connections_active > 128000
for: 5m
labels:
severity: warning
annotations:
summary: "活跃连接数接近上限(当前 {{ $value }})"
关于 nginx_connections_active:这个指标来自 nginx-prometheus-exporter(读 stub_status),是真实可用的。但导出器不提供状态码维度,所以上面前三条必须依赖日志管道产生的自定义指标,名字里的 nginx_log_ 前缀就是用来区分数据来源的。写告警规则时如果把日志指标写成导出器指标名,规则永远不会触发,这是很常见的配置事故。
阈值说明:0.02(2%)不是通用值。基线应该这样定:取过去 30 天同时段 499 率的 P95,乘以 1.5 作为告警线。新系统没有基线时先用 2%,跑两周后按实际调整。
yaml # promtail 片段:解析 JSON 日志并提取标签
scrape_configs:
- job_name: nginx
static_configs:
- targets: [localhost]
labels:
job: nginx
__path__: /var/log/nginx/access.json.log
pipeline_stages:
- json:
expressions:
status: status
uri: uri
request_time: request_time
upstream_time: upstream_response_time
- labels:
status:
- metrics:
nginx_request_seconds:
type: Histogram
source: request_time
config:
buckets: [0.01, 0.05, 0.1, 0.3, 1, 3, 5, 10, 30, 60]
LogQL 查询:
logql # 499 按接口 Top10
topk(10, sum by (uri) (count_over_time({job="nginx"} | json | status=499 [5m])))
# 499 的耗时分布
sum by (le) (rate({job="nginx"} | json | status=499 | unwrap request_time [5m]))
注意:unwrap 只能用于数值字段,且 Loki 的直方图需要 quantile_over_time 配合。如果日志量很大(>10GB/天),Loki 的 unwrap 查询会很慢,建议用 mtail 或 Vector 在采集侧做聚合。
现象:2026-09-18 10:00 起,api.example.com 的 499 从 0.3% 涨到 4.1%,客服反馈"导出报表经常失败"。
初判:499 集中在导出接口,且耗时集中在 15s 和 30s 两个点。15s 与 Nginx 的 proxy_read_timeout 15s 吻合,30s 与 CDN 回源超时吻合。初步判断是上游慢 + 链路超时错配。
检查:
bash # 1. 接口分布
jq -r 'select(.status==499) | .uri' access.json.log | sort | uniq -c | sort -rn | head -5
# 输出:1842 /api/v1/report/export, 210 /api/v1/orders, ...
# 2. 耗时分布
jq -r 'select(.status==499 and .uri=="/api/v1/report/export") | .request_time' access.json.log \
| awk '{printf "%.0f\n", $1}' | sort -n | uniq -c
# 输出:1203 个 15,602 个 30
# 3. 上游耗时
jq -r 'select(.uri=="/api/v1/report/export" and .status==200) | .upstream_response_time' access.json.log \
| awk -F'[,:] *' '{print $NF}' | sort -n \
| awk '{v[NR]=$1} END{printf "P50=%.1f P90=%.1f P99=%.1f\n", v[int(NR*0.5)], v[int(NR*0.9)], v[int(NR*0.99)]}'
# 输出:P50=3.2 P90=12.8 P99=41.5
指标:上游 P99 = 41.5s,远超 Nginx 的 15s 超时。同时 upstream_header_time 与 upstream_response_time 几乎相等(差 <0.1s),说明上游是"算完一次性返回"。
根因:
export cost=41233ms rows=182000)。proxy_read_timeout 设为 15s,到点断开,客户端收到 504 或连接关闭。修复:
task_id,后台任务生成文件,客户端轮询 /api/v1/report/status/{id},完成后给下载链接。proxy_read_timeout 调到 60s(大于 P99 41.5s),并把 CDN 回源超时调到 90s。limit_req zone=export rate=1r/s burst=5,防止重试风暴。验证:
bash # 修复后 30 分钟
jq -r 'select(.status==499) | .uri' access.json.log | sort | uniq -c | sort -rn | head -3
# 导出接口 499 从 1842 降到 12
jq -r 'select(.uri=="/api/v1/report/export") | .status' access.json.log | sort | uniq -c
# 200 占绝大多数,少量 202(异步受理)
回滚预案:如果异步改造出问题,回滚到同步版本,同时保留调大的超时和限流(这两项是安全的)。回滚开关用配置中心控制,不需要发版。
复盘:
变更管控:
vi 生产文件。配置即代码,回滚才有依据。limit_req/limit_conn 的阈值调整属于"影响面大的变更",要有灰度:先在一个 server 或一个机房生效,观察 30 分钟。worker_connections、worker_rlimit_nofile 需要 restart,安排在低峰窗口,并提前确认有备用节点承接流量。执行窗口:
http2 指令写法变化、1.27 起 listen ... http2 已废弃。权限控制:
/usr/sbin/nginx -t, /usr/sbin/nginx -s reload。灰度与验证:
curl --resolve 直连单台验证,避免 LB 把请求分到其他节点。容量:
worker_connections 要大于"预期并发 × 1.2",且注意每个 upstream 连接也占一个 fd。ulimit -n 要大于 worker_rlimit_nofile,否则启动告警。安全:
server_tokens off 隐藏版本号。$upstream_addr 暴露到响应头(信息泄露)。$args,注意敏感参数(token、手机号)脱敏。Nginx 原生不支持字段脱敏,需要在采集侧用 pipeline 处理,或在应用侧避免把敏感信息放 query。499 不是一个"错误",而是一个"事实陈述":客户端先走了。它的价值在于把问题指向"等待"这个动作,而不是"失败"。
排查 499 的核心不是背配置,而是三件事:
$request_time 和 $upstream_response_time 的关系。没有这两个字段,499 无法归因。最后一句实话:大部分 499 的根因不在 Nginx。Nginx 只是那个"如实记录"的角色。真正要改的,往往是上游那个跑了 40 秒的同步任务,和那个没有超时设计的客户端 SDK。
阅读原文:原文链接