☰
TCP/UDP连接异常排查:从握手原理到内核调优
2026/10/23 3:21:53 网站建设 项目流程

做网络编程,遇到连接异常是最常见也最让人头疼的事。客户端明明发了请求,服务端就是收不到;服务端端口明明在监听,客户端却一直超时;更诡异的是有些连接建立之后用着用着就“死”了,既不报错也不返回。这些问题很多时候不是业务代码写错了,而是链路、系统参数、TCP/UDP协议行为攒在一起的结果。

这篇内容我按实战排查的思路,把 TCP/UDP 连接异常拆成几个层面来讲:连接建立阶段、连接传输阶段、UDP 的特殊判断,以及调参和抓包方法。适合正在写网络程序、维护在线服务,或者被各种 Connection reset、Timeout 折磨的开发者参考。理解了异常发生的阶段,你才能决定是改代码、调内核参数,还是去找网络设备。

1. 给连接异常做分类之前,先看一条连接的路

1.1 从一次 TCP 握手看连接是怎么建立的

TCP 建立连接靠三次握手:客户端先发 SYN,服务端收到后回 SYN+ACK,客户端再回 ACK,然后双方才认为连接通了。放在代码里就是客户端的 connect() 返回成功,服务端的 accept() 返回一个新的 socket。

这里有个关键认知:客户端调用 connect() 之后,并不是立刻知道服务端状态的。connect 成功只代表“服务端愿意跟你建立连接”,不代表服务端业务可用。我见过很多排查事故的人,看到 connect 成功了就放心了,结果业务请求发出去之后石沉大海,最后才发现是服务端应用层根本没处理。

UDP 则完全不同,它没有握手。调用 sendto() 只是把数据包交给内核,内核尽力把包发出去,不会确认对端是否收到。所以 UDP 连接的“异常”常常是片面的:你以为对方收到了,对方其实没有;或者对方收到了,你因为没有确认机制,心里没底。

1.2 连接异常其实可以分成三个阶段

实战中,一个连接从生到死会经历三个状态区间,每个区间的异常现象和排查手段完全不同:

  • 建立阶段:从客户端 connect 到服务端 accept 完成。问题大多表现为 connect 超时、Connection refused、Connection reset。
  • 传输阶段:连接已建立,应用层开始收发数据。问题表现为连接“假死”、发送超时、接收不到数据、半包粘包。
  • 释放阶段:连接关闭后,主动关闭方进入 TIME_WAIT,大量连接堆积时会导致端口和资源紧张,表现为“新连接建不起来”或者“连接数一直涨”。

这三个阶段在 netstat/ss 里对应不同的 socket 状态。建立阶段能看到 SYN_SENT、SYN_RECV、ESTABLISHED;传输阶段主要看 ESTABLISHED 和 RECV-Q、SEND-Q;释放阶段就是大量 TIME_WAIT 和少部分 CLOSE_WAIT。看到状态,你基本就能猜到问题出在哪一环。

1.3 影响连接异常的因素分布

我习惯用一个简单框架去想问题:连接出问题,无非是四个层面,把它们列成一张表,排查时逐层打勾:

层面常见问题典型表现
客户端端口耗尽、connect超时、本地路由配置connect 卡住、报 Cannot assign requested address
服务端端口未监听、accept队列满、应用阻塞Connection refused、客户端超时、连接建立后立即断开
中间链路防火墙丢包、NAT超时、MTU不通、负载均衡异常半开连接、偶发超时、本地能通对面不通
系统/内核socket backlog 太小、fd 耗尽、内核参数不当connect 超时、accept 变慢、进程崩溃后端口无法复用

这个框架是后面所有排查动作的基础。遇到任何异常,先把现象归到这层表格里,再决定下一步动代码还是动配置。

2. 连接建立阶段:超时、拒绝与“静默丢弃”

2.1 connect 超时的背后:SYN 包可能被丢了

如果客户端 connect 一直卡住,最后报 timeout,一般不是对端拒绝了,而是 SYN 包发过去没有回应。有三种特别常见的情况:

第一种是网络黑洞。中间防火墙或路由器把 SYN 包静默丢弃,既不拒绝也不回应。客户端内核只能反复重传 SYN,直到重试次数耗尽才返回超时。Linux 下默认 tcp_syn_retries=6,指数退避重传,最长能卡一两分钟。

