偶发 502 故障复盘:keep-alive 超时不匹配、缺超时、重试风暴与连接池治理

一套日常 QPS 三四千的微服务系统,网关到订单服务这一跳每隔一阵就冒出大约千分之三的 502,监控曲线上是几个对不上流量高峰也对不上发布的孤立尖刺,客户投诉「点一次失败再点一次就好」,而后端订单服务的业务日志却干干净净——请求根本没进到业务逻辑,就在网络层被掐断了。这篇把这次「查无此错」的偶发故障从头复盘:一开始误判为上游 OOM 重启、白白扩容浪费了四十分钟,直到用 ss 看连接状态、tcpdump 抓包过滤 RST,才发现出问题的全是被复用的长连接、且都在空闲 5 秒以上后撞上服务端已单方面关闭的连接——根因是一个经典的 HTTP keep-alive 长连接复用竞态:客户端(Nginx 连接池)与服务端各算各的空闲超时、互不协商,服务端比客户端先关,客户端不知情还在那条连接上发请求,于是收到 RST、Nginx 报 upstream prematurely closed。对症的修法是让客户端空闲超时短于服务端、并对幂等请求开有限重试(非幂等靠业务幂等键去重),502 当场归零。再顺着连接这条线把网络层的老隐患一并治了:下游调用压根没设读超时,一个慢上游能让调用线程死等、几十秒内打满线程池演成雪崩——补上连接、读、整体三层从严超时;失败立即原地重试会在下游过载时形成重试风暴——改成指数退避加随机抖动加次数上限,再叠加熔断与降级;大量短连接在高峰期堆出成千上万 TIME_WAIT 耗尽端口导致建连失败——改用连接池复用长连接;每个请求都重新做 DNS 解析、内网 DNS 一抖就全线变慢——加 DNS 缓存与异步刷新。文中给出 ss、tcpdump、curl 计时 time_namelookup、dig 等排查命令,Nginx upstream keepalive 配置、Go HTTP client 三层超时、resilience4j 风格的退避重试与熔断配置,一张 keep-alive 复用竞态的时序图、一棵「先取证再动手」的偶发网络故障排查决策树、五处修复前后对比表,以及进了服务模板与评审清单的七条网络调用原则。

那是一段让我至今心有余悸的偶发故障。我们的网关每隔一阵就会冒出一小撮 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 缓存(nscdsystemd-resolved);在 Kubernetes 环境里则可以用 NodeLocal DNSCache 把解析压力挡在每个节点本地。无论哪种,核心都是同一句话:DNS 解析结果是可以安全复用一小段时间的,没必要为每个请求都付一次解析的钱。把它缓存住,既消除了隐形延迟,也让你的系统在内网 DNS 抖动时多一层韧性。

五处修复的前后对比

把这五处挨个修完,核心指标的前后变化如下。注意每一项治的"病"其实都不一样,但它们有个共同的母题——都是没有正确管理网络连接的生命周期与边界:

问题 表象 根因 修复手段 效果
keep-alive 不匹配 偶发 502 约 0.3% 客户端复用了服务端已关的空闲连接 客户端空闲超时 < 服务端 + 幂等重试 502 归零
无超时 下游一卡,上游线程池被打满雪崩 读超时默认无限等待 连接/读/整体三层超时,从严设定 故障被隔离在单次调用
裸重试 下游过载时被重试压垮 失败立即原地重试,无退避 指数退避+抖动+次数上限+熔断 无重试风暴,自动降级
短连接 / TIME_WAIT 高峰期建连失败、端口耗尽 频繁创建销毁连接 连接池复用长连接 TIME_WAIT 退潮,建连稳定
DNS 每请求解析 内网 DNS 抖动时全线变慢 每次请求重新解析、阻塞等待 DNS 缓存 + 异步刷新 解析开销趋近于零

遇到偶发网络故障,按这棵树排

这次踩坑之后,我把网络偶发故障的排查路径沉淀成一棵决策树。要点是:先看证据(抓包/连接状态)再动手,别凭直觉先改配置。

我们后来沉淀的几条网络调用原则

这次故障最大的收获,不是修好了那 0.3% 的 502,而是把"如何安全地发起一次网络调用"这件事想透了。下面这几条进了我们的服务模板和评审清单:

  1. 任何跨网络调用必须设超时,且连接超时、读超时、整体超时三层都要显式配置,宁可设激进一点,也不能留"无限等待"。
  2. 长连接两端的空闲超时要协调:主动发起方(客户端)的空闲回收时间,必须短于被动方(服务端)的 keep-alive 超时,从根上消除复用竞态。
  3. 重试只对幂等请求开,且必须带指数退避 + 随机抖动 + 次数上限;非幂等操作靠业务幂等键去重,绝不靠传输层重试。
  4. 给关键下游配熔断和降级,失败率/慢调用率超阈值就快速失败,保护自己也给下游留恢复窗口。
  5. 能复用就别新建连接:HTTP、数据库、Redis 一律走连接池,连接池大小按下游承载力来定并纳入监控。
  6. DNS 结果要缓存,别让每个请求都付一次解析的税;内网服务发现尤其要做好本地缓存兜底。
  7. 排查偶发故障先取证再动手:抓包、看连接状态、给各阶段单独计时,拿到证据再改,别让"先扩容试试"浪费黄金时间。

写在最后

回头看,这次故障的根因——keep-alive 空闲超时不匹配——说穿了非常朴素,甚至有点"就这?"的感觉。但它能藏这么久、这么难查,恰恰是因为网络层的问题往往不在你的业务代码里,而在连接的生命周期、在两个系统对"连接还活着吗"这个问题各自的判断里。业务日志干干净净,因为请求根本没走到业务那一层。

这些年我越来越确信一件事:分布式系统里,真正的复杂度大多不在单个服务内部,而在服务与服务之间那条看不见的网络上。超时、重试、连接复用、熔断、DNS——这些听起来都是基础概念,可一旦哪个环节的边界没划清楚,它就会在某个不经意的时刻,以"偶发、无规律、查无此错"的面目找上你。把每一次网络调用都当成一件需要认真对待的事,给它设好超时、规划好重试、管好连接——这点投入,远比事后对着一堆没有规律的尖刺干瞪眼要划算得多。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

MySQL 慢查询从定位到根治:索引失效、深分页、长事务的排查与优化清单

2026-5-29 11:24:17

技术教程

Docker 镜像从 1.4GB 瘦到 90MB:多阶段构建、层缓存与 BuildKit 提速实战

2026-5-29 18:32:00

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索