LOGO 首页 OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 技术文档 其他文档  
 
网站管理员

Nginx 499 大量出现:客户端断连、超时链与上游慢请求

admin
2026年10月27日 0:56 本文热度 68

一、问题背景

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 的 $request_time,大量请求在 30 秒、60 秒这两个数值上"整齐地"结束——这种整数秒的聚集是超时触发的典型特征,不是用户手滑。

原因是 CDN 的回源超时配置为 30 秒,而源站这个接口在冷缓存时确实要 40 秒以上。CDN 到点主动断链,源站记 499。链路里每一跳都有自己的超时,最短的那一跳决定了用户实际能等多久。

现场三:499 和 504 一起出现,比例 1:1。

大促期间某接口 499 与 504 数量几乎相等。504 是 Nginx 等上游超时(proxy_read_timeout 60s),499 是客户端先断。两者的关系是:客户端有自己的 30 秒超时,先断;Nginx 因为 proxy_ignore_client_abort off(默认)也跟着放弃上游,于是同一条请求在不同视角下分别记为 499(Nginx access log)和"客户端中断"(应用日志)。如果客户端超时比 Nginx 长,同样的慢请求就会表现为 504。

也就是说,499 和 504 常常是同一个根因的两个截面:上游慢。区别只在于谁的耐心先用完。

1.2 为什么 499 容易被误判

误判一:把 499 当成"客户端的问题"。

499 是 Nginx 自定义的状态码(Client Closed Request),没有进入 RFC 标准。它的定义只有一句:客户端在服务端返回响应之前关闭了连接。至于客户端为什么关,Nginx 不知道,也不会记录。

一个用户在电梯里、地铁里、Wi-Fi 切 4G 时断网,是客户端问题。但一个用户在 4G 满格的情况下等 30 秒后点了"取消",是服务端问题。两者在日志里长得一模一样。

误判二:只看 499 计数,不看耗时分布。

499 的绝对值没有意义,有意义的是"499 请求的 $request_time 分布"。如果 499 集中在 0.01~0.3 秒,多半是客户端提前取消(用户快速切换页面、App 预加载被取消、浏览器 abort);如果集中在某个整数秒(10s、30s、60s、75s),几乎一定是某一跳的超时;如果分布很宽且尾部很长,是上游慢导致的连锁反应。

误判三:忽略 $upstream_response_time 与 $request_time 的大小关系。

正常请求 $request_time ≥ $upstream_response_time(代理层自己也要花时间)。如果出现 $request_time < $upstream_response_time,说明 Nginx 已经把请求发给上游、还没等到响应,客户端就断了,而 Nginx 仍在(或曾经在)等上游。这一条不等式是区分"客户端主动放弃"和"上游慢导致客户端放弃"的关键证据。

误判四:调大 proxy_read_timeout 就以为解决了。

调大超时只是让 Nginx 等得更久,客户端该断还是断。对 499 来说,能起作用的通常是:降低上游 P99 耗时、缩短链路上游的超时、把长任务改成异步、给客户端更快的首字节(流式响应/进度反馈)。超时参数是兜底,不是解药。

1.3 本文的组织方式

按"一条请求从进入到结束"的时间轴,把 499 拆成可验证的环节:

   text
客户端发起
  → [链路各跳超时] CDN / SLB / 客户端 SDK / 浏览器
  → [TCP 建连] 内核 backlog / conntrack
  → [接收请求头/体] client_header_timeout / client_body_timeout
  → [转发上游] proxy_connect_timeout
  → [等待响应] proxy_read_timeout ← 上游慢在这里体现
  → [回写响应] send_timeout / 客户端已断
  → [连接处置] keepalive / lingering_close

每个环节都给出:对应的配置项、默认值、异常时的日志特征、验证命令。这样排查时不需要背结论,按时间轴一段一段排除即可。


二、适用场景与不适用场景

2.1 适用场景

场景 A:499 占比异常升高,需要定位责任方。从日志字段出发,判断是客户端取消、链路超时、代理层超时还是上游慢。这是本文的主线。

场景 B:新建反向代理层,需要把超时体系设计对。超时不是拍一个数字,而是要和上游 P99、客户端耐心、链路各跳超时对齐。第 5.7 节给出对齐方法。

场景 C:接入 CDN / 云 SLB 后出现断连。需要理解"每一跳都有自己的超时",并找出最短的那一跳。

场景 D:大文件上传/下载、长轮询、SSE、WebSocket 场景的断连治理。这类长连接场景 499 高发,且默认配置几乎都不合适。

