十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TCP三次握手与四次挥手全解:从握手队列到线上排查实战

TCP三次握手与四次挥手全解:从握手队列到线上排查实战 博主在排查连接超时问题的时候经常能看到TCP三次握手、四次挥手相关的状态和报错。而且这个知识点不只是面试用线上Linux服务器的连接状态、端口耗尽、CLOSE_WAIT堆积、Docker端口映射冲突追根溯源都回到这张连接状态图上。这篇就基于实际踩坑经历把三次握手和四次挥手彻底讲透从报文细节到内核状态再到常见的疑难报错一次说清。1. 三次握手连接建立的底层博弈1.1 握手过程的完整时序与数字细节三次握手的本质是通信双方对“初始序列号”的一次互相同步同时确认双方的收发能力都正常。标准的交互过程是这样的客户端发送SYN包初始序列号seqx进入SYN_SENT状态。服务端收到SYN后回复SYNACK包其中seqyackx1进入SYN_RCVD状态。客户端收到SYNACK后回复ACK包seqx1acky1进入ESTABLISHED状态。这里有一个特别容易忽略的细节为什么第二次要带上ackx1因为这个x1表示“我已经收到了你发来的序列号为x的报文现在期待下一个字节是x1”。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.]表示SYNACK第三次Flags [.]表示纯ACK。第三次握手之后客户端这一侧的序列号才真正变成seq1001服务端是seq2001两条方向上都建立了可靠的数据传输基准。1.2 为什么不是两次握手或四次握手很多初学TCP的人都会问为什么三次不是两次先说为什么两次不行。如果只有两次握手服务端在收到SYN并回复SYNACK后就会进入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全连接队列上限默认128Nginx等很多服务启动时会显式调大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包seqm进入FIN_WAIT_1状态。服务端收到FIN后回复ACK包ackm1进入CLOSE_WAIT状态。此时服务端仍然可以继续向客户端发送数据客户端进入FIN_WAIT_2状态。当服务端确认自己的数据也发送完毕后发送FIN包seqn进入LAST_ACK状态。客户端收到FIN后回复ACK包ackn1进入TIME_WAIT状态。第二步和第三步之间服务端可以继续发送剩余数据。这就是TCP的“半关闭”特性一端关闭了发送方向但接收方向仍然工作。在实际项目中我见过大量对“半关闭”理解不到位的Bug。比如一个C#编写的客户端程序下载完文件后立即调用Socket.Close()但服务端还在往客户端推送数据此时客户端直接发送RST包终止连接导致服务端日志里频繁出现“远程主机强迫关闭了一个现有的连接”。正确的做法是调用Socket.Shutdown(SocketShutdown.Send)先关闭发送方向等待服务端关闭后再彻底释放资源。2.2 TIME_WAIT和CLOSE_WAIT深度剖析四次挥手结束后主动关闭方会进入TIME_WAIT状态等待2个MSLMaximum Segment Lifetime报文最大生存时间后才会完全关闭。MSL在Linux中的默认值是30秒所以TIME_WAIT通常持续60秒。TIME_WAIT的两个核心作用必须记清楚保证最后一个ACK能可靠抵达。如果最后一个ACK丢失服务端会重发FIN主动关闭方需要能够再次回复ACK。让旧连接中的“迟到报文”在网络中自然消失。否则新的连接复用同一个四元组时可能收到属于旧连接的残留数据包。但TIME_WAIT过多也是个麻烦事。高并发短连接场景下主动关闭方会出现大量TIME_WAIT状态的连接进而导致端口无法及时释放。早期的解决办法是调整内核参数sysctl -w net.ipv4.tcp_tw_reuse1 sysctl -w net.ipv4.tcp_tw_recycle1注意tcp_tw_recycle在新版内核中已经被移除而且它存在一个严重的NAT环境问题如果多个设备通过同一个NAT网关访问服务端基于时间戳的tcp_tw_recycle会误判某些连接的时间戳过期导致连接直接被丢弃。实际实施时不建议开启tcp_tw_recycletcp_tw_reuse也只在客户端场景生效服务端场景基本不起作用。更推荐的做法是调整连接池策略减少短连接的创建频率。相比TIME_WAITCLOSE_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、SYNACK、ACK然后是应用数据再之后是三次握手的完整序列最后是四次挥手的FIN、ACK、FIN、ACK。如果使用了HTTP keep-alive会看到四次挥手被延迟到连接空闲超时后才出现。在tcpdump的输出中标志位缩写含义如下缩写含义SSYN同步序列号S.SYNACK回复同步请求并确认.ACK普通确认FFIN请求断开连接RRST重置连接我强烈建议把四次挥手和三次握手的抓包放到同一个文件里用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或者更推荐用ncnc -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是否到达、是否回了SYNACK一步就能判断问题出在哪个环节。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和SYNACK中通告自己的窗口大小Window Size即接收缓冲区剩余空间。随后每一段数据报文都会携带当前的窗口大小这是滑动窗口协议的基础。举个例子客户端在第三次ACK中通告Window14600表示自己有14600字节的接收空间。服务端在后续发送数据时就会把未确认的数据量控制在这个窗口范围内。如果客户端处理速度跟不上窗口会逐步缩小到0此时服务端停止发送数据进入窗口探测状态。实际项目中窗口大小直接决定了大流量传输的吞吐量。Linux下可以通过修改socket缓冲区间接影响窗口大小sysctl -w net.ipv4.tcp_rmem4096 87380 6291456 sysctl -w net.ipv4.tcp_wmem4096 16384 4194304net.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 initialwindow10把初始拥塞窗口设为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、客户端误操作关闭还是防火墙拦截。做运维的这些年这个三板斧流程帮我解决过大量连接偶发断开的疑难问题比瞎调内核参数靠谱得多。
返回列表