第二种是服务端 accept 队列满了。服务端收到 SYN 之后,如果半连接队列和全连接队列都满了,内核可能会直接丢弃 SYN。你从外面看端口是通的(ping 能通,甚至 telnet 也偶尔能通),但连接就是不稳定。

第三种是 MTU 问题。客户端发出的 SYN 如果比链路 MTU 大,又禁止分片,这个包会在中间设备被丢弃,服务端根本看不到。这就是传说中的“MTU 黑洞”。判断方法是用 tcpdump 在两端同时抓包:客户端已经在重传 SYN,服务端却什么都没收到,就要往链路 MTU 上想。

2.2 Connection refused 才是“明确拒绝”的信号

与超时不同,Connection refused 是收到明确回应了,只是回应是 RST 包。RST 的含义是“我不想跟你建立连接,并且我明确告诉你”。

最常见的原因就一个:服务端端口没有进程监听。你 telnet 一个没启动服务的端口,或者连错了端口,马上就会返回 Connection refused。这是最“健康”的异常,因为它迅速、明确,不需要猜测。

但还有一种场景容易踩坑:服务端 listen backlog 满了,并且内核参数 tcp_abort_on_overflow=1。这种情况下,全连接队列耗尽时内核会主动回 RST,客户端表现就是 Connection refused,看起来像端口挂了,其实端口活着,是队列满了。如果这个参数是默认值 0,内核就会丢弃 ACK,客户端表现变成超时。同一个问题,两个参数,两种现象,排查方向完全不同。

2.3 半连接队列和全连接队列,是最容易被忽视的瓶颈

握手走的是两个队列:SYN 队列(半连接队列)和 accept 队列(全连接队列)。SYN 队列保存已完成半握手、等待 ACK 的连接;accept 队列保存已完成完整握手、等待应用调用 accept() 的连接。

我遇到过这样的情况:服务进程是好的,端口在监听,但就是大量用户连不上。查下去发现 accept 队列被打满了,因为应用层处理请求太慢,accept() 还没来得及从队列里把连接拿出来,新的连接已经在队列里堆着。此时新来的连接要么被超时处理,要么直接拒绝。

listen 的 backlog 参数就是给 accept 队列用的,但它不是你想设多大就多大,最终还要受 net.core.somaxconn 限制。很多框架在文档里说默认 backlog 设置为 511,但系统 somaxconn 默认是 4096,所以大多数情况不会触顶。但如果你用容器或特定发行版把 somaxconn 调低了,排队就会成为瓶颈。

另外,内核的 syncookies 功能可以在 SYN 队列满时绕过去,默认是开启的。它能在瞬间处理大量 SYN 时降低丢包率,但代价是某些高级 TCP 选项(比如时间戳、窗口缩放)可能不能生效,特别是在大规模并发下要注意。

3. 连接建立之后:假死、粘包与零窗口

3.1 连接“假死”:对端不在了,但你不知道

TCP 连接建立后,如果某一端崩溃、断电,或者中间 NAT 设备把映射表清了,另一端是不会立刻知道的。只要没有数据在线上跑,双方都会认为连接还在。这就是半开连接,也是线上“假死”问题的主要来源。

我曾排查过一个服务,客户端连接池里的连接数量很充足,但请求就是卡住。查了半天才发现,池里大部分连接已经“死了”,客户端以为自己还连着,服务端其实早已无法通信。连接池没有及时剔除这些失效连接,新的请求就被分到了坏连接上。

解决思路有两层:

  • 内核方案:开启 TCP keepalive,靠操作系统定期探测对端。默认是 2 小时探测一次,对大部分业务来说太慢,可以调低 tcp_keepalive_time、tcp_keepalive_intvl、tcp_keepalive_probes。
  • 应用层方案:自己发心跳包,客户端定期向服务端发一个轻量请求,连续几次没有响应就判定连接失效并重建。

我的经验是:如果是长连接服务,不能只靠内核 keepalive。应用层心跳能顺便把超时、上下文状态、鉴权刷新一起做掉,比底层机制可控得多。

3.2 粘包和半包,为什么也算连接异常

很多刚接触 TCP 的人会认为 TCP 是消息协议,发一条消息对面就收一条。实际上 TCP 是字节流协议,它只保证字节顺序,不保证消息边界。你 send() 两次 100 字节,对端 recv() 可能一次收到 200 字节,也可能两次各收到 100 字节,甚至第一次只收到 50 字节。

