news 2026/9/13 2:25:38

Linux TCP协议核心解析:握手、状态流转与排障实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux TCP协议核心解析:握手、状态流转与排障实战

先说我为什么想写这个题目。上周凌晨我被一个生产问题叫起来,服务端日志里全是 Connection reset,用 ss -s 看一眼:TIME_WAIT 堆到五位数,CLOSE_WAIT 还在往上爬。这种情况对做 Linux 运维、后端开发、甚至嵌入式联网设备的人来说都太熟悉了。最后解决靠的不是重启,而是把 TCP 那套连接建立、状态流转、内核参数、socket 编程链路整体过了一遍,才定位到是一个连接池没关干净导致的异常。

所以这篇文章我想彻底展开讲一次 Linux 下的 TCP,从三次握手抓包开始,到内核参数调整、UDP 选型对比、socket 长连接写法,最后落到几个高频线上问题的排障思路。不管你是 CentOS、Ubuntu,还是现在越来越常见的统信 UOS、麒麟这类国产化系统,底层都是同一套 Linux 内核 TCP/IP 协议栈,之前积累的经验完全通用。文章里所有命令我都会给实际可跑的版本,代码也是能直接编译试的,适合照着敲一遍。

1. 三次握手不是三次问候:连接建立的全过程

1.1 用 tcpdump 看三次握手的每一个字节

理论书上说 TCP 三次握手是 SYN、SYN-ACK、ACK,背起来很轻松,但真要排查问题,你需要建立"看到数据包就能还原连接建立过程"的肌肉记忆。我习惯的验证方法特别简单,开两个终端,一个跑抓包,一个跑连接请求:

# 终端A tcpdump -i eth0 tcp port 9000 -nn -vv # 终端B nc -vz 127.0.0.1 9000

如果没有 nc,用 bash 自带的 /dev/tcp 也行:timeout 2 bash -c "echo > /dev/tcp/127.0.0.1/9000"。抓包结果大概是三行:

10:0.1.1.1.52340 > 10.0.1.2.9000: Flags [S], seq 3452345107 10.0.1.2.9000 > 10.0.1.1.52340: Flags [S.], seq 1928374655, ack 3452345108 10.0.1.1.52340 > 10.0.1.2.9000: Flags [.], ack 1928374656

三个关键点。

第一,SYN 报文里的 seq(序列号)不是从 0 开始的,而是个随机初始序列号 ISN(Initial Sequence Number)。这是为了防止旧连接的报文被误认为是新连接的报文,本质上是安全设计。

第二,第二次握手同时带着S.两个标志,把服务端自己的 ISN 发过去,同时用 ack 把客户端的 seq+1 确认回来。为什么必须是 +1?因为 SYN 本身要消耗一个序列号,哪怕它不携带数据。

第三,第三次握手是个纯 ACK 包,seq 也变成服务端 ISN+1。到这里,双方的序列号就同步了,后续数据报文可以从任意方向安全发送。

你可能会问,为什么不干脆两次握手?用个生活类比:两个人打电话,A 说"你能听到我吗",B 说"能听到,你能听到我吗",A 说"能听到"。第三次是专门给 B 确认的,让 B 知道自己的声音 A 确实能收到。如果只有两次,B 永远不知道 A 能不能收到自己的回话,连接建立就是单向确定的。

1.2 backlog 的真相:半连接队列和全连接队列

握手看起来简单,但内核在处理时走了两道关卡:半连接队列(SYN 队列)和全连接队列(accept 队列)。

当服务端收到 SYN,会把连接放进 SYN 队列,同时回复 SYN-ACK。之后客户端发来 ACK,内核需要一个地方暂存这个已经完成握手的连接,等应用层调accept()取走,这个位置就是 accept 队列。理解这两个队列,你对 backlog 的很多困惑都能解开。

listen(fd, backlog)里的 backlog 参数,控制的是 accept 队列的最大长度,但最终生效值还要和内核参数net.core.somaxconn取较小值。比如你在代码里写了listen(fd, 1024),但 somaxconn 还是默认的 4096(老内核是 128),那么 accept 队列上限就是 1024。