场景 E:499 与 502/504 混合出现,需要分清主次。先把"上游慢"这条主线切出来,再处理连接层问题。

2.2 不适用场景

  • Nginx 作为纯静态文件服务器。 静态文件响应快,499 多为客户端取消,排查路径简单,本文的长请求分析用不上。
  • Nginx 的 stream(四层)模块。 四层没有 HTTP 状态码,不会产生 499;四层断连要看 $status 之外的字段(如 $bytes_sent、$session_time)。
  • OpenResty 里 Lua 主动 ngx.exit(499) 的业务逻辑。 那是业务自定义行为,不是本文讨论的连接层 499。
  • 完全托管的云负载均衡。 云产品不暴露 Nginx 日志字段,但第 4 章的排查思路(按跳排查超时)依然适用。

三、核心知识

3.1 499 到底在哪一行代码产生的

Nginx 内部用 NGX_HTTP_CLIENT_CLOSED_REQUEST(值为 499)表示"客户端在响应发出前关闭了连接",这个返回值最终经 ngx_http_finalize_request() 写进 access log。触发点主要有三个:

触发点一:读请求头/体时客户端断开。

error.log 里对应:

   text
2026/09/18 10:22:31 [info] 23177#0: *8821331 client prematurely closed connection,
so upstream connection is closed too while sending request to upstream,
client: 203.0.113.44, server: api.example.com,
request: "POST /api/v1/report/export HTTP/1.1",
upstream: "http://10.20.1.11:8080/api/v1/report/export",
host: "api.example.com"

注意日志级别是 [info]。默认 error_log 级别是 error,所以很多环境根本看不到这条日志,只能在 access log 里看到一个 499。这是"499 无从下手"的第一个原因。

触发点二:响应还没开始发,客户端就断了。

此时 Nginx 已经在等上游。error.log 可能是空的(取决于 proxy_ignore_client_abort),access log 里 $upstream_status 为空或 -,$upstream_response_time 可能为空。

触发点三:响应发到一半,客户端断了。

$body_bytes_sent 小于 Content-Length,$request_time 明显小于上游实际耗时。这种在下载大文件、SSE 场景最常见。

3.2 日志字段的准确语义

排查 499 的前提是把字段含义搞准。以下按 Nginx 官方文档的定义说明(版本 1.30.x 一致):

变量含义单位/格式关键注意点
$request_time从读到请求第一个字节,到把响应最后一个字节写给客户端秒,毫秒精度包含客户端慢速上传的时间
$upstream_response_time与上游交互到收完响应体的总耗时(实测约等于 header_time + 响应体传输时间)秒,毫秒精度多次重试用 ,  和 :  分隔
$upstream_connect_time与上游建立连接耗时秒,毫秒精度1.9.1 起可用;含 TLS 握手
$upstream_header_time从建连到收到上游响应头首字节秒,毫秒精度1.7.10 起可用
$upstream_addr上游地址列表IP:PORT同一次请求多次尝试用 :  分隔,跨 server 用 , 
$upstream_status上游返回的状态码数字或 -与 $upstream_addr 一一对应
$body_bytes_sent发给客户端的响应体字节数字节不含响应头
$request_length请求行+请求头+请求体字节数字节判断上传是否完整
$connection_requests当前连接上第几个请求整数keepalive 复用时大于 1
$server_protocol协议版本HTTP/1.1 等判断是否走了 HTTP/2

三个派生的判断式,建议直接写进排查手册:

   text
判断 A:$request_time < $upstream_response_time
        → 客户端在收到完整响应前断开(499 的强特征)

判断 B:$upstream_header_time 接近 $upstream_response_time
        → 上游是"一次性算完再返回",首字节慢;客户端等的是首字节

判断 C:$upstream_connect_time > 0.1s
        → 建连慢,可能是上游 backlog 满、跨机房 RTT、TLS 握手开销

关于 $upstream_response_time 为空。请求没有转发到上游(比如命中本地缓存、被 limit_req 拒绝、客户端在转发前就断了),该变量为空。空值不等于 0,聚合时不能当 0 参与平均,否则会严重低估上游耗时。

关于 - 与空串。$upstream_status 在上游未响应时为 -。在 awk 里 - 会被当字符串比较,注意类型转换。

3.3 超时链:一条请求会撞上多少个超时

这是本文最需要记住的一张表。用户能等多久,等于这条链上所有超时的最小值,而不是 Nginx 的 proxy_read_timeout。

