API连接被重置?TCP抓包实锤网关空闲超时,两天排查全记录
凌晨五点的告警推送把我从床上薅起来的那个瞬间我还没意识到接下来两天会这么难熬。线上一个调用外部服务的核心链路突然开始大面积报错错误类型出奇一致——连接被重置、请求超时、偶发 502。整整两天我把代码、超时、连接池、DNS、本机网络全部翻了个底朝天最后在 TCP 抓包里找到了铁证:那个 RST 包根本不是我们这侧发出的是对方网关主动断的。问题确实不在我这边。这篇文章就把这次完整的排查过程记录下来。如果你也正在被API 调用老是断折磨或者正准备对接某个第三方 API、做微服务间的远程调用希望这篇能帮你少走两天弯路。1. 现象复现:不是偶发的闪断而是有规律的中断1.1 告警里呈现的症状长什么样那天的告警不是一次性的而是一阵一阵的浪涌式异常。每隔 10 到 20 分钟就会出现一批失败请求持续 1 到 3 分钟后又自动恢复。报错集中在三种类型:建立连接阶段超时connect timeout已经建立连接、发送请求后读响应阶段超时read timeout直接收到 Connection reset by peer这几种报错混在一起最容易让人误判。connect timeout 会让人觉得是网络不通read timeout 会让人觉得是对方处理太慢connection reset 又会让人往自己代码上想——是不是我关闭连接的姿势不对是不是连接池里的连接被别人动过但有一个细节我一开始没太注意后来回头看非常关键:失败请求并不是均匀分布在所有调用方而是集中在连接池里长期闲置的那几个连接到上。也就是说空闲了一段时间的连接第一次复用的时候大概率出事。1.2 第一时间怀疑自己的原因以及这个方向为什么容易跑偏做后端的人遇到连接异常第一反应一定是查自己的代码这是职业本能也是排查里最大的时间陷阱。我当时列了几个重点怀疑对象:是不是我用了单例 HTTP Client多线程下连接状态被搞坏了是不是我在某次异常处理里误关了连接是不是超时时间设置太短稍微慢一点就触发重试重试又堆积成雪崩是不是连接池的最大连接数不够线程在排队等连接等久了就超时这些怀疑都有合理性也都是真实世界里经常出的问题。但问题的关键是我只是在怀疑而没有第一时间用证据去排除它们。结果就是第一天的大半天时间都花在了代码审阅和本地压测上没有任何收获。1.3 先排除最容易被冤枉的几个客户端环节如果在你的场景里也出现了类似的断连问题可以先花半小时做一轮快速自检把下面这几个常规背锅位排除掉:疑点快速验证方式排除结论连接被误关闭全局搜 close/reset 相关调用检查异常处理路径代码路径里没有任何主动 close 操作连接池不够用监控连接池活跃数、等待数观察峰值活跃数远低于上限没有排队等待DNS 解析异常对比服务器上的 resolve 结果和权威解析记录DNS 解析正常TTL 稳定本机网络不稳定ping 网关、查看网卡丢包统计、检查 conntrack 表出口没有丢包连接跟踪表正常超时设置过短把本地超时放宽到原来的 3 倍再压测问题依旧出现且失败特征完全不变半小时内能排除掉这些就可以非常有底气地继续往链路深处排查了。我当时就是吃了没做这轮快速排除的亏一上来就钻进了代码细节里白白浪费了大半天。2. 两天排查的证据链:从应用日志一层层抓到 TCP 报文2.1 第一天:代码审计、压测自证清白以及一个差点误导我的现象第一天下午我终于冷静下来开始系统性排查。第一步是对着代码做完整审计。我们用的是 Go 的 net/http 标准库底层走 HTTP/1.1 Keep-Alive 连接池。排查重点是:http.Transport 的 MaxIdleConns、MaxIdleConnsPerHost、IdleConnTimeout 设置是否合理有没有可能因为 Response.Body 没有完全关闭导致连接无法回收到连接池RoundTripper 是否被多次实例化造成连接池碎片化。代码审计的结果是所有 Response.Body 都正确 close 了Transport 也是全局单例配置参数都是经过线上验证的常规值。理论上不该在这里出问题。为了进一步自证清白我写了一个最小复现程序完全模拟线上调用逻辑开了 50 个 goroutine 持续循环请求同时在本地反复杀掉服务端进程来模拟异常看客户端会不会出现和线上一致的报错。但很遗憾本地无论怎么折腾都没有复现出闲置连接复用后第一次请求就 reset的规律。这个现象差点让我又一次怀疑自己。后来我才意识到本地复现不出来恰恰是重要线索——因为本地没有中间那层网关也没有对方网关的闲置超时策略。问题根本不在客户端代码里。2.2 从应用层下钻到网络层:DNS、出口网络、中间链路检查排除了代码之后我开始怀疑自己的网络环境。毕竟中间隔着公网任何一段链路出问题都可能表现为连接中断。我按顺序做了这么几件事:第一检查 DNS。用 dig 查询目标域名的解析结果确认返回的 IP 是否正常TTL 是否异常抖动。因为我怀疑过 DNS 轮询会不会一会儿解析到 A 机器、一会儿解析到 B 机器导致连接池里的连接 IP 和实际请求 IP 不一致。查下来 DNS 完全正常。第二检查出口带宽和丢包。用 mtr 连续跑了几千个包观察从本机到目标 IP 的每一跳延迟和丢包率。结果是中间链路非常稳定没有丢包也没有明显延迟抖动。第三检查系统 conntrack 表。因为公网连接会经过 NAT如果 conntrack 表项老化被清理会导致后续的 TCP 包找不到对应连接而被丢弃表现就是连接莫名断开。结果 conntrack 表也很干净没有异常。到这里答案已经慢慢清晰问题的触发点不在本机到目标服务之间的基础链路上。但如果只是停留在不是我的问题这个结论这个排查只做了一半。帮我真正锁定根因的是下钻到 TCP 报文那一层。2.3 第二天:抓包取证RST 包出现的位置说明一切第二天一早我在故障复现前提前在服务器上挂起了 tcpdump开始抓原始报文。命令很简单:sudo tcpdump -i eth0 host api.target-service.com -w /tmp/api_debug.pcap抓了大概 40 分钟后故障窗口果然又来了。我立刻停止抓包把 pcap 文件拉到本地用 Wireshark 打开加了个过滤条件tcp.flags.reset 1结果非常直观——所有故障请求对应的连接都收到了来自服务端方向的 RST 包。这里有个非常关键的分析技术我强烈建议每个做后端的人都掌握:看 RST 包的 TTL。TCP 报文头里的 TTL 每经过一个路由器会减 1所以通过对比 RST 包的 TTL 和你正常业务请求到达目标服务器后返回包的 TTL就能判断这个 RST 包到底是目标服务器发出的还是中间某个网络设备比如负载均衡器、云防火墙发出的。在我这次抓包里正常返回包的 TTL 是 52而 RST 包的 TTL 只有 46。差了整整 6 跳。这说明 RST 包的来源根本不是最终的目标服务器而是链路上比目标服务器近了 6 跳的一个中间节点——几乎可以确定是对方机房入口的负载均衡器或网关设备。到这一步问题不在我这边已经不再是个人感觉而是一个有抓包证据支撑的技术结论。接下来要搞清楚的就是网关为什么要无缘无故发 RST。3. 根因定位:服务端网关的默许断连行为3.1 谁在发 RST四元组、序列号和数据包走向都会说话在 TCP 的世界里RST 是一个强吻式的终止信号。和 FIN 的礼貌分手不同RST 意味着我不认这个连接了你重来吧。谁发的 RST谁就是对这段连接最有意见的那一方。通过 Wireshark 打开有问题的流可以看到非常清晰的画面:客户端建立了 TCP 连接完成三次握手发起了 HTTP 请求拿到了响应然后连接进入空闲状态客户端保持着 Keep-Alive等待复用空闲了大约 62 秒后客户端再次在这个连接上发请求请求包发出去的一瞬间收到一个 RST 包序列号正好落在请求包的合法确认窗口内。注意序列号落在合法窗口内这个细节。一个合法的 RST 包必须满足序列号匹配规则否则会被对端当作无效包丢弃。这个 RST 能成功拆掉连接说明它确实知道当前连接的状态。这就排除了伪造 RST这种可能性也排除了运营商乱丢 RST 的情况。它就是一个在正常 TCP 状态机内的、由中间网关发起的、有明确意图的断连行为。3.2 网关的三种常见断连动作:空闲超时、健康检查、连接表老化在我这次的案例里最终定位到的根因是——对方负载均衡网关的空闲超时Idle Timeout被设置成了大约 60 秒。TCP 和 HTTP 的连接只要空闲超过这个阈值网关就会主动发 RST 把这个僵尸连接清理掉。而我们的客户端连接池的闲置时间上限配置成了 120 秒也就是客户端以为连接还能继续用但网关在 60 秒时已经单方面杀了这条连接。于是第一次复用就成了对着墓碑说话对方直接回 RST。这种网关默许的断连在实际生产环境里非常隐蔽因为它和你的代码、你的网络都无关。常见的触发源有三种我在表格里整理了一下:触发源工作机制表现特征网关空闲超时连接空闲超过 N 秒网关主动 RST闲置后第一次复用必挂报 connection reset健康检查探活负载均衡定期探测后端探测失败时摘除并断开连接故障窗口和探活周期高度重合呈现周期性连接表老化防火墙/LB 的 conntrack 表项超时清理后续报文无法匹配请求发出去后无响应下一次连接却正常这三种情况在应用层看起来差不多但在抓包里很不一样。空闲超时对应的是空闲后第一次发包就 reset健康检查对应的是周期性窗口内连接被断连接表老化对应的则是请求发出后一直没收到回包直到超时报错。把这些特征往上一对照方向就很清晰了。3.3 为什么对方服务端日志里一切正常是最大的坑我带着抓包证据去和第三方服务商对线对方最初的态度非常典型:我们这边日志一切正常没有看到任何报错。这个对话几乎每天都在发生。问题在于应用日志正常根本不代表网络层正常。你调用的是对方的网关网关背后可能挂了一堆微服务。网关在你请求的间歇期主动清理空闲连接这时候网关不一定会把我 RST 了一条空闲连接这种事情记录到应用日志里它觉得这是正常的资源回收。要突破这个信息不对称最好的办法不是争论而是给出对方无法忽视的层次化证据。你需要一层层地证明:应用层没报错 → TCP 层确实有 RST → RST 的 TTL 指向了对方网关而不是后端机器 → RST 的时机和网关空闲超时设定完全吻合。我个人不建议在第一次沟通时就甩出结论是你们的问题而是邀请对方一起看抓包文件让对方从自己的机房侧同步做一次抓包对比。两边同时抓谁发的 RST、在哪一层发的一目了然。我们说问题不在我这边一定是要带着自己这边的完整证据链去说的这样既保护自己也节省对方的时间。4. 面对别人的问题客户端能做的自救与反制4.1 重试策略不是万能:写不好反而会把故障放大根因确认了但问题还是得解决。第三方网关的配置短时间内改不了我们能做的是先让自己这边扛住然后再推动对方修复。第一件要做的事情就是重构重试策略。很多团队对重试的认知还停留在失败了就重来一次这是很危险的。如果故障是持续性的无脑重试只会让你的请求雪崩式地打到对方网关上对方网关压力更大断了更多连接然后你重试更多最后两边一起宕机。我重构后的重试策略包含三个要点:第一指数退避加抖动。重试间隔不是固定值而是 1 秒、2 秒、4 秒……这样递增并且每次增加一个随机抖动避免所有请求在同一时刻撞在一起重试。用 Go 实现大概是这样:func nextBackoff(attempt int) time.Duration { base : time.Duration(1uint(attempt)) * time.Second jitter : time.Duration(rand.Int63n(int64(base / 2))) return base jitter }第二区分可重试和不可重试。连接超时、连接重置、502、504 是可重试的但 4xx 业务错误绝对不能重试。之前我见过有人把 401 也拿去重试的不出意外的话肯定又是白白打了一轮。第三设置重试上限和全局熔断。最多重试 3 次达到上限后直接降级处理如果连续失败率超过阈值直接熔断快速失败给对端留出恢复时间。4.2 连接池与保活参数的合理设定:既然对方要断那就主动按它的规矩来针对网关 60 秒空闲超时这个问题最优雅的解决办法是让客户端主动配合——把连接池里的空闲连接回收时间改得比网关的空闲超时更短。这样客户端永远不会去复用一条已经被网关杀掉的连接也就不存在对墓碑说话的问题了。在这个案例里我直接把连接池的 IdleConnTimeout 调整成了 45 秒。效果立竿见影故障窗口立刻消失了。这个参数在主流语言生态里都有对应设置我整理了一个表格:语言/框架参数/配置说明Go net/httpTransport.IdleConnTimeout空闲连接在连接池中的存活时间Java OkHttpConnectionPool.keepAliveDuration空闲连接保活时长默认 5 分钟Java Apache HttpClientPoolingHttpClientConnectionManager.setValidateAfterInactivity空闲连接复用前校验时长Python requestsHTTPAdapter 底层 urllib3 的 Retry 和 PoolManager 配置建议间隔小于服务端超时Node.js axioskeepAlive 配置 Agent 的 timeout客户端空闲回收策略调整的时候有一点要格外注意:这个值不能设得太短。太短会导致连接频繁重建每次都重新走 TCP 握手和 TLS 握手增加明显延迟。正确的做法是去了解对方网关的空闲超时阈值然后在这个阈值基础上打一个六到七折的安全余量。我遇到过一些团队不愿意联系对方要这个参数那也有办法:不需要问对方只需要在自己的抓包里观察——故障连接是空闲多少秒后触发 RST 的这个时间减去一秒就是你该设置的 IdleConnTimeout 上限。4.3 把证据整理成团队能看懂的报告而不是一句你们的问题推动第三方修复是这场战役的最后一公里。这步做得好不好直接决定对方是愿意配合你排查还是跟你打太极。我的经验是不要发一长串截图也不要发一句我们抓包了是你们的问题而是整理一份三方都能看懂的简报包含这几个部分:问题现象什么时间、什么接口、报什么错影响范围是什么客户端自证代码、超时、连接池、DNS、出口网络的排查结论抓包证据RST 包的 Wireshark 截图、正常包和 RST 包的 TTL 对比规律总结从抓包里看到的空闲 60 秒后第一次请求失败这个明确特征我方已经做的缓解连接池回收时间已调整故障已消失请对方确认网关的 idle timeout 配置是多少是否有连接清理策略这份报告发出去之后我第二天就收到了对方网关组的回复。他们确认了确实是网关层在空闲 62 秒时主动清理连接收敛方式就是调整网关的 keepalive 超时设置。问题到此彻底闭环。这里有个非常重要的经验和第三方协作时姿态要硬语气要软。技术证据一定要铁沟通态度一定不能有攻击性。代码不会骗人抓包不会骗人但人是会防御的。你给对方一份漂亮的证据报告本质上是在帮对方节省排查时间这比任何指责都更容易推动问题解决。4.4 事后复盘:如何让下次排查不再花上两天这次排查实际花了大约两天其中有差不多一天半是走弯路的时间。复盘下来有三件事我下次遇到类似问题时会立刻做:第一任何连接异常问题先抓包再猜原因。不是所有问题都需要抓包但一旦涉及连接被重置、超时这类网络层症状抓包应该放在很靠前的位置而不是最后一步。第二快速把客户端自证抽象成一个固定动作。DNS、网卡丢包、conntrack、代码审计、连接池参数这些完全可以固化成一份排查清单遇到连接问题直接照着跑一遍十分钟内就能排除掉大部分客户端因素。第三重视 RST 包的 TTL 分析。这是判断 RST 来自真服务器还是中间网关的黄金线索。我遇到的大部分团队在排查连接问题时都只看到了 RST 的标志位却忽略了 TTL 透露出谁在发这个包的关键信息。另外多说一句这个案例虽然在讲公网 API 调用但同样的排查思路完全可以复用于微服务内部的 RPC 调用、自建网关、以及大模型 API 调用这类长耗时请求场景。大模型推理动辄几十秒如果中间网关的 idle timeout 设置得比推理耗时还短客户端就会看到一堆莫名其妙的断开。遇到这种问题别再只盯着自己的代码了往报文层面看一眼答案往往藏在那里。最后分享一个小技巧:排查完这类问题之后记得把抓包文件和排查结论按时间归档附上核心报错样本和 RST 包特征。下次再碰到类似告警直接复制粘贴就能定位那种感觉真的是身心愉快。