Skip to content

第33章 常见故障案例

故障报错通常只是一个入口。比如同一个“连接超时”报错,背后可能隐藏着网络路径丢包、防火墙策略拦截、目标地址配置错误,甚至是目标主机过载等诸多原因;再比如一条单纯的“重传”标记,既可能是真实的物理丢包,也可能是本机抓包时的机制缺陷导致的假象。因此,一次有效的故障诊断,需要我们把表象放回 TCP 连接的具体阶段中去审视,并通过精心设计的验证实验来层层排除干扰,最终锁定真凶。

一、统一排障模板

为了让排障过程更加系统化,建议在处理每个案例时都按以下九个维度进行记录:

  1. 现象:具体的错误报错文本是什么?故障发生的时间点、频率如何?影响了哪些用户或核心业务指标?
  2. 阶段:问题究竟发生在哪一环?是 DNS 解析、路由寻址、TCP 握手,还是数据传输、应用层处理,或者是最后的连接释放阶段?
  3. 候选原因:列出所有能够解释当前现象的可能原因,并按可能性高低排好优先级。
  4. 抓包特征:明确抓包的网络位置,记录下关键的四元组(源 IP/端口、目的 IP/端口)、数据帧号、Seq/Ack 号、窗口大小(Window Size)及时间戳。
  5. 系统状态:排查当时的操作系统状态,包括监听地址、TCP 连接状态、队列积压情况、CPU/内存等资源消耗,以及防火墙丢包计数。
  6. 应用检查点:翻阅应用日志,确认“读取完成”、“业务处理完成”、“数据写入”、“超时报错”以及“连接关闭”等关键节点的日志记录。
  7. 验证实验:设计实验来证实猜想。遵循“控制变量法”,每次只改动一个变量,优先做那些成本低、排查区分度高的测试。
  8. 处理方法:根据确诊的故障原因,对症下药。
  9. 副作用与回归:评估修复方案是否会带来安全风险、资源开销增加或延迟恶化,并记录业务恢复后的真实表现。

当故障发生时,你可以通过下面这张快速定位表来决定第一步该查哪里:

抓包时间线当前阶段首要排查方向
选对网卡抓包,却连 SYN 都没看到发起 TCP 连接或路由寻址前DNS 解析、目标 IP 配置、应用层日志、本地路由表、确认抓包过滤条件是否正确
发出 SYN 后,瞬间收到 RST+ACK连接建立阶段目标端口是否有进程监听、目标端口是否配置错误、防火墙是否配置了 Reject 策略
狂发 SYN,却始终等不到任何响应连接建立阶段网络双向连通性、防火墙 Drop 策略、目标主机宕机、半连接/全连接队列溢出
刚完成三次握手,立马收到 RST会话初始阶段服务端应用日志、应用层协议校验逻辑、服务端进程是否意外重启、中间代理拦截策略
发送请求后立马收到了 TCP ACK,但业务响应却姗姗来迟应用层处理阶段服务端应用读取速度、消息队列积压、数据库慢查询、线程死锁、下游 RPC 调用超时
接收方通告窗口(Window Size)降为 0数据传输阶段接收端应用的读取性能、Socket 接收缓冲区大小、接收端系统资源压力
收到对端的 FIN 后,本地连接长期卡在 CLOSE_WAIT 状态连接释放阶段本地应用的连接关闭逻辑(是否漏写 close())、处理线程是否阻塞、资源回收机制

二、连接建立类故障

1. Connection refused

最典型的抓包现象是:客户端刚发出 SYN 报文,目标节点紧接着就回了一个 RST, ACK 报文。这通常意味着报文确实到达了某个节点,但该节点极其明确地“拒绝”了你的连接请求。常见原因有:目标机器上的对应端口根本没有进程在监听;中间的防火墙配置了 Reject(拒绝)策略;或是反向代理等转发设备主动拒绝了连接。顺便一提,如果你在本地自己连自己的一个未监听端口,操作系统内核也会光速给你弹一个“拒绝连接”的错误。

text
tcp.flags.syn == 1 || tcp.flags.reset == 1

此时,你可以在服务端通过类似下面的命令,查验端口监听状态:

powershell
$listener = Get-NetTCPConnection -State Listen |
  Where-Object LocalPort -eq 9000 |
  Select-Object -First 1
if ($listener) {
  $listener | Format-Table LocalAddress, LocalPort, OwningProcess
  Get-Process -Id $listener.OwningProcess
} else {
  Write-Output '未找到监听 9000 端口的 TCP Socket'
}