判断队列是否溢出有两个实用命令:

# 查看当前监听 socket 的队列占用,Recv-Q 是已建立的待 accept 连接数,Send-Q 是队列上限 ss -lnt # 查看协议栈统计里是否丢过握手完成的连接 nstat -az | grep -i listen

TcpExtListenOverflowsTcpExtListenDrops这两个计数一旦非零,说明应用层accept()太慢或 backlog 太小。nginx 调优时很多人会写listen 80 backlog=4096;,再配合内核net.core.somaxconn=4096,就是放大了这个队列。

半连接队列的上限更复杂,由tcp_max_syn_backlognet.core.somaxconn以及内存压力共同决定,一般不需要手动调。但如果通过ss -st state syn-recv看到大量 SYN-RECV 状态的连接,说明半连接队列可能被 SYN Flood 攻击填满,或者客户端网络半通,发了 SYN 后收不到 SYN-ACK。

1.3 SYN 重传与握手卡住的排查

握手报文丢了怎么办?有个很朴素的原则:TCP 所有丢包都要重传。客户端发出 SYN 后,如果没等到 SYN-ACK,会按 1s、2s、4s、8s 的间隔重传,默认最多重传 6 次(net.ipv4.tcp_syn_retries=6)。服务端回复 SYN-ACK 后的重传次数则由net.ipv4.tcp_synack_retries控制,默认 5。

实际排障时我见过最典型的场景:客户端telnet 服务端IP 8080一直卡住,抓包发现客户端一直重发 SYN,服务端一个包都不回。这时候方向就清楚了——报文根本没到服务端网络栈,优先查云安全组、iptables/firewalld 规则、中间交换机 ACL,而不是在服务端代码里找问题。

反过来,如果服务端能看到 SYN 且回了 SYN-ACK,但客户端一直不进入 ESTABLISHED,多半是客户端到服务端的回包路径有问题,或者是客户端本地防火墙把入站报文吞了。我在虚拟机里调试时就踩过这个坑:VMware 的虚拟网卡默认开启了"仅主机"模式,宿主机能 ping 通,但 TCP 回包被 Windows 防火墙拦截,导致虚拟机里怎么都连不上宿主机的服务。

2. 四次挥手与连接状态机:TIME_WAIT 和 CLOSE_WAIT

2.1 挥手过程与六个状态的流转

连接关闭比建立更复杂。假设客户端主动关闭,完整链路是这样的:

  1. 客户端发送 FIN,进入 FIN_WAIT_1。
  2. 服务端收到 FIN,回复 ACK,进入 CLOSE_WAIT;客户端收到这个 ACK 后进入 FIN_WAIT_2。
  3. 服务端应用层调用close(),发送 FIN 给客户端,进入 LAST_ACK。
  4. 客户端收到 FIN,回复最后一个 ACK,进入 TIME_WAIT;服务端收到 ACK 后进入 CLOSED。客户端等 2MSL 后才 CLOSED。

我经常在面试里问:为什么服务端收到客户端 FIN 后要先回 ACK,然后才发 FIN?因为 TCP 是双向的,收到 FIN 只表示"对方不再发数据了",本端可能还有数据没发完。所以内核先回 ACK 确认收到关闭请求,应用层等数据发送完毕后再关自己的写入方向。这个机制导致了一个经典问题——服务端可以继续保持 CLOSE_WAIT 很久,如果代码不close(),连接永远关不掉。

ss -tan看状态分布最直观:

ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

正常线上会看到 ESTABLISHED、TIME_WAIT 为主,偶尔有少量 SYN_SENT。如果 CLOSE_WAIT 大量堆积,几乎可以断定是应用代码有 bug。

2.2 TIME_WAIT 为什么是 2MSL,以及 tcp_tw_reuse 的正确用法