环节配置项/参数典型默认值触发后的表现
客户端 SDK/浏览器各自实现10s / 30s / 60s 不等客户端主动断,Nginx 记 499
CDN 回源回源超时(控制台配置)30s 常见CDN 断链,源站记 499
云 SLB 四层连接超时(空闲超时)900s 常见空闲连接被回收
云 SLB 七层请求超时/空闲超时60s / 900s 常见504 或断链
Nginx 读请求头client_header_timeout60s408,error.log client timed out
Nginx 读请求体client_body_timeout60s408
Nginx 建连上游proxy_connect_timeout60s502,upstream timed out
Nginx 发请求给上游proxy_send_timeout60s504
Nginx 读上游响应proxy_read_timeout60s504
Nginx 写响应给客户端send_timeout60s499 或连接关闭
Nginx 空闲长连接keepalive_timeout75s连接被服务端关闭
上游应用框架/网关超时各异上游返回 504/500 或直接断

看到问题了吗:默认配置下,Nginx 的每一个超时都是 60 秒,而客户端通常在 10~30 秒就放弃了。 这意味着默认配置下 Nginx 永远等不到自己的超时,它看到的全是 499。这就是"499 多、504 少"的配置层原因。

反过来,如果客户端耐心 120 秒而 Nginx 60 秒就断,用户看到的是 504,而应用可能还在跑。这时候要做的是让 Nginx 的超时略大于上游 P99.9,而不是无限大。

3.4 客户端为什么会断

把 499 的原因分成五类,这是后续所有排查的骨架。

类型 1:用户主动取消。用户点了返回、切了页面、下拉刷新、关闭 App。特征是 $request_time 很短(几十毫秒到几秒),请求多为 GET 或列表类接口,$body_bytes_sent 为 0。这类 499 是正常的,不需要治理,只需要在监控里排除。

类型 2:客户端超时。SDK 设置了 10s/30s,到点断。特征是 $request_time 在整数秒附近聚集,且该接口的上游 P99 大于这个值。治理方向是降低上游耗时或改异步。

类型 3:链路中间设备断链。CDN、WAF、SLB、企业出口防火墙、NAT 网关。特征是同一条链路上的所有接口 499 同时上升,且断点时间与某设备超时值吻合。NAT 设备还有个隐蔽问题:长时间空闲的连接会被 NAT 表项回收,客户端再发数据时收到 RST。

类型 4:网络质量差。移动网络切换、弱网、丢包重传。特征是 499 与客户端地域/运营商强相关,且伴随 $request_time 分布很散。用 tcpdump 能看到大量重传。

类型 5:上游太慢导致的"被动放弃"。这是最需要治理的一类。上游 P99 从 2s 涨到 40s,客户端等不住就断了,Nginx 记 499。特征是 499 与上游耗时曲线同步,$upstream_response_time 高的请求里 499 占比显著更高。

3.5 proxy_ignore_client_abort 的取舍

这个参数默认 off,含义是:客户端断开后,Nginx 立即中止与上游的交互,关闭上游连接。

   nginx
# 默认行为:客户端断,上游也断
proxy_ignore_client_abort off;

# 改为 on:客户端断,Nginx 仍把上游响应读完(读完即丢弃)
proxy_ignore_client_abort on;

两种选择的实际影响:

off(默认)的好处:上游能立刻感知到客户端走了,可以提前终止计算,省资源。坏处:如果上游是非幂等的写操作(下单、扣款),中断可能发生在"已写入但未提交"的尴尬位置,需要上游自己做事务保护。另外应用日志里会出现大量 Broken pipe、Connection reset by peer,干扰排障。

on 的好处:上游请求能完整跑完,写操作不会被打断,日志干净。坏处:客户端已经走了,上游还在算,CPU 和数据库连接被浪费;如果是 40 秒的慢查询,等于白白占用 40 秒资源。

实践建议:

  • 读接口(GET/查询):保持 off,让上游尽快释放。
  • 写接口(POST/PUT,涉及资金、库存、订单):用 location 单独设 on,保证事务完整,配合上游幂等键。
  • 长任务(导出、报表):既不要 on 也不要靠超时,改成"提交任务 → 返回 task_id → 轮询/推送结果"的异步模型。

配置示例:

   nginx
# 写接口:允许上游跑完,避免半途中断
location ~ ^/api/v1/(orders|payments|inventory)/ {
    proxy_pass http://backend_write;
    proxy_ignore_client_abort on;
    proxy_read_timeout 30s;
    proxy_next_upstream off;   # 写请求不重试,避免重复提交
}

# 读接口:客户端走了就断,尽快释放上游
location /api/v1/ {
    proxy_pass http://backend_read;
    proxy_ignore_client_abort off;
    proxy_read_timeout 10s;
    proxy_next_upstream error timeout http_502 http_503;
}