需要特别注意监听 IP 的绑定问题。如果服务只绑定了 127.0.0.1:9000,那就只能接受本机的本地环回请求;如果是远程客户端访问,服务必须绑定一个外部可达的网卡 IP,或者直接监听通配地址(如 0.0.0.0)。当然,放宽监听地址会增加系统的暴露面,操作时务必同步确认主机的防火墙规则、身份认证机制以及安全访问边界。

一个很容易与之混淆的现象是“三次握手成功后立刻收到 RST”。区分它们的关键证据非常直白:如果是真正的 "Connection refused",RST 是紧跟在第一次 SYN 之后的;而应用层面的拒绝或异常崩溃,RST 必定出现在三次握手彻底完成之后。对于后者,你的排查重点应该立刻转向服务端的 accept() 调用栈、应用层协议头校验逻辑,以及服务进程是否发生了意外重启。

2. 连接超时与 SYN 重传

在抓包中,你会看到客户端按照 TCP 重传定时器的退避策略,一遍遍地发送相同的 SYN 报文,但死活等不到服务端的 SYN+ACK 或 RST 回应,最终只能无奈地抛出“连接超时”的异常。这类问题的“嫌疑人”很多:比如报文掉进了路由黑洞、被防火墙静默丢弃(Drop)、目标主机已经宕机离线、回程路由出现故障,或者是服务端的并发 SYN 处理能力达到了瓶颈(比如半连接队列被打满)。

powershell
Test-NetConnection 10.0.0.8 -Port 9000 -InformationLevel Detailed
Get-NetRoute -AddressFamily IPv4 | Sort-Object RouteMetric

针对这种故障,一套行之有效的最小验证步骤是:

  1. 先确认客户端选对了网卡,且 SYN 报文确确实实发了出去;
  2. 跑到服务端所在主机的入口处抓包,看看这个 SYN 报文到底有没有送达;
  3. 如果报文到了服务端,那就重点排查服务端的端口监听状态、是否有大量的 SYN_RECV 连接、TCP 队列是否溢出,以及系统资源消耗;
  4. 如果服务端明明发出了 SYN+ACK,那说明问题出在“回程路径”上,你得沿着回去的路由逐跳转发设备去找报文是在哪丢的。

注意:很多人习惯用 ping 命令来测网络连通性。千万记住,ping 成功仅仅代表当前方向上的 ICMP 测试没问题。由于防火墙和路由策略往往对 ICMP 和 TCP 采取不同的处理规则,ICMP 通了不代表 TCP 端口也能通,务必针对 TCP 端口进行独立验证。

与之相似的另一个现象是“DNS 解析慢导致的等待”。要区分两者很简单:如果是 DNS 的问题,你在 TCP 抓包里压根连 SYN 的影子都看不到。此时,你需要把目光转向 DNS 请求报文、本机的域名解析日志,或者对应用层调用的耗时进行分段埋点,以此来证实是否卡在了 DNS 环节。

3. 握手后立即 RST

如果你发现 TCP 三次握手刚刚完成,其中一端就无情地甩出一个 RST 报文把连接掐断,常见原因通常有这几个:服务端进程在调用 accept() 拿到连接后,因为某种原因直接执行了强制关闭(Abortive Close);应用层协议读取第一个数据包后发现格式不对,校验失败;中间的七层代理根据访问策略主动切断了连接;抑或是服务进程本身发生了崩溃和重启。

排查时,务必记录下 RST 报文前最后交互的一段数据、RST 的发送方向、Seq/Ack 号是否落在合法的滑动窗口内,并对照两端的应用日志仔细排查。请注意,仅仅盯着 RST 报文里的源 IP 是很难断定这个包是谁发的,因为路途上的有状态防火墙或透明代理同样有能力伪造并发送复位报文。

想要验证这类问题,你可以写一个极简的客户端脚本。分别用“完全符合协议的正确首包”和“故意写错首部格式的异常载荷”向服务端发起请求,观察两者的断开行为是否一致。当问题修复后,别忘了进行全面的回归测试:不仅要验证正常请求,还要确保系统在面对错误请求、服务端进程重启、以及连接池重建等场景时,依然能表现出预期的恢复能力。

三、连接释放类故障

1. 大量 CLOSE_WAIT

如果在服务器上敲命令发现积压了海量的 CLOSE_WAIT 状态,先别慌。本地出现 CLOSE_WAIT,意味着底层 TCP 协议栈已经收到了对端发来的 FIN 报文,并且把“对端已经没有数据要发了”这个消息通知到了上层应用。此时,本地应用仍紧紧攥着这个 Socket 句柄,还没有调用它自己的 close() 去完成最终的挥手。

