我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [网关超时比服务短:一次 499 的完整归因](#网关超时比服务短:一次 499 的完整归因)
-
- [一、499 是什么](#一、499 是什么)
- 二、一次典型的多层链路
- [三、第 1 步:确认 499 出现在哪一层](#三、第 1 步:确认 499 出现在哪一层)
- [四、第 2 步:把整条链路的超时列出来](#四、第 2 步:把整条链路的超时列出来)
-
- 正确的收敛示例
- [Go 侧怎么配](#Go 侧怎么配)
- [五、第 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(加索引、改查询);
- 下游依赖慢(加缓存、降级);
- 计算量大(异步化、分页)。
三条常见优化:
- 加缓存:热点数据不查库;
- 异步化:超过 2 秒的任务改成"提交任务 + 轮询结果"(返回 202);
- 分页 / 限流:避免一次查全量。
六、还有一个反直觉的点:499 时上游还在跑
Nginx 记了 499,但上游应用的请求并没有被取消。 它会一直跑完,然后尝试往一个已经关闭的连接写响应 → 失败。
这意味着:客户端每放弃一次,服务端就白干一次。 高并发下这会形成恶性循环:
text
请求慢 → 客户端超时放弃 → 499
→ 客户端重试(或者用户刷新)→ 新的请求进来
→ 服务端负载更高 → 更慢 → 更多超时
这是在 502/504 之外,最容易引发雪崩的一条路径。
解法:
- 上游要支持 ctx 取消 (Go 里把
ctx传到底),这样客户端断开后服务端能感知并中止; - 客户端重试要有退避 + 熔断,别在超时后立刻重试;
- 超时值收敛(前面讲的),让内层先快速失败。
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 是"别人比你先放弃"------先把整条链路的超时值列出来,找出最小的那个;然后按"逐层收敛"重新配一遍,再回头治上游慢的根因。