proxy_next_upstream off 对写请求很重要。默认值 error timeout 意味着上游超时后 Nginx 会把请求重发到下一台,如果第一台其实已经执行成功只是响应慢,就会产生重复下单。这个坑和 499 经常一起出现:客户端断了,Nginx 重试,上游收到两次请求。

3.6 内核与连接层因素

有些 499 的根因在 Nginx 之下。

accept 队列溢出。net.core.somaxconn 默认 4096(较新内核),listen() 的 backlog 取 min(somaxconn, 配置值)。Nginx 的 listen ... backlog=511 是默认值,在突发流量下偏小。溢出时客户端 SYN 被丢弃,表现为建连超时,客户端放弃 → 499 或直接连接失败。

   bash
# 查看当前值与溢出计数
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog
nstat -az | grep -Ei 'ListenOverflows|ListenDrops|SynRetrans'

TcpExtListenOverflows 持续增长,说明 accept 队列不够。

conntrack 表满。Nginx 前面有 iptables/nftables 或 Kubernetes Service 时,连接要进 conntrack 表。表满后新连接被丢,dmesg 会出现:

   text
nf_conntrack: nf_conntrack: table full, dropping packet
   bash
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
conntrack -C   # 当前条目数

端口耗尽。Nginx 作为客户端连上游时消耗本地端口。ip_local_port_range 默认 32768 60999,约 2.8 万个。高并发短连接场景下会出现 Cannot assign requested address。

   bash
sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse
ss -s | head -5

TIME_WAIT 堆积。主动关闭方进入 TIME_WAIT。Nginx 主动关上游连接时,TIME_WAIT 堆在 Nginx 侧。tcp_tw_reuse=1 对出向连接有效(Linux 4.12+ 更安全),能缓解端口耗尽,但对入向连接无效。


四、整体排查思路

4.1 决策树

   text
499 上升
├─ 1. 是否集中在某几个接口?
│   ├─ 是 → 看这些接口的 $request_time 分布 → 转 2
│   └─ 否 → 全站性,转 4(链路/网络)
├─ 2. $request_time 是否在整数秒聚集?
│   ├─ 是 → 某一跳超时,转 3
│   └─ 否 → 上游慢导致的被动放弃,转 5
├─ 3. 聚集值是多少?
│   ├─ 10s/30s/60s → 客户端或 CDN 超时,核对各跳配置
│   └─ 与 proxy_read_timeout 一致 → 代理层超时,但客户端更早断
├─ 4. 是否与地域/运营商/时间段相关?
│   ├─ 是 → 网络质量或中间设备,抓包验证
│   └─ 否 → 检查内核指标(backlog/conntrack/端口)
└─ 5. 上游 P99 是否同步升高?
    ├─ 是 → 按上游慢请求治理(见第五节 Step 5)
    └─ 否 → 回到 4

4.2 三条铁律

铁律一:先量化,再归因。没有 $request_time 分布的 499 计数毫无价值。第一步永远是补日志字段、做聚合,拿到"哪个接口、什么耗时、什么客户端、什么时段"。

铁律二:一条请求的 499 需要三方对账。Nginx access log、上游应用日志、客户端埋点/抓包。三方时间戳对齐后才能确定"谁先放弃"。只有 Nginx 日志时,最多只能做概率判断。

铁律三:不要用调大超时来"解决"499。调大 Nginx 超时只会把 499 变成更长时间的 499。真正有效的手段是:降低上游耗时、缩短链路超时到合理值、异步化长任务、给客户端即时反馈。

4.3 需要提前准备的东西

排查 499 是个"事后取证"的活,现场往往已经过去。所以下面这些要在平时就配好:

  1. access log 补齐 $request_time、$upstream_response_time、$upstream_connect_time、$upstream_header_time、$upstream_addr、$upstream_status、$request_length、$body_bytes_sent、$connection_requests、$server_protocol、$ssl_protocol。
  2. error.log 级别临时调到 info(或对特定 server 单独开),否则看不到 client prematurely closed connection。
  3. 日志集中化(Loki/ES),保留至少 7 天,支持按 status + uri + 耗时聚合。
  4. 内核指标采集(node_exporter 已覆盖大部分,conntrack 需要额外 collector)。
  5. 上游应用侧记录"请求开始/结束/客户端断开"三个时间点。

五、实战步骤

5.1 Step 0:环境假设与前置检查

本文命令基于以下环境,其他环境需对应调整:

   text