系统中存在少量、短暂的 CLOSE_WAIT 是非常正常的,它只是连接关闭生命周期中的一环。但如果这个状态的数量在持续增长,那几乎可以断定是代码路径出了问题:要么是在某些异常报错的分支里漏写了 close();要么是处理该连接的业务线程死锁、长期阻塞;或者是后台的资源清理线程停滞了。

powershell
Get-NetTCPConnection -State CloseWait |
  Group-Object OwningProcess | Sort-Object Count -Descending

排查手段也很清晰:在抓包文件中定位对端的 FIN 和本地回复的 ACK,提取出该连接的四元组信息,然后顺藤摸瓜,找到本机的具体进程、线程栈和对应的请求日志。处理重点是:确保无论是正常的业务处理完毕,还是遭遇了各种超时、解析报错、客户端主动取消等异常场景,最终的代码执行流都必须进入一个确定的资源回收流程。 在线上救火时,直接杀掉进程确实能瞬间清理存量连接,但这会把该进程正在处理的其他正常请求一并粗暴掐断,应当谨慎使用。

2. 大量 TIME_WAIT

TIME_WAIT 状态永远会出现在“主动发起关闭”的一端,它的存在是为了吸收旧连接迟到的迷途数据包,并允许自己在最终的 ACK 丢失后,能再次对重传的 FIN 做出响应。在“高并发请求 + 短连接”的架构下,自然会产生较多 TIME_WAIT。诊断时,我们不应该单纯害怕这个状态,而应该去统计它的产生速率、目标四元组的分布情况、系统临时端口范围是否耗尽,以及客户端连接失败率。

连接复用可以有效减少握手、TLS 协商和关闭开销,是解决 TIME_WAIT 的正道。而通过缩短系统级 TIME_WAIT 时长或扩大端口复用范围来解决问题,会改变整台主机的连接安全边界,因此在操作前应确保压测、故障注入和回滚方案均已齐备。 另外,别把 TIME_WAIT 和 CLOSE_WAIT 搞混了。两者的区分字段是状态名、FIN 报文的方向,以及本地应用是否已经发起了关闭调用。

3. 空闲连接成为陈旧连接

当主机崩溃重启、移动网络切换(比如基站切换)或中间网络设备偷偷清除状态表后,某一端可能暂时还以为连接处于 ESTABLISHED 状态。下一次这端尝试发送数据时,要么会经历重传并最终超时,要么会立刻挨一记对端的 RST 报文。

对比防火墙、NAT、负载均衡上配置的空闲超时时间与实际的连接空闲时长,并用受控的空闲实验去复现,是定位该问题的标准做法。至于如何系统性地预防这类陈旧连接,第37章会进一步探讨 TCP Keepalive、应用心跳和业务健康检查的设计。

四、数据传输类故障

1. 读取长期阻塞与双方互相等待

这是一种让人抓狂的现场:双方连接保持在 ESTABLISHED,Window(接收窗口)正常,抓包也没有任何丢包;客户端死等响应,服务端却还在痴痴地等请求的剩余部分。这种互等通常源于应用层约定的错位:比如长度前缀字段写错、缺失了关键的分隔符、应用层只调用了一次 recv() 没做循环、或者双方对半关闭语义的理解不一致。

使用 Wireshark 的 Follow TCP Stream 功能还原字节流,并按协议规则手工算一笔账:长度前缀声明了多少、实际到达了多少、接收循环还期待收到多少。为了防患于未然,应用日志应清晰记录“首字节到达”、“完整帧到达”和“开始业务处理”这三个时间点。同时,给网络读取操作设置一个明确的总截止时间,并在触发超时后将已收字节数一并写入错误日志,这样就能把永久等待转变成可追踪的具体事件。

2. Zero Window

接收方通告 Win=0(Zero Window),表示其 TCP 接收缓冲暂时没有可用空间了。发送方收到通告后会停止推进普通数据,转而周期性地发送 Zero Window Probe(窗口探测报文);直到接收方恢复读取,通告了一个非零窗口,传输才会继续。

text
tcp.window_size == 0 || tcp.analysis.zero_window_probe || tcp.analysis.window_update