TIME_WAIT 是很多初学者的噩梦,其实它是 TCP 最优雅的设计之一。两个作用:

第一个作用是确保最后的 ACK 能到达对方。最后这个 ACK 如果丢了,服务端在 LAST_ACK 状态会重发 FIN,所以客户端必须保持 TIME_WAIT 足够长时间,以便收到这个重发的 FIN 后再次回复 ACK。

第二个作用是让旧连接的所有报文在网络中消失。报文最长存活时间是 MSL(Maximum Segment Lifetime),两端加起来 2MSL 后,旧连接的重复报文就全部消亡,新连接不会被旧报文干扰。

Linux 的 2MSL 硬编码是 60 秒,不是有些书上写的 4 分钟,这点很多人搞混。

处理大量 TIME_WAIT,首要思路是应用层复用连接,而不是每次请求都新建。连接池、HTTP Keep-Alive、长连接,本质上都是减少 TIME_WAIT 产生的效率手段。

再讲内核参数。net.ipv4.tcp_tw_reuse我建议在客户端场景开启,它允许内核把处于 TIME_WAIT 状态的连接用于新的出站连接,前提是开启tcp_timestamps。但它对服务端入站连接无效,别指望它解决服务端接入层的 TIME_WAIT 堆积。

另一个老参数tcp_tw_recycle是一个大坑,NAT 环境下开了它会导致大量连接被错误丢弃。好在内核 4.12 之后已经把它移除了,如果你还能在旧文档里看到推荐开启的教程,那篇文章基本可以判断是上古货。

2.3 CLOSE_WAIT 泄漏:连接耗尽的第一元凶

CLOSE_WAIT 堆积几乎都是程序问题。流程是这样的:服务端收到 FIN,内核自动回 ACK 并把连接置为 CLOSE_WAIT,此时应用层通过read()会返回 0,表示对端关闭了。正确的处理是应用层也调用close()关闭本端 socket,连接才会继续走到 LAST_ACK。

如果程序一直不close(),连接就死在这里。问题代码常见两类:

一类是读数据时只处理了read() > 0的分支,没处理read() == 0的 EOF 分支。另一类是使用了某个 HTTP 客户端库或数据库驱动,连接空闲了但底层的 TCP 连接没有正确释放,比如 Java 的 HttpClient 在低版本时就有这个毛病。

排查思路三步走:

# 1. 统计各状态连接数 netstat -tan | awk '/^tcp/ {print $NF}' | sort | uniq -c | sort -rn # 2. 找出 CLOSE_WAIT 连接对应的进程和本地端口 netstat -tnp | grep CLOSE_WAIT # 3. 用 lsof 看进程里哪些 socket 没关闭 lsof -p <PID> | grep TCP

有一次我在一个 Java 服务里看到 4000 多个 CLOSE_WAIT,进程 CPU 都在处理超时重试。lsof后发现是一批 OAuth 客户端每次调用令牌接口都通过new Socket()建连,异常分支里没close()。改完连接池复用后,CLOSE_WAIT 立刻降到了个位数。

3. Linux 内核 TCP 参数调优:从默认值到生产配置

3.1 调优前先明确场景

网上很多"TCP 优化清单"直接让你把一堆 sysctl 参数抄上去,这是不负责任的。调优的前提是你清楚自己的流量模型。高并发短连接、长连接大流量、跨运营商长肥网络,优化的方向完全不一样。

  • 高并发短连接:重点看端口范围、TIME_WAIT 复用、文件描述符上限。
  • 长连接大流量:重点看收发缓冲区、拥塞控制算法、窗口缩放。
  • 公网高丢包链路:重点看拥塞控制算法(比如 BBR)和重传策略。
  • 内网低延迟低丢包:默认参数通常已经不错,不必乱动。

所以调优第一步是ss -snetstat -s看现状,第二步才是改参数。下面这些参数是我在多个项目里实际验证过、改完能稳定提升效果的,按组整理。

3.2 缓冲区:吞吐量卡在哪儿,先算 BDP