这意味着应用层必须自己定义消息边界。常见方案有四种:固定长度、分隔符、消息头带长度字段、以及更复杂的二进制协议。实战中用的最多的是“消息头带长度字段”:

  • 消息头里用 4 字节存 payload 长度。
  • 接收端先把头读完,解析出长度,再读够完整 payload 才开始处理。
  • 如果没读够,继续留在缓冲区内,等待下次数据到达再拼装。

如果没有这套机制,接收端很容易出现“读了一半就开始业务处理”的问题。轻则请求报错,重则因为错位导致解析失败,表现为时好时坏,极难定位。所以遇到“数据对不上”“消息时灵时不灵”,先检查一下应用层有没有处理半包,而不是怀疑 TCP 是否丢数据。TCP 不会丢数据,它只会让你等待。

3.3 零窗口:接收方不给力,发送方被迫暂停

还有一种比较隐蔽的异常,发生在接收方处理不过来的时候。TCP 有流量控制机制,接收方会在 ACK 里带上自己剩余接收窗口的大小。如果接收方的应用程序不从 socket 读取数据,缓冲区满了,窗口就会降为 0,发送方就不得不暂停发送,从而造成一种“连接还活着但数据推不动”的状态。

在排查时,用 ss 看 ESTABLISHED 状态的连接,如果 SEND-Q 一直很大且不降,或者数据一直堆积在发送缓冲区,就要重点看对端是不是应用层阻塞了。是代码里处理线程卡住了,还是读循环里没有及时 recv(),还是 socket 设置了阻塞模式导致相互等待?这不是网络问题,但它表现出来完全是网络问题的样子。

曾经有个项目,客户端大量上报数据,服务端单线程处理,处理不过来又没设置合适的读循环,结果窗口被打满,客户端发送缓冲区越堆越高。表面上看是“网络传输变慢了”,实际是服务端消费能力不够。

4. UDP 没有连接,但“异常”一样存在

4.1 为什么 UDP 也会“断连”

UDP 是无连接的,按理说不会断连,但实际排障时经常有“UDP 连不上”的说法。真相是,UDP 虽然没有握手,但有几种现象让人误以为它断了:

  • 对端没有 socket 监听时,某些系统会回 ICMP Port Unreachable。如果客户端对这个 UDP socket 做过 connect(),sendto() 就会得到 ECONNREFUSED,看着跟 TCP 被拒一样。
  • ICMP 包在公网和复杂的中间网络上经常被丢弃,所以客户端可能完全收不到错误,神秘地“无响应”。
  • UDP 没有重传机制,个别包丢了业务就断了。对实时性要求高的服务,比如音视频通话,用户感觉就是“卡了”“断了”。

所以,UDP 项目要做“连接健康检测”,必须是应用层自己做。最简单的心跳方案是:客户端定期发一个序号递增的探测包,服务端收到后回复,客户端连续收不到 N 次回复就切换通道或宣告对端不可达。

4.2 丢包、乱序、重复:UDP 的三个老朋友

UDP 的不可靠主要体现在三个方面,排障时需要区分它们:

  • 丢包:包发出去,对端没收到。原因可能是网络拥塞、中间设备队列满了、接收缓冲区满了。
  • 乱序:包到了,但顺序是反的。互联网上不同包可能走不同路径,先发的反而后到。
  • 重复:同一个包被复制了一份,对端收到两份,业务方不小心会重复处理。

判断丢包不能只靠“感觉”。量化方法是看序号连续性:应用层每发一个包带一个自增序号,接收端统计序号出现间隙的数量,除以总包数就是丢包率。还可以在两端用 tcpdump 抓包做对比,看到底是发出的包没到,还是到了没被应用层接走。

乱序的应对方式是可以“等一小会儿”再重排。接收端维护一个小窗口,先存起来,等待缺失的包到齐后再交给上层。但等待时间要克制,否则延迟会变高,实时性就没了。

重复包可以由业务层加去重判断。用“对端IP+端口+序号”做 key,处理过相同序号的直接丢弃,成本很低,收益明显。

4.3 UDP 缓冲区溢出:内核帮你丢包

UDP 的接收缓冲区队列是有限的,如果应用层读得不够快,或者某个 socket 收到太多包,新来的包会被内核直接丢弃。这种现象在 ss/netstat 里看得到:

  • ss -u 看 UDP socket 时,Recv-Q 如果一直满,说明应用程序消费不过来。
  • netstat -su 会显示 receive buffer errors 和 packet receive errors,这两个数值一旦持续增长,基本就是缓冲区溢出了。