OS:        Rocky Linux 9.4 / Ubuntu 22.04(命令差异处会标注)
Nginx:     1.30.6(stable)或 1.28.x(legacy stable)
部署方式:   二进制包(nginx.org 官方源)或发行版包
上游:       Java/Go 应用,监听 8080
日志:       access log 自定义格式 + error log

前置检查:

   bash
# 版本与编译参数(确认是否有 VTS、stub_status 等模块)
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'nginx/|with-http|add-module'

# 配置语法检查(任何修改前必做)
nginx -t

# 当前 worker 数与连接上限
nginx -T 2>/dev/null | grep -E 'worker_processes|worker_connections|worker_rlimit_nofile'

# 确认日志路径与轮转
ls -l /var/log/nginx/
cat /etc/logrotate.d/nginx

预期输出:nginx -t 输出 syntax is ok 和 test is successful。异常表现:nginx: [emerg] unknown directive 说明配置里用了当前版本不支持的指令(常见于抄了 NGINX Plus 的 health_check)。判断依据:nginx -V 的输出里没有 --add-module=...nginx-module-vts,就不能用 VTS 的 /status 页面。

5.2 Step 1:补齐日志字段

目的:让每条日志自带定位 499 所需的全部证据。

操作:在 http 块定义格式,在 server 或 location 引用。建议用 JSON,便于 Loki/ES 解析;同时保留一份文本格式给 awk 用。

   nginx
# /etc/nginx/nginx.conf  (http 块内)
log_format main_json escape=json
    '{'
      '"time":"$time_iso8601",'
      '"remote_addr":"$remote_addr",'
      '"x_forwarded_for":"$http_x_forwarded_for",'
      '"host":"$host",'
      '"server_name":"$server_name",'
      '"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",'
      '"scheme":"$scheme",'
      '"user_agent":"$http_user_agent",'
      '"referer":"$http_referer",'
      '"request_id":"$request_id"'
    '}';

access_log /var/log/nginx/access.json.log main_json buffer=64k flush=5s;

配置说明:

  • escape=json 是必须的。少了它,UA 里的引号会把 JSON 打坏,日志采集器解析失败。
  • buffer=64k flush=5s 减少写盘次数,但会让日志延迟最多 5 秒。排查 499 时如果嫌慢,临时去掉 buffer。
  • $request_id 需要 1.11.0+,用于和应用日志做关联。前提是把 X-Request-ID 透传给上游:

   nginx
proxy_set_header X-Request-ID $request_id;

生效方式:nginx -t && nginx -s reload。reload 是平滑的,已有连接继续用旧配置,新连接用新格式。这意味着日志里会短暂混有两种格式,采集器要能容忍(或选在低峰期改)。

验证:

   bash
# 发一条测试请求,确认字段齐全
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/healthz
tail -n 1 /var/log/nginx/access.json.log | python3 -m json.tool

预期输出:完整 JSON,upstream_response_time 有数值。异常表现:python3 -m json.tool 报 Expecting ',' delimiter,说明某字段含未转义字符(通常是 $args 或 UA)。判断依据:"upstream_addr":"-" 说明请求没走 upstream(静态文件或本地 location),这类请求不参与上游耗时分析。

风险与回滚:改日志格式本身风险低,但采集侧 pipeline 如果强依赖旧格式会断。回滚方式是把 access_log 指回旧格式文件并 reload,旧文件不动。

5.3 Step 2:量化 499

目的:拿到 499 的接口分布、耗时分布、客户端分布、时间分布。

2.1 总览

   bash
# 499 占比与总量(假设日志为 combined 格式,status 在第 9 列)
awk '$9==499 {c++} {t++} END{printf "499=%d total=%d ratio=%.2f%%\n", c, t, c*100/t}' \
  /var/log/nginx/access.log

预期输出:499=3217 total=81234 ratio=3.96%判断依据:占比 <0.5% 且集中在短耗时请求,一般无需处理;>2% 或与业务投诉时间吻合,需要深挖。

2.2 按接口聚合(JSON 日志)

   bash
# 依赖 jq。统计 499 最多的 20 个接口
jq -r 'select(.status==499) | .uri' /var/log/nginx/access.json.log \
  | sort | uniq -c | sort -rn | head -20

2.3 按耗时桶聚合(关键)

   bash