TCP 的收发缓冲区直接决定单连接的吞吐上限。你可能会遇到带宽明明 100Mbps,单 TCP 连接却只能跑 5Mbps 的情况,大概率是缓冲区不够大,或者接收窗口受限。

这里有个概念叫 BDP(Bandwidth-Delay Product,带宽时延积)。计算公式是:

BDP = 带宽 × RTT

比如 100Mbps 链路,RTT 20ms:

100 * 10^6 * 0.02 / 8 = 250KB

也就是说单连接的发送/接收缓冲区至少要 250KB,否则无法填满链路。Linux 默认tcp_rmem的 max 在现代内核里通常是 6MB 左右,一般够用,但老系统可能只有 4MB,高带宽大 RTT 场景就要手动调大。

查看当前值:

sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem

我常用的一组生产配置,适合内网大流量场景:

net.ipv4.tcp_rmem = 4096 131072 16777216 net.ipv4.tcp_wmem = 4096 131072 16777216 net.core.rmem_max = 16777216 net.core.wmem_max = 16777216

三个数字分别是最小值、默认值、最大值。中间那个值是 socket 建立时默认分配的,会随实际吞吐动态调整,不需要手动设置。用iperf3压测对比调前调后的差异,是最直接的验证方式。

3.3 拥塞控制算法:CUBIC 还是 BBR

拥塞控制决定了 TCP 在丢包时如何降低发送速率、如何探测带宽。Linux 默认是 CUBIC,适合普通高带宽链路。如果链路本身丢包率高,比如跨运营商公网、4G/5G 网络,CUBIC 会把丢包误判为拥塞,吞吐大幅下降。

BBR 是 Google 提出的一种基于瓶颈带宽和往返时延的算法,在非拥塞丢包场景下提升非常明显。我实测过一个跨省公网传输任务,同样的文件,CUBIC 全程 2-3Mbps,切到 BBR 后稳定在 12Mbps 以上。

查看和切换:

sysctl net.ipv4.tcp_congestion_control sysctl net.ipv4.tcp_available_congestion_control # 切换到 BBR,需要内核支持(4.9+) sysctl -w net.ipv4.tcp_congestion_control=bbr

修改/etc/sysctl.conf需要加一行:

net.ipv4.tcp_congestion_control=bbr

需要注意,BBR 在内网低延迟低丢包环境收益很小,而且某些网络设备对 BBR 的探测报文处理可能不友好。云厂商的负载均衡后端如果开了 BBR 遇到问题,可以先关掉对比一下再决定。

3.4 常用内核参数与 sysctl 配置清单

下面是我在实际项目中经常调整的内核参数,按用途分组写成一个可以直接抄的/etc/sysctl.conf片段。注意改完用sysctl -p生效,最好在压测环境先跑一遍。

# 端口范围,高并发短连接客户端建议调大 net.ipv4.ip_local_port_range = 1024 65535 # 文件描述符上限,高并发连接必须调 fs.file-max = 2097152 # 全连接队列上限 net.core.somaxconn = 4096 # SYN 队列上限 net.ipv4.tcp_max_syn_backlog = 16384 # FIN 等待超时,调小可加速 TIME_WAIT 回收 net.ipv4.tcp_fin_timeout = 30 # TIME_WAIT 复用(仅客户端方向有效) net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_timestamps = 1 # 长连接空闲多久发探测包,配合应用层心跳使用 net.ipv4.tcp_keepalive_time = 600 net.ipv4.tcp_keepalive_intvl = 30 net.ipv4.tcp_keepalive_probes = 3 # 自动调节缓冲区上下限 net.ipv4.tcp_rmem = 4096 131072 16777216 net.ipv4.tcp_wmem = 4096 131072 16777216 # 本机端口允许更多连接,减少 NAT 场景下的瓶颈 net.netfilter.nf_conntrack_max = 1048576