Linux 的默认 UDP 接收缓冲区大小一般用 rmem_default 控制,大约 200KB 左右。对视频、游戏同步这类高吞吐业务来说,这个默认值往往不够,届时可以调大 net.core.rmem_max,然后在应用里 setsockopt() 设置 SO_RCVBUF。但要注意,缓冲区调大只是延后丢包,不能解决应用层消费慢的问题,该优化处理的还得处理。

需要说明的是,UDP 的可靠性问题没有银弹。QUIC 这类协议其实就是在 UDP 之上自行实现了可靠传输、重传和连接状态管理,效果很好,但背后是一整套复杂机制。如果你没有精力做整套可靠层设计,就得在业务层面接受 UDP 的“尽力而为”,并把丢包重传做进协议设计里。

5. 实战定位:命令、参数与抓包三件套

5.1 两条命令先看清连接状态

排查任何可疑的连接异常,我第一步不是看代码,而是先用 ss 看当前系统连接状态。以下命令几乎每次都能用到:

# 查看监听端口和状态 ss -lnt # 查看全量连接状态统计 ss -s # 查看具体端口连接分布 ss -ant | grep 8080 # 查看某个连接的收发队列 ss -ant sport = :8080

看输出时重点盯三个字段:State、Recv-Q、Send-Q。State 告诉你连接处在哪个阶段,Recv-Q/Send-Q 在 established 连接里代表内核缓冲区中未读或未确认的数据量。如果 Recv-Q 长期很大,说明接收方消费不过来;Send-Q 长期很大,说明发送被卡住了,可能是对端窗口为零,也可能是网络拥堵。

接着用 lsof 确认端口到底哪个进程在监听:

lsof -i:8080

这一步看起来很基础,但能排除大量“服务没起来”“端口被别的程序占用”的低级问题。

5.2 两个端同时抓包,才能判断问题在哪一段

网络问题最怕“只见一端不见另一端”。我强烈建议,风险可控的前提下,客户端、服务端两边同时抓包对比。抓包工具首选 tcpdump,命令并不复杂:

# 服务端抓取 8080 端口的所有 TCP 包 tcpdump -nn -i eth0 tcp port 8080 -s 0 # 保存成文件,供后续用 Wireshark 慢慢分析 tcpdump -nn -i eth0 tcp port 8080 -w /tmp/handshake.pcap

抓包后看握手过程非常直观:

  • 客户端发出了 SYN,服务端没收到 —— 问题在客户端到服务端的入向链路上。
  • 服务端收到 SYN 并回了 SYN+ACK,但客户端没收到 —— 问题在返回链路,或者是服务端的半连接队列满了,来不及回包。
  • 客户端收到 SYN+ACK 但最后一条 ACK 服务端没收到 —— 问题可能出在服务端的 accept 队列,或者客户端本地防火墙干扰。
  • 两端都发了包,但都有大量重传 —— 链路存在丢包,看中间设备。

还有一点容易被忽略:抓包的时候尽量用 -nn 不要做反向域名解析,否则抓包进程本身可能因为 DNS 解析变慢,影响结果。

5.3 内核参数调优:宁可少调,不可乱调

内核参数在连接异常中起的作用比大部分人想的大得多。我把常用的几个整理成表,调优前先看清楚影响范围:

参数作用典型调整场景
net.core.somaxconn限制 listen backlog 上限accept 队列经常满时调大
net.ipv4.tcp_max_syn_backlog半连接队列大小上限高并发 SYN 打满时调大
net.ipv4.tcp_syn_retriesSYN 重传次数希望快速失败时调小
net.ipv4.tcp_synack_retriesSYN+ACK 重传次数服务端 accept 慢时配合排查
net.ipv4.tcp_keepalive_timekeepalive 空闲探测时间长连接假死较多时调小
net.ipv4.tcp_tw_reuse允许复用 TIME_WAIT 连接客户端大量主动短连接时开启
net.core.rmem_maxsocket 接收缓冲区最大值UDP 大流量场景调大
net.core.wmem_maxsocket 发送缓冲区最大值TCP 大吞吐发送场景调大