排查时,矛头应直指接收端的应用:检查它是否被慢数据库查询、锁等待、磁盘 I/O 或下游服务的队列阻塞了,顺带还要查看系统的内存压力和 Socket 缓冲配置。适当调大缓冲区确实能吸收更大的突发流量,但也会增加单连接的内存开销和排队时延。 请注意区分“接收窗口受限”和“拥塞窗口受限”:如果抓包显示接收窗口仍有很大余量但发送依然停滞,丢包现象、RTT 剧烈波动以及在途字节数的变化,往往更能解释这是拥塞控制在发挥作用。

3. 持续重传、乱序和吞吐量低

遇到这类问题,请先按第32章的内容核对抓包的完整性与网卡卸载(Offload)影响。真实的丢包常伴随相同 Seq 范围的再次发送、重复 ACK 或 SACK 的出现、在途数据回退,以及吞吐量的直线下滑;而网络乱序则表现为报文序列短暂出现缺口,稍后该缺口段到达,累计 ACK 随后迅速推进。通过在两端同时抓包,可以确诊报文到底是在发送前、路径中还是接收后神秘消失的。

低吞吐量还可能来自应用层的写入间隔太长、磁盘 I/O 瓶颈、CPU 过载、接收窗口过小、拥塞窗口爬升慢,或者是受限于带宽时延积(BDP)。记录下每秒的应用数据产出、发送速率、Bytes in flight(在途字节数)、双端窗口大小、RTT 和重传率,综合分析就能判断出瓶颈究竟卡在哪一环。

对于小请求交互时偶发的停顿,请密切关注“小写入操作”、“Nagle 算法”与“Delayed ACK(延迟确认)”这对时间组合。通过受控的 A/B 实验,你可以比较自行聚合写入和开启 TCP_NODELAY 的效果;开启后者通常会增加发包率和头部开销,所以在回归测试时,应同时观察延迟分位数的改善程度以及每秒包数的增长情况。

五、应用协议与重试故障

TCP 是流式协议,只保证字节流可靠,不懂业务边界。比如,请求头中声明长度为 1000 字节,但发送方实际只发了 800 字节就断了。TCP 会可靠地将这 800 字节确认,而接收应用会傻傻地继续等待剩下的 200 字节。为此,协议实现必须严守底线:限制最大读取长度、使用循环读取、正确识别 EOF,并在抛出异常时将“声明长度、已收长度、当前连接 ID”悉数写入错误日志。

另一个极易踩坑的典型现场是:服务端已经成功完成了扣款,但响应在返回途中丢失。客户端超时后,新建一个 TCP 连接发起重试,于是导致了两次业务操作。请记住,这两次 TCP 连接交互各自可能都是完全符合标准的,TCP 帮不了你。业务上的唯一性必须依靠请求 ID、幂等键、服务端去重记录和事务日志来建立。网络超时只说明客户端在规定期限内没有取得结果,绝不代表服务端没执行,业务最终状态必须通过幂等重试或状态查询来兜底确认。

实验:四份最小故障报告

请尝试复现以下四个经典场景,并为每个场景填写本章开头的统一排障模板:

  1. 向一个本机根本未监听的端口发起连接,记录下 SYN、RST 的抓包特征与系统监听表状态。
  2. 服务端在建立连接后故意暂停读取数据,客户端则持续发送大量数据,抓包记录接收窗口(Window Size)从收缩到恢复的全过程。
  3. 伪造并发送一个“声明长度远大于实际载荷”的畸形数据帧,记录下双方陷入等待的时间点以及你是如何通过总截止时间打破僵局的。
  4. 模拟服务端完成核心操作后主动丢弃响应包,客户端携带同一个请求 ID 再次发起重试,验证服务端的去重防重逻辑是否生效。

在每个案例的报告末尾,再列出一个表面相似的干扰现象,并明确写出能够将两者区分开的具体数据帧、状态机环节或日志事件。

理解检查

  1. 抓包中,紧跟在 SYN 之后的 RST+ACK 与完成握手之后的 RST,分别指向哪些不同的检查点?
  2. 在服务端入口明明已经看到发往客户端的 SYN+ACK,但客户端却依然在重传 SYN,下一步应重点排查哪段网络路径?
  3. 当系统中 CLOSE_WAIT 数量持续反常增长时,哪一端的应用层关闭路径最值得怀疑?
  4. 同为发送端停止发送数据,Zero Window 和网络拥塞造成的停顿可以用哪些不同的字段特征来区分?
  5. 为什么说即使底层 TCP 流完全正常无丢包,应用层的超时重试机制依然可能引发重复的业务操作?

延伸阅读


上一章:第32章 常见抓包假象 · 所属篇:第七篇 · 下一章:第34章 TCP 性能调优的边界 · 教程总览

Licensed under CC BY-NC-SA 4.0.