有一条我踩过的坑必须提醒:net.ipv4.ip_local_port_range默认是 32768-60999,也就是大约 2.8 万个出站端口。如果你的服务作为客户端频繁发起短连接,端口耗尽会报Cannot assign requested address,把起始端口调到 1024 能缓解,但根本解法还是连接池复用。

3.5 CentOS 防火墙放行 TCP 端口的正确姿势

热词里有个高频问题"centos 防火墙开放 tcp 端口配置文件",实测很多人连防火墙开着都不知道,直接怀疑程序没起动。CentOS 7+ 默认用 firewalld,配置文件在/etc/firewalld/zones/public.xml,但不推荐直接改文件。

标准操作用命令:

# 放行 8080 端口,永久生效 firewall-cmd --permanent --add-port=8080/tcp firewall-cmd --reload # 查看已放行端口 firewall-cmd --list-ports # 放行某个服务 firewall-cmd --permanent --add-service=http firewall-cmd --reload

老系统还在用 iptables:

iptables -I INPUT -p tcp --dport 8080 -j ACCEPT service iptables save

排查端口不通时按这个顺序:先ss -lntp | grep 端口确认进程在监听,再firewall-cmd --list-ports确认防火墙放行,然后检查云平台安全组。云服务器上经常遇到"防火墙关了但外网还是不通",十有八九是控制台里的安全组规则没放行。

4. TCP 和 UDP 怎么选:从协议对比到真实场景

4.1 一张表看懂本质差异

TCP 和 UDP 的选择困扰过很多人。先说结论:如果你的应用对数据完整性有强要求,选 TCP;对实时性要求高、能容忍少量丢包、或者需要组播广播,选 UDP;其他情况按场景具体分析。两者的核心差异我用表格列出来:

维度TCPUDP
连接状态面向连接,需要三次握手无连接,直接发数据
可靠性可靠传输,确认、重传、排序尽力而为,不保证到达和顺序
传输单元字节流,无边界数据报,保留消息边界
流量控制有,滑动窗口机制
拥塞控制有,CUBIC/BBR 等
头部开销20 字节起8 字节
广播组播不支持支持
典型场景HTTP、数据库、文件传输音视频、DNS、游戏同步

这里说一个容易被忽略的点:TCP 是字节流,意味着应用层发两次send(),对端一次read()可能拿到合并后的数据,也可能只拿到一部分。UDP 是数据报,一个sendto()对应一个recvfrom(),边界天然保留。

4.2 场景一:Modbus RTU 和 Modbus TCP,到底转的是什么

工控场景里 modbus 协议非常典型。Modbus RTU 跑在串口上,报文帧带 CRC 校验;Modbus TCP 跑在以太网上,默认端口 502,报文在 RTU 的基础上做了重组:去掉 CRC,加上了 MBAP 头(事务标识 2 字节、协议标识 2 字节、长度 2 字节、单元标识 1 字节)。

所以做"RTU 转 TCP"时,不能简单地把串口收到的字节流硬塞进 TCP 报文,需要先解析 RTU 帧、剥掉 CRC、加上 MBAP 头,再发给 TCP 客户端。反之 TCP 转 RTU 则要剥掉 MBAP、重新计算 CRC,最后通过串口下发。

很多老工程师第一次做协议网关时就栽在这里:直接在 TCP 报文的 payload 里塞原始 RTU 帧,结果上位机软件怎么都解析不出来。原因就是协议格式根本不是直接兼容的。

4.3 场景二:UDP 转 TCP 网关到底在转什么

工业现场经常碰到设备只支持 UDP 上报,但平台侧只开放 TCP 接入的情况,比如用socat或者自研网关做转换。核心思路其实不复杂:网关一边监听 UDP 端口,收到数据报后把载荷重新封装成 TCP 流发送出去;反向则相反。

但这里面有个隐蔽的坑:UDP 是消息边界分明的,一个包就是一条完整消息;TCP 是流,没有边界。转发程序如果直接send(tcpSock, udpPayload, len, 0),对端收到的可能和多个 UDP 包粘在一起,也可能被拆成半包。做这类网关时,应用层必须自己定义消息边界,常见做法是每个 UDP 包转发前加 4 字节长度前缀,或者用特定分隔符隔开。光做载荷搬运不设计协议字段,在线路上跑五分钟就会出现解析错位。