我要特别说下 tcp_tw_reuse。它只对主动发起连接的一方有效,适用于客户端大量短连接场景,能复用掉 TIME_WAIT 状态的连接。但它依赖时间戳选项才能安全工作,而且服务端场景基本不受益,需要在 NAT 后面格外小心。tcp_tw_recycle 则由于安全问题,在新内核里已经废弃,不要再用。

另外,调参数之前先记录当前值,一次只调一个,调完观察一段时间。线上环境多参数联动,一次改好几个,出了问题都不知道是哪个改出来的。

5.4 日志和监控是排查的“基础设施”

有一类连接异常,问题出现得非常短暂,等你上机器去查,现场已经没了。这时就需要依赖监控和日志:

  • 记录每个连接的建立时间、关闭时间、对端 IP、端口,以及最后的 RST/FIN 时机。
  • 监控网卡的重传率、丢包率、drop 计数。
  • 对 socket 数量和状态做周期性采集,尤其是 TIME_WAIT、CLOSE_WAIT、ESTABLISHED 数量的变化。

很多连接异常是“温水煮青蛙”式的,一开始只有几个 TIME_WAIT,后面逐渐堆积,直到某一天突然端口耗尽。没有趋势数据,你很难提前发现。

6. 异常速查表与一套标准排查流程

6.1 常见问题速查表

结合前面提到的各种情况,我整理一份能直接用的问题速查表。排障时先对号入座,可以少走很多弯路:

现象最可能的原因优先排查动作
connect 超时SYN 被丢,队列满,网络黑洞两端抓包,看 SYN 是否到达
Connection refused端口未监听,或 tcp_abort_on_overflow=1lsof 确认监听,ss 看队列
Connection reset对端主动发 RST,应用异常关闭看服务端日志,检查应用退出码
连接建立后立即断accept 队列溢出ss -lnt 看 accept 队列堆积
数据发送卡住对端窗口为零,接收方消费阻塞看 Send-Q、Recv-Q 以及对端状态
连接假死不报错NAT 超时、对端崩溃应用层心跳,调短 keepalive
UDP 收不到包缓冲区溢出、丢包、没监听端口netstat -su 看 drop 计数
UDP 偶发卡顿乱序、重传缺失抓包做序号对比,查丢包率

使用这个表要注意:它给的是方向,不是结论。同样是 Connection refused,可能是真没监听,也可能是 accept 队列满加 abort_on_overflow,需要靠第二条证据链锁定。

6.2 一套标准的排查顺序

每次处理连接异常,我基本都按下面的顺序来,出错率很低:

  1. 先确认基础事实:端口是否在监听,进程是否存活,服务端机器能不能 ping 通。
  2. 再看状态统计:ss -s、netstat -su,找出异常的连接状态或丢包计数。
  3. 接着看重点连接:ss -ant 按端口过滤,观察 Recv-Q/Send-Q 和当前状态。
  4. 抓包对比:客户端和服务端同时抓,分析 SYN、ACK、RST、重传的时序。
  5. 查应用层日志:看服务端有没有 accept 之后立即异常关闭的记录,看客户端有没有超时重试的日志。
  6. 最后才动代码或参数:确认根因后,有针对性调参或改业务逻辑。

这套流程的顺序很有讲究。先看基础事实能排除 60% 的“假网络问题”;抓包放后面,是避免一上来就被复杂信息绕晕;动代码放最后,是防止拿猜测替代证据。

6.3 几个值得长期坚持的习惯

最后分享几个我踩过坑之后总结出来的习惯,适合沉淀到团队日常开发里:

  • 启动时把本地 IP、端口、内核相关参数打进启动日志,线上环境变化时可以快速对比。
  • 对外提供服务的端口,都设置一个独立的最小监控,比如连接状态分布、accept 队列长度。
  • 长连接服务,一定要在协议设计阶段就把心跳、超时、重连策略定下来,后面补会很痛苦。
  • 用 TCP 还是 UDP 不只由性能决定,还要看业务是否能容忍丢包和乱序。能重传、能容忍延迟,就用 TCP;要求低延迟、丢包可接受,才考虑 UDP。

连接异常排查本质上是在还原一条数据链路上的所有行为。明确连接在哪一步断了、为什么断了,再动手改,比拍脑袋调参数有效得多。希望这套从分类到命令、从抓包到调优的思路,能让你下次遇到 TCP/UDP 连接异常时少几分手忙脚乱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询