博主在排查连接超时问题的时候,经常能看到TCP三次握手、四次挥手相关的状态和报错。而且这个知识点不只是面试用,线上Linux服务器的连接状态、端口耗尽、CLOSE_WAIT堆积、Docker端口映射冲突,追根溯源都回到这张连接状态图上。这篇就基于实际踩坑经历,把三次握手和四次挥手彻底讲透,从报文细节到内核状态,再到常见的疑难报错,一次说清。
1. 三次握手:连接建立的底层博弈
1.1 握手过程的完整时序与数字细节
三次握手的本质是通信双方对“初始序列号”的一次互相同步,同时确认双方的收发能力都正常。标准的交互过程是这样的:
- 客户端发送SYN包,初始序列号
seq=x,进入SYN_SENT状态。 - 服务端收到SYN后,回复SYN+ACK包,其中
seq=y,ack=x+1,进入SYN_RCVD状态。 - 客户端收到SYN+ACK后,回复ACK包,
seq=x+1,ack=y+1,进入ESTABLISHED状态。
这里有一个特别容易忽略的细节:为什么第二次要带上ack=x+1?因为这个x+1表示“我已经收到了你发来的序列号为x的报文,现在期待下一个字节是x+1”。TCP是全双工通信,每一条方向上的数据流都有独立的序列号,三次握手本质上就是让双方把两条方向上的序列号都对齐。
用tcpdump抓包时,最容易看到的特征序列是这样的:
tcpdump -i eth0 host 192.168.1.10 and port 8080 -nn -S14:23:01.101001 IP 192.168.1.10.50000 > 192.168.1.20.8080: Flags [S], seq 1000 14:23:01.101020 IP 192.168.1.20.8080 > 192.168.1.10.50000: Flags [S.], seq 2000, ack 1001 14:23:01.101030 IP 192.168.1.10.50000 > 192.168.1.20.8080: Flags [.], ack 2001注意第二次Flags [S.]表示SYN+ACK,第三次Flags [.]表示纯ACK。第三次握手之后,客户端这一侧的序列号才真正变成seq=1001,服务端是seq=2001,两条方向上都建立了可靠的数据传输基准。
1.2 为什么不是两次握手或四次握手
很多初学TCP的人都会问:为什么三次,不是两次?
先说为什么两次不行。如果只有两次握手,服务端在收到SYN并回复SYN+ACK后就会进入ESTABLISHED状态。此时如果一个“迟到”的旧SYN包被服务端当作新连接请求处理,服务端就会白白建立一个半悬空的连接,等待一个根本不会来的客户端数据,造成资源浪费。三次握手中的第三次ACK,是客户端主动告诉服务端“我确实存活且准备收发数据”,这能有效避免失效请求造成的资源残留。
再说为什么不需要四次。因为第二次报文将SYN和ACK合并在一起发送,相当于“回复的同时也发起自己的同步请求”。如果拆成两个包,握手会变成四步,但没有任何额外收益。实质上经过三次交互,两端都已确认对方收发正常,再多的来回只是浪费RTT。
1.3 内核中的握手队列与backlog参数
三次握手在Linux内核里不是一次“瞬时完成”的操作,而是由两个队列协同完成的。理解了这两个队列,线上遇到“连接卡在SYN_RCVD”或“端口能ping通但业务连不上”时,就有非常明确的排查方向。
客户端SYN到达服务端后,内核会把它放进半连接队列(SYN Queue),此时服务端状态是SYN_RCVD。当三次握手完成、服务端回复了第三次ACK之后,连接会从半连接队列挪到全连接队列(Accept Queue),状态变为ESTABLISHED,等待应用层调用accept()取出。
这两个队列的相关参数如下:
| 参数 | 作用 | 注意事项 |
|---|---|---|
net.ipv4.tcp_max_syn_backlog | 限定半连接队列的最大长度 | 默认1024,高并发下可能不够 |
listen(fd, backlog) | 应用层传入的backlog | 同时受net.core.somaxconn限制 |
net.core.somaxconn | 全连接队列上限 | 默认128,Nginx等很多服务启动时会显式调大 |
net.ipv4.tcp_syncookies | 半连接队列溢出时启用SYN Cookie | 默认1,可缓解SYN Flood |
我用一个真实例子来说明排查思路。一次线上服务告警,客户端上报大量connect timeout,但服务端的进程还活着,sar -n DEV也看不到网络丢包。此时用ss -ant一看:
ss -lntState Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 500 128 0.0.0.0:8080 0.0.0.0:*Recv-Q达到500,说明全连接队列已经堆积了500个待accept()的连接,而Send-Q列显示的128是backlog上限。这说明应用进程要么没有及时调用accept(),要么线程池已经打满,根本没机会处理新到的连接。之后再去看JVM/线程数,果然是业务线程全部阻塞在外部依赖上,导致新连接无法被处理。
注意:
ss -lnt里的Send-Q在LISTEN状态下表示全连接队列的最大长度,Recv-Q表示当前排队的连接数。这是判断服务端是否“有连接堆积”最直接的方法。
2. 四次挥手:断开连接的完整生命周期
2.1 挥手过程的拆分与半关闭状态
TCP断开连接需要四次挥手,原因是TCP连接是双向的,每一方的数据通道都必须独立关闭。假设客户端主动发起关闭,完整流程如下:
- 客户端发送FIN包,
seq=m,进入FIN_WAIT_1状态。 - 服务端收到FIN后,回复ACK包,
ack=m+1,进入CLOSE_WAIT状态。此时服务端仍然可以继续向客户端发送数据,客户端进入FIN_WAIT_2状态。 - 当服务端确认自己的数据也发送完毕后,发送FIN包,
seq=n,进入LAST_ACK状态。 - 客户端收到FIN后,回复ACK包,
ack=n+1,进入TIME_WAIT状态。
第二步和第三步之间,服务端可以继续发送剩余数据。这就是TCP的“半关闭”特性:一端关闭了发送方向,但接收方向仍然工作。
在实际项目中,我见过大量对“半关闭”理解不到位的Bug。比如一个C#编写的客户端程序,下载完文件后立即调用Socket.Close(),但服务端还在往客户端推送数据,此时客户端直接发送RST包终止连接,导致服务端日志里频繁出现“远程主机强迫关闭了一个现有的连接”。正确的做法是调用Socket.Shutdown(SocketShutdown.Send)先关闭发送方向,等待服务端关闭后再彻底释放资源。
2.2 TIME_WAIT和CLOSE_WAIT深度剖析
四次挥手结束后,主动关闭方会进入TIME_WAIT状态,等待2个MSL(Maximum Segment Lifetime,报文最大生存时间)后才会完全关闭。MSL在Linux中的默认值是30秒,所以TIME_WAIT通常持续60秒。
TIME_WAIT的两个核心作用必须记清楚:
- 保证最后一个ACK能可靠抵达。如果最后一个ACK丢失,服务端会重发FIN,主动关闭方需要能够再次回复ACK。
- 让旧连接中的“迟到报文”在网络中自然消失。否则新的连接复用同一个四元组时,可能收到属于旧连接的残留数据包。
但TIME_WAIT过多也是个麻烦事。高并发短连接场景下,主动关闭方会出现大量TIME_WAIT状态的连接,进而导致端口无法及时释放。早期的解决办法是调整内核参数:
sysctl -w net.ipv4.tcp_tw_reuse=1 sysctl -w net.ipv4.tcp_tw_recycle=1注意tcp_tw_recycle在新版内核中已经被移除,而且它存在一个严重的NAT环境问题:如果多个设备通过同一个NAT网关访问服务端,基于时间戳的tcp_tw_recycle会误判某些连接的时间戳过期,导致连接直接被丢弃。实际实施时,不建议开启tcp_tw_recycle,tcp_tw_reuse也只在客户端场景生效,服务端场景基本不起作用。更推荐的做法是调整连接池策略,减少短连接的创建频率。
相比TIME_WAIT,CLOSE_WAIT堆积是更棘手的线上问题。CLOSE_WAIT状态表示服务端已经收到客户端的FIN,但应用进程没有调用close()关闭自己的socket。如果服务端程序中有一个地方忘了释放socket资源,就会出现大量CLOSE_WAIT连接。
排查办法非常直接:
ss -ant | awk '{print $1}' | sort | uniq -c | sort -nr看到CLOSE_WAIT数量持续上涨,基本可以断定应用代码里有socket泄漏。再用lsof定位是哪个进程的哪些文件描述符:
lsof -p <pid> | grep TCP | grep CLOSE_WAIT常见的泄漏原因包括:读取完数据后忘记关闭读取流、使用HTTP客户端时响应体没有消费完全、线程异常退出前没有finally关闭连接等。
2.3 线上“连接断开后自动重连”的实现思路
有次在C#项目里做TCP客户端,业务方要求“服务端重启后客户端能自动恢复连接”。最开始只写了重连逻辑,没有细化关闭流程,结果客户端在重连时非常容易产生大量异常连接。
后来我按这个思路重构:
- 检测连接是否真正可用,不要只看
Socket.Connected属性。这个属性只在最近一次收发操作时更新,实际网络中连接可能早已断开。 - 发送一个应用层心跳包,比如JSON格式的
{"type":"ping"},服务端必须回复pong。如果连续3个心跳都无响应,才判定连接失效。 - 断开后进入退避重连状态,初始间隔1秒,每次失败翻倍,最大不超过30秒。避免服务端还在启动过程中时客户端疯狂重连造成雪崩。
- 重连前必须显式释放旧Socket,否则文件描述符会持续增长,最终报
Too many open files。
这个模式在通信服务里非常通用,底层依然是TCP四次握手的状态管理。核心是理解:TCP层的连接断开是客观存在的,应用层必须用主动探测感知并接管重连过程。
3. 用抓包把理论变成实锤
3.1 抓取三次握手和四次挥手的完整报文
理论讲再多,都不如自己抓一次包印象深刻。我习惯用tcpdump在服务端抓包,然后从客户端发起一个最简单的TCP连接,比如用curl访问一个本地HTTP端口:
tcpdump -i lo -nn port 8080 -w handshake.pcap另一个终端执行:
curl http://127.0.0.1:8080/然后查看抓包结果:
tcpdump -nn -r handshake.pcap正常会看到四条关键报文:三次握手的SYN、SYN+ACK、ACK,然后是应用数据,再之后是三次握手的完整序列,最后是四次挥手的FIN、ACK、FIN、ACK。如果使用了HTTP keep-alive,会看到四次挥手被延迟到连接空闲超时后才出现。
在tcpdump的输出中,标志位缩写含义如下:
| 缩写 | 含义 |
|---|---|
S | SYN,同步序列号 |
S. | SYN+ACK,回复同步请求并确认 |
. | ACK,普通确认 |
F | FIN,请求断开连接 |
R | RST,重置连接 |
我强烈建议把四次挥手和三次握手的抓包放到同一个文件里,用Wireshark打开后点击“统计→流量图→TCP流”,选择“TCP时间序列图”,可以非常直观地看到连接什么时候建立、什么时候断开、谁先发起的FIN。这个是排查“连接被谁关闭”时的标准操作。
3.2 从抓包判断连接异常
有次遇到一个工业现场问题,西门子PLC只有每次重启后的一分钟内能连上上位机,之后连接就断开且无法恢复。抓包后发现了一个规律:PLC在重启后主动发起TCP连接,三次握手成功,交换了几次数据后,PLC发送了FIN包上位机也回复了ACK,连接正常关闭。但之后上位机再主动连接PLC时,客户端SYN包连续重传却没有收到任何回复。
这说明PLC侧的TCP服务端在正常通信后主动关闭了监听端口,或者防火墙规则在一段时间后生效。最终排查发现是PLC程序里的TCP服务指令被提前终止,通信块只在初始化后的第一个循环周期里执行。这个案例再次说明:抓包永远比猜测高效,先用tcpdump确认问题出在握手阶段还是数据传输阶段,再往下追业务逻辑。
3.3 用ss命令检查连接状态
服务端最常用的状态检查命令是ss。除了看队列情况,还可以快速统计所有连接状态:
ss -ant | awk '{print $1}' | sort | uniq -c45 CLOSE_WAIT 12 ESTABLISHED 3 LISTEN 890 TIME_WAIT如果ESTABLISHED数量远小于TIME_WAIT,说明系统中有大量短连接,连接建立和释放频率很高。如果CLOSE_WAIT不断增长,按前文的方式排查socket泄漏。如果SYN_RCVD数量很高,可能有半连接队列溢出或SYN Flood攻击风险。
netstat -s也能提供一些内核级统计信息,比如被动打开次数、主动打开次数、超时重传次数等。结合ss使用,基本能把连接层的异常定位到内核队列、应用代码或网络设备三个层面。
4. 实战避坑:常见的TCP报错与排查实录
4.1 “bind: address already in use”与Docker端口占用
在Linux上跑服务时,最常见的报错之一是:
error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address翻译成人话就是:当前地址和端口组合已被占用,或者仍然有连接处于TIME_WAIT状态。排查步骤按顺序执行:
# 查看端口被哪个进程占用 ss -lntp | grep 11434 # 查看是否有大量TIME_WAIT占住端口 ss -ant | grep 11434如果是TIME_WAIT造成的,可以通过设置SO_REUSEADDR来解决。在C#中对应:
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);Go语言中则是:
l, err := net.ListenConfig{}.Listen(context.Background(), "tcp", ":11434")不过更常见的原因是Docker。Docker启动容器时报:
Error response from daemon: ports are not available: exposing port tcp 0.0.0.0:8080 -> 0.0.0.0:0: bind: address already in use这说明宿主机上的8080端口已经被其他进程占用。用上面的ss -lntp | grep 8080命令查到占用进程后,选择停止旧进程或修改容器的端口映射即可。需要注意的是Docker的端口映射默认绑定在0.0.0.0,如果宿主机上已有服务绑定了127.0.0.1:8080,同样会冲突,因为0.0.0.0包含了所有网卡地址。
4.2 Modbus TCP能Ping通但连不上
这个坑在工业通信领域非常典型。设备之间ping能通,但Modbus TCP的modscan工具就是连不上,或者报“TCP/IP Connection terminated!”。
先说原理:ping走的是ICMP协议,它只能证明IP层可达,不能证明TCP端口可通。Modbus TCP使用的是502端口,所以排查时第一件事是测试端口连通性:
telnet 192.168.1.100 502或者更推荐用nc:
nc -vz 192.168.1.100 502如果端口不通,下一步检查防火墙:
firewall-cmd --list-all iptables -L -n工业现场经常会出现设备A能ping通设备B,但设备B的防火墙或访问控制列表只放行了ICMP协议,没有放行TCP 502端口。
还有一个容易被忽略的原因是网关NAT配置问题。有朋友遇到过“Modbus TCP能ping通,但modscan不通”,最后发现是交换机上的ACL把TCP端口过滤了,导致SYN包无法到达PLC。现场排查时,最好在PLC侧用Wireshark或者镜像端口抓包,确认SYN是否到达、是否回了SYN+ACK,一步就能判断问题出在哪个环节。
4.3 串口能通但TCP连不上,为什么
工业自动化场景还有一种情况:串口走Modbus RTU能正常读写,但换成Modbus TCP后连接失败。很多人以为是协议转换器坏了,实际上两者属于完全不同的通信模型。
串口通信是点对点的,不存在寻址和端口概念,建链开销非常小。TCP则必须经过三次握手建立连接,而且依赖完整的IP网络链路。排查时不仅看物理链路,还要确认IP地址、子网掩码、网关、端口号是否一致。
另外,Modbus TCP和Modbus RTU的报文结构也不同,Modbus TCP在RTU基础上增加了MBAP报文头(共7个字节),包含了事务标识符、协议标识符、长度字段和单元标识符。如果串口服务器做的是“裸串口透传”,另一端必须自己处理MBAP头,否则数据格式对不上,即使TCP连接成功也无法正确解析报文。
4.4 服务端“连接被重置”的真实案例
有一次排查线上服务,客户端日志频繁出现TCP/IP connection terminated!。服务端是Java应用,客户端是C#程序。抓包发现:客户端发送完请求后,服务端直接回了RST包。这是因为服务端在处理请求时发生未捕获异常,主动关闭了Socket,而该Socket的接收缓冲区中还有未读取的数据,内核就发送RST而不是FIN。
这个案例给了一个非常重要的经验:收到RST和收到FIN是完全不同的两回事。FIN表示“我不会再发数据了,但之前的数据我都处理完了”;RST表示“这个连接已经无法继续使用了,立刻终止”。排查问题时,如果抓包显示大量RST,优先看应用代码是否在异常路径上错误关闭了连接,而不是去调TCP参数。
5. 三次握手之外:流量控制与拥塞控制联动
5.1 握手与滑动窗口的衔接
三次握手不只是建立连接,它还为后续的流量控制规定了初始参数。TCP的接收方会在SYN和SYN+ACK中通告自己的窗口大小(Window Size),即接收缓冲区剩余空间。随后每一段数据报文都会携带当前的窗口大小,这是滑动窗口协议的基础。
举个例子:客户端在第三次ACK中通告Window=14600,表示自己有14600字节的接收空间。服务端在后续发送数据时,就会把未确认的数据量控制在这个窗口范围内。如果客户端处理速度跟不上,窗口会逐步缩小到0,此时服务端停止发送数据,进入窗口探测状态。
实际项目中,窗口大小直接决定了大流量传输的吞吐量。Linux下可以通过修改socket缓冲区间接影响窗口大小:
sysctl -w net.ipv4.tcp_rmem='4096 87380 6291456' sysctl -w net.ipv4.tcp_wmem='4096 16384 4194304'net.ipv4.tcp_rmem里的三个值分别是最小值、默认值和最大值。如果接收窗口过小,即使带宽再大,TCP吞吐量也会被限制在“窗口大小除以RTT”这个上限附近。理解这个公式,就能明白为什么跨地域专线的RTT很高时,必须调大TCP窗口才能跑满带宽。
5.2 慢启动、快重传与握手重传的联系
新建立的TCP连接不会立刻以全速发送数据,而是从cwnd(拥塞窗口)的初始值开始,每收到一个ACK就指数增长,这就是慢启动。等cwnd超过慢启动阈值ssthresh后,进入拥塞避免阶段,增长速率下降为线性。
很多人在抓包时发现,新连接传输数据时并不是“一口气发完”,而是呈现“先少后多”的波形,这正是慢启动的效果。慢启动的代价是RTT大时吞吐上升慢,所以出现了TCP Fast Open和initialcwnd参数调优。Windows下可以用:
netsh int tcp set global initialwindow=10把初始拥塞窗口设为10个MSS,比默认的4到10个MSS能明显提升短连接场景下的传输效率。Linux下可以通过ip route change命令配合initcwnd来调整。
快重传是另一个关键的拥塞控制机制。当接收方收到乱序报文时,会重复确认丢失的序列号,连续收到3个重复ACK后,发送方立即重传,不用等待超时。排查线上传输慢问题时,如果看到大量重复ACK和TcpExtTCPFastRetrans计数器增长,说明网络中发生了乱序或丢包,优先检查MTU设置和交换机端口协商模式。
5.3 结合握手理解“连接很好但传输很慢”
“能建立连接”和“能快速传输数据”是两个不同层次的问题。三次握手只保证连接建立,传输速率取决于窗口大小、拥塞控制、网络丢包率和服务端处理能力。我在排查一个“TCP每次重启才能连上一分钟”的故障时,就发现连接本身一直能建立,但建立后数据传输极慢,最终定位到PLC的TCP服务指令只执行了一个扫描周期,后续通信全靠残留连接支撑。如果没有抓包分析,很难想到TCP握手完全正常但应用逻辑已经停止响应。
5.4 关于“TCP协议栈数据流走读”的延伸学习
对于想深入研究Linux TCP协议栈的读者,我建议按这个顺序阅读源码:tcp_v4_connect处理客户端主动连接,tcp_v4_do_rcv处理接收路径,tcp_rcv_state_process处理所有状态机转换,tcp_ack处理确认和窗口更新,tcp_sendmsg处理发送路径。先看入口函数,再跟进状态机,最后看数据路径。整个流程走下来,三次握手和四次挥手在内核中的具体行为就会非常清晰。这也是了解“为什么调tcp_tw_recycle会导致NAT用户断连”这类问题的底层路径。
最后再分享一个日常排查的小技巧:在怀疑TCP连接被异常重置时,第一时间看ss -ant和netstat -s | grep -i "resets",再配合tcpdump -i any port <端口> -nn抓包,基本五分钟内能定位到是服务端主动发RST、客户端误操作关闭还是防火墙拦截。做运维的这些年,这个三板斧流程帮我解决过大量连接偶发断开的疑难问题,比瞎调内核参数靠谱得多。