# 把 499 的 request_time 分桶,看是否在整数秒聚集
jq -r 'select(.status==499) | .request_time' /var/log/nginx/access.json.log \
  | awk '{
      t=$1;
      if      (t < 0.1)  b="00_<0.1s";
      else if (t < 1)    b="01_0.1-1s";
      else if (t < 5)    b="02_1-5s";
      else if (t < 10)   b="03_5-10s";
      else if (t < 30)   b="04_10-30s";
      else if (t < 60)   b="05_30-60s";
      else               b="06_>60s";
      c[b]++;
      n++;
    } END { for (k in c) printf "%-12s %6d  %5.1f%%\n", k, c[k], c[k]*100/n }'
 \
  | sort

预期输出示例:

   text
00_<0.1s        812   25.2%
01_0.1-1s       401   12.5%
02_1-5s         288    9.0%
03_5-10s        355   11.0%
04_10-30s       902   28.0%
05_30-60s       431   13.4%
06_>60s          28    0.9%

判断依据:

  • 大量落在 00_<0.1s → 客户端主动取消(类型 1),看是不是 App 预加载、页面切换。
  • 集中在 04_10-30s 和 05_30-60s → 客户端/CDN 超时(类型 2/3),去核对各跳超时配置。
  • 06_>60s 有量 → 上游极慢,或 proxy_read_timeout 被调得很大。

2.4 按接口 × 耗时桶交叉

   bash
jq -r 'select(.status==499) | [.uri, (.request_time|tostring)] | @tsv' \
  /var/log/nginx/access.json.log \
  | awk -F'\t' '{b=($2<1)?"lt1s":(($2<30)?"1-30s":">30s"); k=$1" "b; c[k]++}
      END{for (k in c) print c[k], k}'
 | sort -rn | head -20

这一步能直接告诉你"哪个接口在哪个耗时区间出问题",是后面定位的分水岭。

2.5 时间分布

   bash
jq -r 'select(.status==499) | .time[0:16]' /var/log/nginx/access.json.log \
  | uniq -c | tail -40

看是否与定时任务、发布、备份、CDN 刷新等事件对齐。

2.6 客户端分布

   bash
jq -r 'select(.status==499) | .user_agent' /var/log/nginx/access.json.log \
  | sed -E 's/[0-9]+\.[0-9]+(\.[0-9]+)*/X/g' \
  | sort | uniq -c | sort -rn | head -15

App 版本集中 → 某版本 SDK 超时设置有问题;某运营商 IP 段集中 → 链路问题。

5.4 Step 3:区分"客户端先断"还是"上游慢"

目的:用 $request_time 与 $upstream_response_time 的大小关系做判定。

   bash
