网关超时比服务短:一次 499 的完整归因

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [网关超时比服务短:一次 499 的完整归因](#网关超时比服务短:一次 499 的完整归因)
    • [一、499 是什么](#一、499 是什么)
    • 二、一次典型的多层链路
    • [三、第 1 步:确认 499 出现在哪一层](#三、第 1 步:确认 499 出现在哪一层)
    • [四、第 2 步:把整条链路的超时列出来](#四、第 2 步:把整条链路的超时列出来)
    • [五、第 3 步:解决根因------为什么慢](#五、第 3 步:解决根因——为什么慢)
    • [六、还有一个反直觉的点:499 时上游还在跑](#六、还有一个反直觉的点:499 时上游还在跑)
    • 七、带走物

网关超时比服务短:一次 499 的完整归因

一、499 是什么

499 不是 HTTP 标准状态码,它是 Nginx 自定义的:

499 Client Closed Request ------ 客户端在 Nginx 返回响应之前就关闭了连接。

这个"客户端"不一定是浏览器,可能是:

  • 更上游的网关 / CDN / 负载均衡;
  • 调用方的 HTTP 客户端超时了主动断开;
  • 用户关掉了页面 / 取消了请求。

关键认知:499 意味着"有人比 Nginx 更早放弃了"。 所以排查方向不是"Nginx 为什么慢",而是"谁先超时的,以及超时链路上谁的值最小"。

二、一次典型的多层链路

text 复制代码
用户 → CDN(30s) → SLB(60s) → Nginx(60s) → 应用(无超时) → 数据库

每一层都有自己的超时。499 出现在 Nginx 这一层,说明 CDN 或 SLB 先超时断开了。

但注意:499 也可能出现在更下游 。如果用户直接打到 Nginx,而 Nginx 的 proxy_read_timeout 是 60s、用户客户端的超时是 5s,那么用户 5 秒就断了 → Nginx 记 499。

所以第一步是搞清楚链路有几层,每层的超时是多少。

三、第 1 步:确认 499 出现在哪一层

bash 复制代码
# Nginx access log 里找 499
awk '$9==499' /var/log/nginx/access.log | tail -20

# 统计数量和占比
awk '{c[$9]++} END {for (k in c) print k, c[k]}' /var/log/nginx/access.log | sort -rn

关键字段是 $request_time 和 $upstream_response_time:

nginx 复制代码
log_format main '$remote_addr - $request_time $upstream_response_time '
                '$status $upstream_status $request';
text 复制代码
$request_time            整个请求在 Nginx 里的耗时(从收到第一个字节到发完响应)
$upstream_response_time  上游(应用)的响应耗时

对比这两个值就能定性:

情况 含义
request_time ≈ upstream_response_time,都很大 应用慢,客户端等不及断了
request_time 大,upstream_response_time 是 - 或很小 请求没到上游/上游很快返回但 Nginx 发不出去
request_time 恰好等于客户端超时值 客户端超时导致,确认了

举例:

text 复制代码
10.0.0.5 - 5.001 - 499 "GET /report HTTP/1.1"
                 ↑ request_time 恰好 5.001 秒

5.001 这个值非常可疑------它是客户端的超时时间。 说明客户端 5 秒就放弃了,而上游还在处理。

四、第 2 步:把整条链路的超时列出来

这是最关键的一步,也是最容易做错的。超时必须逐层收敛:

text 复制代码
数据库 < 应用 < 网关 < CDN/SLB < 客户端

正确的配置应该是"越外层越大",否则外层会先放弃,内层还在白干。

一个典型的错误配置:

text 复制代码
客户端超时:3s
Nginx proxy_read_timeout:60s     ← 比客户端大 20 倍!
应用超时:无

这个配置下,客户端 3 秒就断了,Nginx 还在等上游 60 秒。结果就是满屏 499,而且上游的资源被大量无效请求占着。

正确的收敛示例

text 复制代码
用户端        :10s
CDN / SLB     :15s     (要比用户端大)
Nginx proxy_read_timeout:20s
应用 HTTP 超时:25s
下游 RPC 超时 :8s
数据库超时    :3s

每一层都比它的调用方大一点点(留 20~50% 余量),这样:

  • 正常情况下最内层先完成;
  • 如果真的超时,也是最内层先失败并快速返回,外层拿到错误响应而不是干等;
  • 不会出现"上游还在跑,下游已经放弃"的浪费。

Go 侧怎么配

go 复制代码
// HTTP 客户端超时必须小于本服务被上游允许的超时
client := &http.Client{
    Timeout: 8 * time.Second,       // 总超时
}

// 更精细的控制(推荐)
tr := &http.Transport{
    DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,     // 建连
    TLSHandshakeTimeout:   3 * time.Second,
    ResponseHeaderTimeout: 5 * time.Second,   // 等响应头
    IdleConnTimeout:       90 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 8 * time.Second}

http.Client.Timeout 是总超时 (包括读响应体),而 ResponseHeaderTimeout 只管响应头。两者都设,行为更可控。

数据库同理:

go 复制代码
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
db.QueryContext(ctx, sql)

五、第 3 步:解决根因------为什么慢

499 本身不是病,是症状。真正要治的是"为什么上游要那么久"。

bash 复制代码
# 直接打应用,量真实耗时
curl -w "ttfb=%{time_starttransfer} total=%{time_total}\n" \
     -o /dev/null -s http://10.0.1.20:8080/report

如果 ttfb 就超过客户端超时,那 499 是必然的。去查:

  • 慢 SQL(加索引、改查询);
  • 下游依赖慢(加缓存、降级);
  • 计算量大(异步化、分页)。

三条常见优化:

  1. 加缓存:热点数据不查库;
  2. 异步化:超过 2 秒的任务改成"提交任务 + 轮询结果"(返回 202);
  3. 分页 / 限流:避免一次查全量。

六、还有一个反直觉的点:499 时上游还在跑

Nginx 记了 499,但上游应用的请求并没有被取消。 它会一直跑完,然后尝试往一个已经关闭的连接写响应 → 失败。

这意味着:客户端每放弃一次,服务端就白干一次。 高并发下这会形成恶性循环:

text 复制代码
请求慢 → 客户端超时放弃 → 499
→ 客户端重试(或者用户刷新)→ 新的请求进来
→ 服务端负载更高 → 更慢 → 更多超时

这是在 502/504 之外,最容易引发雪崩的一条路径。

解法:

  1. 上游要支持 ctx 取消 (Go 里把 ctx 传到底),这样客户端断开后服务端能感知并中止;
  2. 客户端重试要有退避 + 熔断,别在超时后立刻重试;
  3. 超时值收敛(前面讲的),让内层先快速失败。
go 复制代码
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()     // 客户端断开时这个 ctx 会被取消
    rows, err := db.QueryContext(ctx, sql)    // 传下去,断开时查询会被中断
    ...
}

注意 :r.Context() 在客户端断开时会被取消------这是 Go 里实现"上游感知放弃"的关键。但前提是你在代码里真的把它传下去了。

七、带走物

排查流程:

bash 复制代码
# 1. 找 499,重点看 request_time 是不是恰好等于某个超时值
awk '$9==499 {print $NF, $request_time}' /var/log/nginx/access.log | tail -20

# 2. 对比 request_time 与 upstream_response_time
#    前者大、后者小 → 上游快,是外层先放弃
#    两者都大       → 上游慢,去优化应用

# 3. 列出整条链路的超时值,检查是否逐层收敛
#    客户端 < CDN < Nginx < 应用 < 下游 < DB

# 4. 优化根因:慢 SQL / 缓存 / 异步化

配置原则三条:

text 复制代码
1. 超时逐层收敛:外层 ≥ 内层 × 1.2~1.5
2. Go 侧 http.Client 必须设 Timeout(默认是无超时!)
3. 把 r.Context() 传到底,让上游能感知客户端放弃

一句话结论:499 是"别人比你先放弃"------先把整条链路的超时值列出来,找出最小的那个;然后按"逐层收敛"重新配一遍,再回头治上游慢的根因。

相关推荐
味悲6 小时前
Nginx笔记
linux·运维·笔记·nginx
John_Hui7 小时前
CentOS7安装Nginx
linux·服务器·nginx
我不会起名字3229 小时前
Kafka 顺序消息为什么乱了:分区数变化与重试的 3 类原因
kafka·消息队列·顺序·排查
时间售卖铺2 天前
Nginx 反向代理
运维·nginx·云原生
life码农2 天前
Nginx location 优先级详解:匹配规则、修饰符与完整代码示例
运维·nginx
爬也要爬着前进2 天前
Prometheus通过Nginx-vts-exporter模块监控nginx服务
运维·nginx·prometheus
溪语流沙2 天前
【Web全栈进阶】Nginx反向代理 + HTTPS:正式上线
前端·vue.js·python·nginx·https·fastapi
无敌糖果2 天前
Nginx 后端error.log疯狂刷盘问题分析
运维·nginx
李纲明3 天前
WordPress 古腾堡点「更新」报「不是有效的 JSON 响应」:我按 REST 接口一路查下来的顺序
运维·nginx·php·wordpress·rest api