还有一层是连接管理。TCP 长连接如果断了,网关要能自动重连并缓存期间的数据;UDP 端则要注意多来源数据怎么区分会话。这些细节决定了网关是能用还是能稳定用。

5. Socket 编程背后的 TCP 细节:从 accept 到心跳保活

5.1 一个最小可编译的 TCP 回显服务

不管是 Java 的 ServerSocket、Python 的 socket 模块还是 C 的 socket API,底层内核做的事情是同一套。我建议每个人都亲手用 C 写一遍最小实现,能编译能联通,比看一百篇框架文章都管用。

服务端示例:

#include <stdio.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> int main() { int lfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(9000); addr.sin_addr.s_addr = htonl(INADDR_ANY); bind(lfd, (struct sockaddr *)&addr, sizeof(addr)); listen(lfd, 128); printf("listening on 9000\n"); while (1) { struct sockaddr_in cli; socklen_t len = sizeof(cli); int cfd = accept(lfd, (struct sockaddr *)&cli, &len); char buf[1024]; ssize_t n = read(cfd, buf, sizeof(buf) - 1); if (n > 0) { buf[n] = 0; printf("recv: %s\n", buf); write(cfd, buf, n); } close(cfd); } return 0; }

客户端示例:

#include <stdio.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> int main() { int fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(9000); inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) { write(fd, "hello tcp", 9); char buf[1024] = {0}; read(fd, buf, sizeof(buf) - 1); printf("recv: %s\n", buf); } close(fd); return 0; }

编译运行:

gcc server.c -o server && ./server gcc client.c -o client && ./client

这个 demo 背后有两个值得记住的点:accept 返回的 cfd 是新的文件描述符,和 lfd 监听 socket 不是同一个;read 返回值 0 表示对端关闭了连接,返回值小于 0 才是出错。很多连接泄漏 bug 都出在没处理返回 0 的分支上。

5.2 粘包问题:谁的责任,怎么解决

粘包拆包是 TCP 编程第一个绕不过去的坎。前面说过 TCP 是字节流,消息边界需要应用层自己定义。我见过最朴素也最容易出问题的做法是用固定大小缓冲,比如每次读 256 字节当作一条消息,但真实数据长度一旦不是 256,就必然错位。

更稳的方案是加长度前缀。发送端先塞 4 字节的大端整数表示 payload 长度,再发 payload;接收端先攒够 4 字节,解析出长度 n,再继续读 n 字节,读到完整消息才去解析。这个过程在 C 里要处理 read 可能一次读不满的情况,代码简单写一下逻辑:

uint32_t len; read_exact(fd, &len, 4); len = ntohl(len); read_exact(fd, buf, len);

read_exact要自己用循环实现,因为 read 可能只读到部分数据。有些协议喜欢用分隔符,比如 Modbus TCP 那种按锁步骤字节判断的也有,但长度前缀在我看来是工程上最通用、最不容易出错的方案。真实项目里设计协议字段时,一开始就加一个统一的消息头,后面扩展的麻烦能少很多。

5.3 长连接如何保活:应用层心跳与 TCP Keepalive

搜索词里有个"怎么使用 socket 进行 TCP 长连接请求",这正是我要展开讲的点。长连接建立的成本主要是三次握手,所以大量请求场景下复用连接能显著降低延迟。但长连接最大的敌人是中间设备悄悄把连接断了,最常见的就是 NAT 超时清除映射表。

客户端和服务端之间如果长时间没有数据交互,NAT 设备会在超时后删除这个映射,之后任一方再发数据,收到的就是 ICMP 不可达或者直接无响应。连接在应用层看起来还活着,实际上已经死了。