jq -r 'select(.status==499) |
  [.uri, .request_time, (.upstream_response_time // ""), (.upstream_status // "")] | @tsv'
 \
  /var/log/nginx/access.json.log \
  | awk -F'\t' '
    {
      rt=$2+0; urt=$3;
      if (urt=="" || urt=="-") { empty++; next }
      # 上游可能有多段耗时(重试),取最后一段
      n=split(urt, a, /[,:] */);
      last=a[n]+0;
      if (rt < last - 0.05)      client_first++;
      else if (rt >= last)       upstream_first++;
      total++;
    }
    END{
      printf "upstream_time_missing=%d\n", empty;
      printf "client_closed_first=%d (%.1f%%)\n", client_first, client_first*100/total;
      printf "upstream_timeout_first=%d (%.1f%%)\n", upstream_first, upstream_first*100/total;
    }'
 \
  | tee /tmp/499_split.txt

预期输出:

   text
upstream_time_missing=104
client_closed_first=2711 (86.4%)
upstream_timeout_first=402 (12.8%)

判断依据:

  • client_closed_first 占比高,说明客户端在 Nginx 拿到上游响应前就走了。要往"客户端为什么等不住"方向查(Step 5)。
  • upstream_timeout_first 占比高,说明 Nginx 自己等超时了,但状态码却是 499——这通常意味着响应发出时客户端已经断开,需要看 $upstream_status。
  • upstream_time_missing 占比高,说明请求根本没到上游(在 limit_req、client_body 阶段就断了),去看 error.log 的 408/499 记录。

注意:$upstream_response_time 含 TLS 握手和建连时间,且在多上游重试时是"每段耗时"的列表。取最后一段是近似做法,精确分析要看 $upstream_addr 的段数。

5.5 Step 4:抓包确认断连方向

当日志判断不了"谁发的 FIN/RST"时,抓包是唯一可信的证据。

目的:确认是客户端发 FIN(正常关闭)、客户端发 RST(异常关闭),还是中间设备发 RST。

   bash
# 只抓 499 高发接口的流量,避免全量抓包打爆磁盘
tcpdump -i eth0 -s 128 -w /tmp/499.pcap \
  'tcp port 443 and host 203.0.113.44' -c 20000

# 抓完立即用 tshark 提取 TCP 标志
tshark -r /tmp/499.pcap -Y 'tcp.flags.fin==1 || tcp.flags.reset==1' \
  -T fields -e frame.time -e ip.src -e tcp.srcport -e ip.dst -e tcp.dstport \
  -e tcp.flags.str 2>/dev/null | head -30

预期输出:

   text
2026-09-18 10:22:31.482  203.0.113.44  51234  10.20.0.11  443  ·······A···F
2026-09-18 10:22:41.003  10.20.0.11    443    203.0.113.44 51234 ·······A···F

判断依据:

抓包现象结论下一步
客户端先发 FIN客户端主动关闭查客户端超时/用户行为
客户端发 RST客户端进程被杀或 socket 被重置查 App 崩溃、SDK 重连
中间设备发 RST(源 IP 不是两端)防火墙/NAT/WAF 干预查设备会话表与超时
服务端发 RST 且无 FINNginx reset_timedout_connection 或 lingering_close 生效属正常行为,看是否超时触发

风险:生产抓包有 CPU 和磁盘开销。-s 128 只抓包头,-c 限制包数,抓完立刻删除。不要在核心链路上长时间抓全包。

5.6 Step 5:定位上游慢请求

如果 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 卡在 getConnectionjstack + 连接池指标
外部 HTTP 调用无超时线程卡在 socket read线程栈 + 下游 RT
锁竞争线程 BLOCKEDjstack 统计锁
GC 停顿GC 日志 pause 时间长-Xlog:gc* 或 GC 指标
同步日志写盘磁盘 IO util 100%iostat -x 1
CPU 限流(cgroup)nr_throttled 增长cat /sys/fs/cgroup/.../cpu.stat
容器内存不足 swap/oomdmesg OOM 记录kubectl describe pod

5.5 上游慢的修复顺序

  1. 先止血:对慢接口加 proxy_read_timeout 合理上限(如 15s),让 Nginx 快速失败返回 504,而不是让连接挂着。这样客户端能立刻重试或提示,而不是干等。
  2. 再降耗时:加索引、拆查询、加缓存、异步化。这部分是应用侧的工作,Nginx 侧只能配合。
  3. 最后改交互:长任务改异步(提交 → task_id → 轮询/SSE)。
  4. 兜底:限流保护(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 让客户端能区分限流和故障。

5.7 Step 6:处理链路超时错配

如果 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(或消失)。这是最直接的因果验证。

5.8 Step 7:长连接与 keepalive 相关 499

有一类 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,说明连接复用有问题。

常见根因:

  1. Nginx keepalive_timeout 75s,但中间设备(NAT/SLB)的空闲超时更短(如 60s),设备先回收,客户端不知道。
  2. 上游 keepalive 与 Nginx upstream keepalive 不匹配,Nginx 复用了已被上游关闭的连接(这个产生 502 而非 499,但同源)。
  3. 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 而不配这两个,连接可能被复用几百次直到对端关闭。

5.9 Step 8:验证修复效果

修复后必须用数据证明有效,而不是"感觉好了"。

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 业务侧验证

技术指标好转不等于业务好转。要确认:

  • 客户端埋点里"请求超时"事件数下降。
  • 客服工单里"提交失败/转圈"类反馈下降。
  • 关键接口成功率(业务口径,不是 HTTP 口径)回升。

判断依据:三者同时改善才算修好。只有 Nginx 499 下降而业务投诉不变,说明只是把 499 变成了 504(客户端还在等,只是 Nginx 先断)。

5.10 Step 9:回滚

回滚触发条件:

  • 499 率未下降反而上升超过 20%。
  • 出现新的 5xx(502/504 激增)。
  • 上游应用错误率上升(重试导致重复请求)。
  • 业务侧成功率下降。

回滚步骤:

   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,会有秒级中断,必须在窗口内做。

5.11 常用命令清单

   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

5.12 完整配置示例

把前面各节的建议合成一份可直接参考的配置。注意这是模板,所有数值都要按自己的 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 单独放宽,不能全局。

六、日志与指标

6.1 Nginx 自身能提供什么

数据源能拿到拿不到
stub_statusactive/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 就以为监控齐了,实际上状态码维度是空的。

6.2 推荐指标与告警

   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%,跑两周后按实际调整。

6.3 日志管道示例(Loki)

   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),说明上游是"算完一次性返回"。

根因:

  1. 导出接口同步生成 Excel,大数据量时耗时 40s+(应用侧日志确认:export cost=41233ms rows=182000)。
  2. Nginx proxy_read_timeout 设为 15s,到点断开,客户端收到 504 或连接关闭。
  3. CDN 回源超时 30s,比 Nginx 更长,所以部分请求在 CDN 层就断了,源站记 499。
  4. 客户端重试机制导致同一导出被请求 3 次,进一步加重数据库压力(正反馈恶化)。

修复:

  1. 导出改异步:接口立即返回 task_id,后台任务生成文件,客户端轮询 /api/v1/report/status/{id},完成后给下载链接。
  2. 过渡期把 proxy_read_timeout 调到 60s(大于 P99 41.5s),并把 CDN 回源超时调到 90s。
  3. 加 limit_req zone=export rate=1r/s burst=5,防止重试风暴。
  4. 客户端关闭自动重试(对 POST 类请求)。

验证:

   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(异步受理)

回滚预案:如果异步改造出问题,回滚到同步版本,同时保留调大的超时和限流(这两项是安全的)。回滚开关用配置中心控制,不需要发版。

复盘:

  1. 同步长任务不该放在 HTTP 请求路径里,这是设计问题,不是配置问题。
  2. 超时链没有统一设计,每一跳各配各的,最短的那跳决定了用户体验。
  3. 客户端自动重试没有幂等保护,把小问题放大成大问题。
  4. 监控只看了 5xx,没看 499,导致问题存在两周才被发现。
  5. 改进项:所有对外接口标注预期耗时;超时链纳入架构评审;499 纳入 SLO。

八、生产注意事项

变更管控:

  • 改 Nginx 配置必须走 git + MR,禁止直接 vi 生产文件。配置即代码,回滚才有依据。
  • limit_req/limit_conn 的阈值调整属于"影响面大的变更",要有灰度:先在一个 server 或一个机房生效,观察 30 分钟。
  • 改 worker_connections、worker_rlimit_nofile 需要 restart,安排在低峰窗口,并提前确认有备用节点承接流量。

执行窗口:

  • 日志格式变更:随时可做(reload),但采集侧要同步。
  • 超时参数调整:随时可做,但要观察至少一个业务高峰。
  • 限流阈值调整:避开大促和发布日。
  • 版本升级(如 1.28 → 1.30):单独窗口,注意 1.25.1 之后 http2 指令写法变化、1.27 起 listen ... http2 已废弃。

权限控制:

  • Nginx 配置目录属主 root,权限 644,只有运维组可写。
  • reload 权限通过 sudo 白名单控制:/usr/sbin/nginx -t, /usr/sbin/nginx -s reload。
  • 日志目录不要让应用用户可写,防止日志注入。

灰度与验证:

  • 多节点部署时,先 reload 一台,观察 10 分钟(499 率、5xx、P99),再全量。
  • 用 curl --resolve 直连单台验证,避免 LB 把请求分到其他节点。
  • 灰度期间对比新旧节点的 499 率,差异超过 30% 就暂停。

容量:

  • worker_connections 要大于"预期并发 × 1.2",且注意每个 upstream 连接也占一个 fd。
  • 反向代理场景,一个客户端连接对应一个上游连接,实际 fd 消耗约是并发的 2 倍。
  • ulimit -n 要大于 worker_rlimit_nofile,否则启动告警。

安全:

  • server_tokens off 隐藏版本号。
  • 不要把 $upstream_addr 暴露到响应头(信息泄露)。
  • 日志里如果有 $args,注意敏感参数(token、手机号)脱敏。Nginx 原生不支持字段脱敏,需要在采集侧用 pipeline 处理,或在应用侧避免把敏感信息放 query。

九、总结

499 不是一个"错误",而是一个"事实陈述":客户端先走了。它的价值在于把问题指向"等待"这个动作,而不是"失败"。

排查 499 的核心不是背配置,而是三件事:

  1. 把日志字段补全,尤其是 $request_time 和 $upstream_response_time 的关系。没有这两个字段,499 无法归因。
  2. 理解超时链。用户能等多久,等于链路上所有超时和客户端耐心的最小值。499 聚集在哪个整数秒,就去找那一跳。
  3. 区分"客户端不等了"和"上游太慢了"。前者是交互设计问题,后者是性能问题,治理手段完全不同。

最后一句实话:大部分 499 的根因不在 Nginx。Nginx 只是那个"如实记录"的角色。真正要改的,往往是上游那个跑了 40 秒的同步任务,和那个没有超时设计的客户端 SDK。


阅读原文:原文链接


该文章在 2026/10/27 10:44:22 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2026 ClickSun All Rights Reserved  粤ICP备13012886号-2  粤公网安备44030602007207号