那是一段让我至今心有余悸的偶发故障。我们的网关每隔一阵就会冒出一小撮 502,比例不高,大概千分之三,监控曲线上看就是几个孤零零的尖刺,既不在流量高峰、也对不上任何一次发布。客户偶尔投诉"下单点一次失败、再点一次就好了",我们查了半天日志,后端订单服务自己的日志里干干净净,没有任何报错——请求压根没进到业务逻辑里,就在网络层被掐断了。这种"查无此错"的偶发故障,是最磨人的一类。
这篇我想老老实实把这次故障从头到尾复盘一遍:我们怎么从一堆没有规律的 502 里,一步步把根因挖到 TCP 连接复用和 keep-alive 空闲超时这一层,中间走过哪些弯路,最后又是怎么连带把超时、重试、连接池、DNS 这几个网络层的老问题一起治了。所有命令、配置和数字都是当时的真实记录。
故障现场:查无此错的 502
先交代背景。我们这套系统是典型的微服务结构:最外层是 Nginx 网关,往后是订单服务,订单服务再通过 HTTP 调库存服务、用户服务等几个下游。日常 QPS 大概三四千,不算高。502 集中出现在网关到订单服务这一跳:Nginx 的错误日志里是 upstream prematurely closed connection while reading response header from upstream,翻译过来就是"我正打算从上游读响应,结果这条连接被上游提前关掉了"。
关键线索有两个:第一,502 是偶发的,不是持续的,说明上游服务本身没挂,只是某些连接出了问题;第二,错误信息明确说连接是被"提前关闭"的。这两点叠在一起,基本可以排除"上游服务崩溃"这个最容易先入为主的猜测——如果是服务挂了,会是大面积持续 502,而不是零星几个。
事故时间线
| 时刻 | 现象 / 动作 |
|---|---|
| T+0 | 监控告警:网关 502 比例从 0 跳到 0.3%,无规律尖刺 |
| T+15min | 查订单服务日志,业务逻辑零报错,请求根本没进来 |
| T+40min | 误判方向:怀疑上游 OOM 重启,扩容订单服务,502 依旧 |
| T+70min | 抓包发现:出问题的连接,客户端刚发请求、服务端立刻回 RST |
| T+95min | 定位:Nginx 复用了一条服务端已判定空闲超时、刚关掉的连接 |
| T+130min | 对齐 keep-alive 超时 + GET 幂等重试,502 归零 |
| T+1day | 顺带补齐超时、退避重试、连接池、DNS 缓存,根治隐患 |
第一轮排查:走错的那段弯路
说来惭愧,我们最开始判断错了。502 嘛,第一反应是"上游是不是挂了或者重启了",于是去看订单服务的内存和 GC,没发现异常,又怀疑是不是偶发 OOM 被 kill 重启,索性先扩容加了两个实例。结果 502 比例纹丝不动。这一步浪费了大概四十分钟,教训是:偶发故障最忌讳凭直觉先动手改,得先拿到证据再说。
回到证据本身。既然 Nginx 说连接被上游提前关了,那就去连接这一层看。先用 ss 看订单服务这边的连接状态分布,再上 tcpdump 抓包看出问题那一刻到底发生了什么:
# 看订单服务端口上的 TCP 连接状态分布,重点看 TIME_WAIT 和空闲连接堆积
ss -ant state all '( dport = :8080 or sport = :8080 )' | \
awk 'NR>1{print $1}' | sort | uniq -c | sort -rn
# 输出里我们看到大量 ESTAB,以及一批 TIME_WAIT,初步说明连接是长连接在复用
# 抓包,盯着网关 IP 和订单服务端口,看出问题的连接上发生了什么
tcpdump -i any -nn 'host 10.0.1.21 and port 8080' -w net.pcap
# 事后用 wireshark 过滤 tcp.flags.reset==1,找到了关键一幕:
# 网关发出 HTTP 请求(PSH) 后,服务端没有回 200,而是立刻回了一个 RST
# 再往前翻,这条连接上一次活动是在 5 秒多以前
抓包结果一下子就把方向扭正了:出问题的连接,都是被复用的长连接,而且都有一个共同特征——网关发请求之前,这条连接已经空闲了 5 秒以上。服务端在某个时刻单方面把它关了,但网关并不知道,还当它是好的、继续往上面发请求,于是收到一个 RST,Nginx 就报 502。
问题本质:一场关于"连接还活着吗"的误会
到这里根因就清晰了:这是一个经典的 HTTP keep-alive 长连接复用竞态。Nginx 为了性能,会和上游维持一个长连接池复用 TCP 连接,避免每次请求都三次握手。而上游(订单服务用的 Web 容器)也有自己的 keep-alive 空闲超时,默认比较短。当一条连接空闲时间超过了服务端的超时阈值,服务端会主动关闭它;但这个关闭的 FIN/RST 还在路上、或者网关还没处理,网关侧的连接池仍以为这条连接可用,恰好在这个窗口里又派了一个请求上去,就撞上了服务端已经关闭的连接。
把这场误会用时序图画出来,一眼就明白卡在哪了:
这里最反直觉的一点是:双方对"这条连接该不该关"的判断,是各算各的,没有协商。服务端觉得空闲够久了就关,客户端不知情还在用。只要两边的空闲超时设置不匹配——尤其是服务端比客户端短——这个竞态窗口就一直存在,流量越大、空闲连接越多,撞上的概率就越高,表现出来就是那种"低比例、无规律"的偶发 502。
修法一:让两边的 keep-alive 超时不再打架
对症下药,核心是消除那个竞态窗口。原则很简单:让主动发起请求的一方(客户端/网关)的空闲超时,短于被动接收的一方(服务端),这样总是客户端先判定连接该回收,而不会出现"服务端已经关了、客户端还在用"的情况。同时给 Nginx 到上游显式配置 keepalive 连接池,并对幂等的请求开启有限重试兜底:
# Nginx 到上游的连接池配置
upstream order_service {
server 10.0.1.21:8080;
server 10.0.1.22:8080;
# 每个 worker 与上游保持的空闲长连接数
keepalive 64;
# 客户端侧空闲超时,务必 < 服务端的 keepalive_timeout
keepalive_timeout 60s;
# 单连接复用上限,到了就重建,避免连接老化
keepalive_requests 1000;
}
server {
location /api/ {
proxy_pass http://order_service;
proxy_http_version 1.1; # 长连接必须 1.1
proxy_set_header Connection ""; # 清掉 Connection: close,才能复用
# 对幂等请求(GET 等)在连接被提前关时重试一次到另一台
proxy_next_upstream error timeout http_502;
proxy_next_upstream_tries 2;
}
}
这里要特别强调 proxy_next_upstream 的边界:它只能对幂等请求安全开启。GET、查询类请求重试一次没问题;但下单、扣款这类非幂等的 POST,绝不能无脑重试,否则一次"连接被关 + 自动重试",可能变成重复下单、重复扣款。我们的做法是只对明确幂等的接口放开重试,非幂等接口靠业务层的幂等键(去重 token)来兜底。把超时一对齐、幂等重试一加,那 0.3% 的偶发 502 当场归零。
修法二:给每一次网络调用都设上超时
502 治好后,我们顺着连接这条线往下查,又揪出一个更危险的隐患——很多下游调用压根没设超时。订单服务调库存服务用的 HTTP 客户端,是当年随手 new 出来的,连接超时、读超时全是默认值,而很多 HTTP 客户端的默认读超时是"无限等待"。平时没事,可一旦库存服务某个实例卡住(GC 停顿、慢查询、网络抖动),订单服务这边的调用线程就会一直挂在那条连接上死等,等不到响应也不释放。请求一个接一个进来,线程池里的线程一个接一个被卡住,几十秒内整个订单服务的线程池被耗尽,连健康的请求也再没线程处理——一个下游的局部慢,通过"无限等待"放大成了上游的全面雪崩。
这是分布式系统里最致命的故障模式之一。解法没有任何花哨:每一次跨网络的调用,连接超时和读超时都必须显式设置,而且要设得比你以为的更激进。下游正常响应 50ms,你给它 1 秒读超时绰绰有余,绝不能给 30 秒甚至不限——超时的意义不是"等到响应",而是"在拖垮自己之前果断放弃"。
// ❌ 反例:用默认客户端,读超时无限,一个慢上游能拖垮整个线程池
resp, err := http.Get("http://inventory/api/stock")
// ✅ 正解:连接、读写、整体三层超时都显式设定,且偏激进
client := &http.Client{
Timeout: 1500 * time.Millisecond, // 整个请求的硬上限,兜底
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 300 * time.Millisecond, // 建连超时
KeepAlive: 30 * time.Second, // 客户端侧 keep-alive
}).DialContext,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50, // 连接池上限,见修法四
IdleConnTimeout: 50 * time.Second, // 须 < 服务端 keepalive(修法一)
ResponseHeaderTimeout: 1 * time.Second, // 等响应头的超时,关键
},
}
resp, err := client.Get("http://inventory/api/stock")
设超时这件事,本质是给故障划定边界。不设超时,等于默认"我愿意为任何一个下游的任何程度的慢,无限期地搭上自己的资源"。设了超时,故障就被限制在"这一次调用失败"这个最小范围里,不会顺着调用链往上蔓延。后面要讲的重试和熔断,都建立在"超时能让请求及时失败"这个前提上——没有超时,重试和熔断都无从谈起。
修法三:重试要退避,失败要熔断
修法一里我们对幂等请求开了重试,但重试是把双刃剑:用对了是兜底,用错了是火上浇油。最危险的就是"无退避的立即重试"——下游本来就是因为过载才变慢,你一失败立刻原地重试,等于在它最虚弱的时候又压上一倍流量,把它彻底压垮,这就是著名的重试风暴。我们当时的重试逻辑就是失败立即重三次,所幸故障期间没演变成雪崩,但这是个不定时炸弹。
正确的重试要满足三个条件:只重试幂等请求、带指数退避加随机抖动、有总次数上限。退避是给下游喘息时间,抖动是避免所有客户端在同一刻一起重试形成同步冲击。再往上一层,要有熔断:当某个下游的失败率超过阈值,直接断开一段时间,所有请求快速失败(走降级逻辑),既保护自己也给下游留出恢复窗口。
# 下游调用的弹性策略(以 resilience4j 风格的配置示意)
inventory-service:
retry:
max-attempts: 3 # 含首次,最多 3 次
wait-duration: 100ms # 基础退避
exponential-backoff-multiplier: 2 # 100ms -> 200ms -> 400ms
randomized-wait-factor: 0.5 # 叠加 ±50% 抖动,打散同步重试
retry-on-idempotent-only: true # 只对幂等请求重试(关键约束)
circuit-breaker:
failure-rate-threshold: 50 # 滑动窗口内失败率 > 50% 则熔断
slow-call-duration-threshold: 1s
slow-call-rate-threshold: 80 # 慢调用占比 > 80% 也算异常
wait-duration-in-open-state: 10s # 熔断后冷却 10s 再半开试探
sliding-window-size: 100
这套组合拳的逻辑链条是:超时让单次调用及时失败 → 退避重试在不踩踏下游的前提下兜底偶发抖动 → 熔断在下游真的不行时果断止损并降级。三者缺一不可:只有超时没重试,偶发抖动会暴露给用户;有重试没退避,会演成重试风暴;有重试没熔断,下游持续故障时你会一直无谓地重试、拖死自己。把这条链补全,系统对下游故障的容忍度上了一个台阶。
修法四:连接池与 TIME_WAIT,别让连接本身成为瓶颈
抓包时我们还注意到一批 TIME_WAIT 状态的连接。TIME_WAIT 是主动关闭连接的一方,在连接关闭后会保留 2MSL(通常 60 秒)的时间,用来确保最后的 ACK 送达、并清掉网络里可能残留的旧报文。它本身是 TCP 正常机制,但如果你的服务大量使用短连接——每次请求都新建一条 TCP、用完就关——在高 QPS 下,主动关闭方会瞬间堆积成千上万的 TIME_WAIT,耗尽本地端口,新连接建不出来,表现为 cannot assign requested address。
根治办法不是去暴力调那些有副作用的内核参数,而是从根上减少连接的创建与销毁——用连接池复用长连接。一条连接服务成百上千个请求,创建销毁的频率降下来,TIME_WAIT 自然就退潮了。下面是排查和调优连接复用的几个固定动作:
# 看 TIME_WAIT 到底堆了多少
ss -ant | awk '{print $1}' | grep -c TIME-WAIT
# 看本地端口范围够不够用(短连接高并发时容易被 TIME_WAIT 占满)
sysctl net.ipv4.ip_local_port_range
# 安全且推荐:开启 TIME_WAIT 连接的快速复用(仅对出站连接,无 NAT 副作用)
sysctl -w net.ipv4.tcp_tw_reuse=1
# 注意:tcp_tw_recycle 在 NAT 环境下会丢包,新内核已移除,绝对不要开
# 但以上都是治标。真正的解法是应用层用连接池复用长连接:
# - HTTP 客户端开启 keep-alive 并设 MaxIdleConnsPerHost(见修法二)
# - 数据库/Redis 客户端配置合理的连接池大小,别每次操作新建连接
连接池的大小也要拿捏:太小了,高并发时请求排队等连接;太大了,又会给下游和自己都压上过多并发连接、浪费资源。我们的经验值是按"下游单实例能稳定承载的并发 × 安全系数"来定,并配合监控里的"等待连接耗时"这个指标动态调整。连接是有成本的资源,既不能频繁创建销毁(短连接的病),也不能无节制地堆着(连接池过大的病),复用且适量,才是正道。
修法五:别让 DNS 解析成为每次请求的隐形税
最后一个隐患,藏在最不起眼的地方——DNS。我们的服务之间是用域名互相调用的(http://inventory/api/stock),方便做服务发现和灰度。但有个客户端实现里,每次发起请求都会重新做一次 DNS 解析,而内网 DNS 偶尔会抖动,一次解析慢个几百毫秒。平时这点延迟淹没在正常波动里看不出来,可一旦内网 DNS 服务器压力上来或者短暂故障,所有请求都会卡在 DNS 解析这一步,集体变慢——而这时候你盯着业务代码、盯着下游服务,怎么查都查不出问题,因为耗时根本没花在它们身上,全花在了"把域名翻译成 IP"这一步。
DNS 这一层的优化思路是缓存 + 异步刷新:解析结果按 TTL 缓存起来,绝大多数请求直接命中缓存、零解析开销;缓存过期时在后台异步刷新,而不是让正在进行的请求阻塞着等新结果。下面是排查 DNS 是否成为瓶颈、以及如何验证缓存生效的手段:
# 单独给 DNS 解析这一步计时,看它在整个请求里占多少
# %{time_namelookup} 是解析耗时,%{time_total} 是总耗时,两者一比就知道占比
curl -o /dev/null -s -w \
"dns=%{time_namelookup}s connect=%{time_connect}s total=%{time_total}s\n" \
http://inventory/api/stock
# 我们当时看到 dns 偶尔能占到 total 的一大半,实锤了 DNS 是隐形税
# 直接压测内网 DNS 服务器的响应,确认是不是它在抖
dig @10.0.0.2 inventory.svc.cluster.local +stats | grep "Query time"
# 验证:本地 DNS 缓存(nscd / systemd-resolved)是否开启并命中
systemd-resolve --statistics | grep -i cache
具体落地有几种层次:最省事的是在客户端库里启用 DNS 缓存(很多 HTTP 客户端有现成的 resolver 缓存选项);往上是部署节点级的 DNS 缓存(nscd 或 systemd-resolved);在 Kubernetes 环境里则可以用 NodeLocal DNSCache 把解析压力挡在每个节点本地。无论哪种,核心都是同一句话:DNS 解析结果是可以安全复用一小段时间的,没必要为每个请求都付一次解析的钱。把它缓存住,既消除了隐形延迟,也让你的系统在内网 DNS 抖动时多一层韧性。
五处修复的前后对比
把这五处挨个修完,核心指标的前后变化如下。注意每一项治的"病"其实都不一样,但它们有个共同的母题——都是没有正确管理网络连接的生命周期与边界:
| 问题 | 表象 | 根因 | 修复手段 | 效果 |
|---|---|---|---|---|
| keep-alive 不匹配 | 偶发 502 约 0.3% | 客户端复用了服务端已关的空闲连接 | 客户端空闲超时 < 服务端 + 幂等重试 | 502 归零 |
| 无超时 | 下游一卡,上游线程池被打满雪崩 | 读超时默认无限等待 | 连接/读/整体三层超时,从严设定 | 故障被隔离在单次调用 |
| 裸重试 | 下游过载时被重试压垮 | 失败立即原地重试,无退避 | 指数退避+抖动+次数上限+熔断 | 无重试风暴,自动降级 |
| 短连接 / TIME_WAIT | 高峰期建连失败、端口耗尽 | 频繁创建销毁连接 | 连接池复用长连接 | TIME_WAIT 退潮,建连稳定 |
| DNS 每请求解析 | 内网 DNS 抖动时全线变慢 | 每次请求重新解析、阻塞等待 | DNS 缓存 + 异步刷新 | 解析开销趋近于零 |
遇到偶发网络故障,按这棵树排
这次踩坑之后,我把网络偶发故障的排查路径沉淀成一棵决策树。要点是:先看证据(抓包/连接状态)再动手,别凭直觉先改配置。
我们后来沉淀的几条网络调用原则
这次故障最大的收获,不是修好了那 0.3% 的 502,而是把"如何安全地发起一次网络调用"这件事想透了。下面这几条进了我们的服务模板和评审清单:
- 任何跨网络调用必须设超时,且连接超时、读超时、整体超时三层都要显式配置,宁可设激进一点,也不能留"无限等待"。
- 长连接两端的空闲超时要协调:主动发起方(客户端)的空闲回收时间,必须短于被动方(服务端)的 keep-alive 超时,从根上消除复用竞态。
- 重试只对幂等请求开,且必须带指数退避 + 随机抖动 + 次数上限;非幂等操作靠业务幂等键去重,绝不靠传输层重试。
- 给关键下游配熔断和降级,失败率/慢调用率超阈值就快速失败,保护自己也给下游留恢复窗口。
- 能复用就别新建连接:HTTP、数据库、Redis 一律走连接池,连接池大小按下游承载力来定并纳入监控。
- DNS 结果要缓存,别让每个请求都付一次解析的税;内网服务发现尤其要做好本地缓存兜底。
- 排查偶发故障先取证再动手:抓包、看连接状态、给各阶段单独计时,拿到证据再改,别让"先扩容试试"浪费黄金时间。
写在最后
回头看,这次故障的根因——keep-alive 空闲超时不匹配——说穿了非常朴素,甚至有点"就这?"的感觉。但它能藏这么久、这么难查,恰恰是因为网络层的问题往往不在你的业务代码里,而在连接的生命周期、在两个系统对"连接还活着吗"这个问题各自的判断里。业务日志干干净净,因为请求根本没走到业务那一层。
这些年我越来越确信一件事:分布式系统里,真正的复杂度大多不在单个服务内部,而在服务与服务之间那条看不见的网络上。超时、重试、连接复用、熔断、DNS——这些听起来都是基础概念,可一旦哪个环节的边界没划清楚,它就会在某个不经意的时刻,以"偶发、无规律、查无此错"的面目找上你。把每一次网络调用都当成一件需要认真对待的事,给它设好超时、规划好重试、管好连接——这点投入,远比事后对着一堆没有规律的尖刺干瞪眼要划算得多。
—— 别看了 · 2026