TCP 自带 Keepalive 探测机制,默认tcp_keepalive_time=7200,也就是空闲 2 小时才探测一次,太慢了,满足不了线上需求。所以实际项目通常采用应用层心跳:约定每隔 30 秒或 60 秒发一次心跳消息,对端必须在规定时间内回复,连续几次没回复就判定连接断开,主动重启连接。

心跳设计有两个级别。连接级心跳只探测链路通断,发 PING 回 PONG 就行。应用级心跳要带业务序号,确认对端应用进程和线程池都正常,适合核心链路。我一般会同时做:底层维护连接级心跳和超时重连,上层业务再做必要的应用级探测。这样连接断开可以在秒级感知,不会等调用业务时报错才发现。

嵌入式场景更明显,ESP32 这类芯片跑 TCP 客户端时,Wi-Fi 断开后内核里的 TCP 状态不会自动恢复,必须自己监听网络事件,断开就主动connect()重新建立。所以长连接不只要会建,还得会重连,最好做指数退避,别疯狂重试把服务器打挂。

6. 线上排障实录:五个高频 TCP 问题处理

6.1 dial tcp connectex:TCP 连接建立失败的通用排查

搜索词里那个报错是 Go 程序输出,connectex是 Windows 平台 socket 系统调用的返回信息,本质上是 TCP 连接根本没有建立起来。我把它归纳为三类原因:端口没人监听、中间链路丢弃、对端拒绝连接。

排查流程我给一张可复用的路径:

# 1. 本机到目标机是否通(先确认 IP 路由) ip route get <目标IP> # 2. 对端端口是否在监听(在对端执行) ss -lntp | grep 443 # 3. 全链路抓包看卡在哪一步(本机执行) tcpdump -i any host <目标IP> and tcp port 443 -nn

如果本机和目标机之间有防火墙,或者目标机在云上,还要检查安全组规则。有些环境对出站方向有过滤,只允许特定端口出去,也会有同样表现。这类报错十有八九不是代码问题,而是网络路径上的策略问题。临时验证对端端口是否能连,可以用python3 -m http.server 8080在目标机上快速起一个 TCP 监听服务,再用客户端连一下。

6.2 failed to listen TCP on 1080:端口被占用的定位与处理

服务程序启动时报 listen 失败,最常见原因是端口被别的进程占了。优先用ss -lntp | grep 1080找到占用进程:

ss -lntp | grep 1080

输出里能看到 PID 和进程名,比如users:(("foo",pid=1234,fd=3))。确认确实是旧进程,可以kill 1234kill -9 1234。注意有一种隐蔽情况:配置文件里同一个监听项写了两遍,程序自己启动两次抢同一个端口,也会报同样的错误。这种在 Docker 里重名容器比较常见。

还有一种情况是只绑定冲突。比如一个程序监听0.0.0.0:1080,另一个程序想监听127.0.0.1:1080,Linux 会拒绝后者的 bind。反过来先监听 127.0.0.1 再监听 0.0.0.0 是可以的,因为前者更具体。调试代理类工具时常遇到,记一下ss -lntp里的Local Address列别只盯着端口看。

6.3 Tomcat 里 RMI TCP Connection 线程从哪启动的

Java 开发会看到 Tomcat 进程里一堆名字叫RMI TCP Connection的线程,即使你改了server.xml的 connector 配置也不减少。原因很简单:这不是 Tomcat 处理 HTTP 的线程,而是 Java RMI 机制自己建立的 TCP 连接处理线程。

RMI 默认走 JRMP 协议,底层就是 TCP。每来一个 RMI 连接,JVM 都会派一个线程处理,线程名就叫 RMI TCP Connection。它的 AcceptThread 负责 accept 监听端口,然后创建 IoHandler 线程处理连接上的请求。

排查思路是对照连接数和线程数的关系:先ss -tnp | grep java看当前 RMI 连接数,再jstack <pid> | grep "RMI TCP"看线程数量。如果连接数持续增长、线程数同步增长,就是调用方不断新建 RMI 连接且不释放。解决办法通常是把 RMI 客户端改成连接复用,或者在架构上避免频繁调用。有些场景直接换用 REST/HTTP 协议能少走很多弯路。

6.4 工控场景 Modbus TCP 连接不稳定

KingsCada、PLC 串口模块转 Modbus TCP 这类场景,我处理过几次,共通问题都在连接管理上。很多 PLC 对 Modbus TCP 并发连接数限制很严,比如只能同时接受 4 到 8 个连接。上位机如果每次轮询都新建 socket,请求完就关闭,再下次又新建,连接表很快被 TIME_WAIT 占满,PLC 就会拒绝新连接,表现就是"偶尔通,频繁掉线"。

正确的做法是上位机和 PLC 之间维护一条长连接,所有 Modbus 请求都在同一条连接上串行或按事务 ID 并发发送。同时把 socket 超时时间设得比 PLC 程序扫描周期长一些,避免误判超时后重复重连加剧问题。如果现场用网线连接,还要检查交换机的端口协商是否稳定,网线松动是工控环境第一大隐形杀手。对欧姆龙 NX-CIF105 这类串口模块要转 Modbus TCP,同样建议加一个稳定的协议网关统一管理连接和轮询,而不是每个上层软件各连各的。

6.5 面试和笔试里常考的六个 TCP 问题

搜索词里有"linux 面试题",这六个问题和我前面讲的内容正好对应,列成速查表:

问题一句话答案
为什么三次握手不是两次必须让双方都确认对方的收发能力,并且同步初始序列号
为什么挥手要四次关闭是全双工的,双方要各自关闭一次发送方向
TIME_WAIT 为什么存在保证最后 ACK 可达,同时让旧连接报文在网络中消亡
CLOSE_WAIT 大量堆积怎么排查查代码里 read 返回 0 后有没有 close
TCP 和 UDP 怎么选要可靠有序选 TCP,要低延迟和消息边界选 UDP
粘包怎么解决应用层定义消息边界,推荐长度前缀

这些问题的答案在书上都写得比较抽象,但如果你按这篇文章的实际操作做过一遍抓包和排障,再回答起来就是"我真的见过"而不是"我背过书"。

最后再分享一点实测体会

TCP 是那种"看起来简单、用起来水深"的协议。我早期排查问题时看到 TIME_WAIT 就慌,各种参数乱调,后来才明白它在多数场景下是正常现象,真正危险的往往是 CLOSE_WAIT 和半打开连接。踩过几次坑之后,我现在接手任何一个网络项目,都会先把ss -snetstat -sss -lnt三个命令的结果存个档,作为基线。后续出问题,对比基线的变化比凭空猜测高效得多。

这篇文章里我给的所有配置和代码,都是我实测过可以落地的东西,但参数照抄之前一定先用压测验证你的场景。网络排障的本质不是背参数,而是顺着数据包的路径一步步看它在哪里停了。希望这篇长文能帮你少走我走错过的路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 2:25:17

Windows平台Oracle 11g安装完全指南:从环境检查到避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 2:25:00

superfile 怎么设置默认文件编辑器与目录编辑器?

superfile 怎么设置默认文件编辑器与目录编辑器&#xff1f; 【免费下载链接】superfile Pretty fancy and modern terminal file manager 项目地址: https://gitcode.com/GitHub_Trending/su/superfile 在 superfile 中&#xff0c;按 e 会用文件编辑器打开当前选中的文…

作者头像 李华
网站建设 2026/9/13 2:24:36

信号完整性SI实战指南:阻抗匹配、等长误区与参考平面设计

1. 为什么“信号完整性”不是PCB设计师的选修课&#xff0c;而是生死线刚入行那会儿&#xff0c;我跟着师傅画一块四层板&#xff0c;主控用的是当时很火的ARM Cortex-M4芯片&#xff0c;跑80MHz系统时钟&#xff0c;带SPI Flash和USB接口。原理图检查三遍&#xff0c;Layout也…

作者头像 李华