
写网络的人但凡做过几次故障排查几乎都绕不开TCP三次握手和四次挥手这两个词。很多刚接触TCP/IP协议栈的同事能背出“SYN、SYNACK、ACK”和“FIN、ACK、FIN、ACK”可一旦线上出现“端口不可用”“连接卡住”“客户端重连报地址已在使用”这种实际问题就不知道这些状态和数据包到底意味着什么。这篇文章不打算只讲教科书上的流程我会把三次握手、四次挥手掰开揉碎从协议栈的真实行为讲起再结合我这些年排查过的连接问题给你一份能直接用来分析故障的实操指南。无论你是写Java后端的、搞嵌入式单片机通信的还是被nginx反向代理连接数折磨过的运维都值得花几分钟把这里面的逻辑理顺。1. 三次握手一次连接建立背后的“三次确认”1.1 为什么必须是三次两次会漏掉什么很多人第一次接触三次握手时的疑问是既然客户端发一个SYN服务端回一个SYNACK不就够了吗为什么客户端还要再回一个ACK这里需要先理解TCP连接的本质TCP不是像UDP那样把数据包扔出去就不管了它要在通信双方之间同步一套“状态”让两边都确认“你那边能收我这边能发”。如果只握两次最大的问题在于无法避免历史重复连接请求造成的错误。想象一下客户端因为网络卡顿第一个SYN报文在网络里滞留了很久客户端等不到回应就超时重传了第二个SYN。第二个SYN被服务端正常处理连接建立完毕数据也传完了连接关闭。这时候那个滞留的旧SYN姗姗来迟服务端如果只靠两次握手收到旧SYN就直接以为客户端想再建立一条连接于是分配资源、回复确认可客户端根本没有这个意图。两个握手只能让服务端单方面确认“我收到了你的请求”却不能让客户端确认“服务端收到的确实是这一次的新请求”。三次握手通过最后一次ACK把“我确认你确认了”这个信息传回去同时双方在握手过程中交换初始序列号ISN。服务端收到旧SYN后回复SYNACK客户端一看这个确认号对不上自己当前发送的序列号范围就知道这条连接是历史残留直接发一个RST把它切断不会浪费服务端资源。所以从设计动机上看第三次ACK看起来像是一次多余的回执实际上是为了让连接建立过程对“网络中存在旧报文”这件事有抵抗能力。另外三次握手还有一个不起眼但很重要的功能同步双方的初始序列号。TCP的可靠传输依赖每个字节都有一个序号发送方通过序号告诉接收方“这段数据从哪个位置开始”接收方通过确认号告诉发送方“我下一个期待收到哪个序号”。这个序号必须在一开始就约定好否则后续重传、去重、排序全都无从谈起。两次握手可以交换序列号吗从表面看能交换但做不到双向确认一旦某一方没收到对方的序列号整个连接的数据顺序就是错乱的。1.2 seq、ACK、SYN三次握手里的数字游戏三次握手不是抽象概念它落到报文上就是三个具体的TCP报文每个报文带着自己的序列号seq和确认号ack。我习惯用一个最简单的例子来说明数字之间的对应关系。假设客户端要连服务端客户端选择的初始序列号是x服务端选择的初始序列号是y。第一步客户端发送SYN报文其中seqx。这个报文不携带任何应用数据SYN标志位置1它表达的意思是“我从序号x开始发送数据请建立连接”。第二步服务端收到后回送SYNACK报文其中seqyackx1。这个ackx1的含义是“我已经收到你从x开始的数据下一个字节我希望收到x1”。同时服务端也在SYN标志位中把自己的初始状态同步给客户端告诉客户端“我的起始序号是y”。第三步客户端再发送ACK报文其中seqx1acky1。seq变成x1是因为第二步中服务端已经确认了x所以客户端接下来的数据从x1开始acky1则是对服务端初始序列号的确认。这里有个新手特别容易糊涂的点为什么确认号是对方序列号加1因为SYN报文虽然不携带应用数据但它在TCP语义里占用一个序号。就像占了一个坑位接收方必须告诉发送方“你占用的那个坑我已经知道了下一个坑可以开始填”。同理四次挥手里的FIN报文也占用一个序号所以对FIN的确认也是seq1。从这个数字关系能看出来三次握手里的seq和ack是严格对应的。你在抓包工具里如果看到某个SYN的seq是1000那么响应的SYNACK里ack就必须是1001否则这条连接就是对不上的。实际排障中很多异常连接就是从序号错乱开始的而Linux内核的TCP协议栈对这种错乱处理得很小心一旦发现确认号不合理会直接丢弃或者回RST。1.3 抓包才能看到的真实握手过程理论讲再多都不如自己抓一次包。我最推荐的方式是在本机用tcpdump抓回环接口的流量然后用Wireshark打开分析或者直接在命令行用tcpdump加-A参数看到TCP标志位。一个最简单的动手实验是这样启动一个监听端口的服务比如nc -l 8080然后从另一个终端执行curl http://127.0.0.1:8080同时用下面的命令抓包sudo tcpdump -i lo -nn tcp port 8080 -w handshake.pcap抓完后用Wireshark打开handshake.pcap过滤表达式输入tcp.flags.syn 1 || tcp.flags.fin 1就能看到完整的连接建立与关闭过程。你会在第一条看到客户端的SYN第二条是服务端的SYNACK第三条是客户端的ACK这三条报文的seq和ack正好就是我上面讲的数字关系。等到连接结束时又能看到双向各发送一次FIN的四个报文。我建议所有做网络开发的人都亲手抓一次这个包因为只有亲眼看到SYN、ACK、FIN这些标志位在不同报文里的组合才会对TCP状态机有一个实体感。比如你会注意到客户端发送的第三个ACK通常情况下不携带应用数据它只是纯粹用来完成握手的确认。如果抓包时发现第三步的ACK迟迟不来服务端就会一直停留在SYN_RCVD状态这就是半连接队列堆积的开端。2. 握手不是瞬时完成重传、队列与攻击2.1 SYN重传与connect超时三次握手看起来是一瞬间的事但网络环境不是理想化的。SYN报文可能在中间链路丢失可能被对端协议栈丢弃也可能因为服务端半连接队列满而无法处理。这种情况下发起连接的一方不会无限期地等下去而是会按照退避策略重传SYN。Linux里控制TCP SYN重传次数的是net.ipv4.tcp_syn_retries。不同发行版默认值不完全一样常见的是6次。第一次重传等待时间是1秒之后每次翻倍1秒、2秒、4秒、8秒……如果重传6次都收不到SYNACK客户端的connect调用最终会返回超时错误也就是我们常说的Connection timed out。所以如果你在排查连接超时时发现整个过程持续了很长一段时间多半是客户端在反复重传SYN而不是真的“卡死”了。很多定时任务或者微服务调用场景里connect超时时间过长会导致上层业务线程被阻塞很久。遇到这种情况除了调整应用层超时时间还可以根据实际网络质量调小tcp_syn_retries。比如在一个内部局域网里丢包率很低把重试次数从6改成3最坏情况下总耗时也就124815秒左右比默认的六十多秒短得多。反过来如果是在跨地域的高丢包链路就不能盲目调小否则一次瞬时拥塞就会让连接失败。排查SYN重传可以从客户端看tcpdump里是否有多个源端口相同、seq相同的SYN包重复出现也可以直接看服务端的netstat -s里SYNs to LISTEN sockets dropped这类统计。如果SYN重传频繁说明链路确实存在丢包或者服务端的SYN处理能力已经到了瓶颈。2.2 半连接队列和backlog服务端为什么突然“连不上”服务端收到客户端的SYN后会进入SYN_RCVD状态把这条连接信息放进半连接队列等收到客户端的ACK后再把它移动到全连接队列。这两个队列只要有一个满了新的连接就可能建立失败。Linux内核中影响这两个队列的参数有这么几个net.ipv4.tcp_max_syn_backlog控制半连接队列的最大长度应用通过listen函数传入的backlog参数以及net.core.somaxconn共同决定全连接队列的长度。很多开发者在写服务端程序时以为listen(fd, 1024)就代表可以接受1024个并发连接其实不是。这个backlog只是告诉内核全连接队列的上限参考值真正的上限是min(backlog, somaxconn)。如果somaxconn默认值是128你代码里写1024实际队列也就只有128。我在压测一些Java服务时遇到过这类问题明明没到连接数上限客户端却出现连接被重置抓包看服务端直接发了RST查下来就是监听队列被打满新的SYN根本进不了队列。当全连接队列满的时候新到的客户端ACK还没完成第三次握手就会被丢弃客户端可能表现为连接建立后立刻失败或者长时间无法建立连接。对于这种情况建议观察ss -lnt输出里的Send-Q和Recv-Q。如果你看到Recv-Q数值长期接近Send-Q说明应用层accept的速度已经跟不上连接建立的速度这时候首先应该检查应用代码是不是在accept之后处理得太慢而不是一味地调大队列。增大队列参数只是给应用争取处理时间真正瓶颈往往在应用自身。2.3 SYN Flood握手也会成为攻击入口三次握手的半连接过程天然存在一个弱点服务端收到SYN就要分配内存、记录状态但客户端可以不完成第三次握手。如果攻击者用伪造源地址发起海量SYN半连接队列会被快速填满正常的连接请求就进不来了这就是经典的SYN Flood攻击。Linux内核应对SYN Flood最有效的机制是SYN Cookie。开启net.ipv4.tcp_syncookies1后服务端在判断半连接队列压力过大时不再保存这条半连接的状态而是通过源地址、端口、时间戳等信息计算出一个Cookie把这个Cookie作为初始序列号放在SYNACK里返回。客户端回ACK时如果携带的确认号正好等于Cookie处理后的值服务端就能确认这个客户端确实收到了之前的SYNACK再分配完整连接资源。这个机制的精妙之处在于它把服务端的“状态存储”从收到SYN时延迟到了收到ACK时攻击者如果无法收到SYNACK就无法构造出有效的确认号。不过SYN Cookie也不是银弹它会增加服务端CPU计算开销而且TCP的一些扩展选项在这种情况下可能无法正常协商。所以生产环境的建议是保留syncookies开启同时配合连接速率限制、黑名单等更上层的防护手段。做防护时不要只盯着内核参数应用层也能做很多事情比如nginx有limit_conn、limit_reqiptables有synproxy模块都可以和多层防护一起用。3. 四次挥手关闭连接比打开更讲究3.1 为什么关闭要四次而不是三次连接建立需要三次连接关闭却需要四次这说明关闭过程要考虑更复杂的情况。建立连接时双方都还没开始收发数据状态是同步的关闭连接时可能某一方还有数据没发完。TCP支持半关闭状态就是一方可以停止发送数据但继续接收数据。四次挥手的具体过程是主动关闭方发送FIN表示“我的数据发完了但我还能继续收你的数据”被动关闭方收到FIN后回一个ACK表示“我知道了但你等我把我手头的数据发完”被动关闭方处理完所有剩余数据后再发送自己的FIN表示“我这边也发完了”主动关闭方最后回一个ACK确认。之所以不能像握手那样三步完成是因为第二步和第三步之间隔着一段不确定的时间。被动关闭方收到FIN时应用层可能还有数据要发送内核不能替应用层马上决定关闭连接。如果强行合并成三个报文就意味着被动关闭方必须在收到FIN的瞬间就能把“停止发送”的决定做出来这在TCP的设计里并不现实。简单说四次挥手是为了给对端留出发送剩余数据的时间。实际应用中如果被动关闭方在收到FIN后很长时间都没有调用close它就不会发送FIN连接会停留在CLOSE_WAIT状态。这通常是应用层代码的bug某个线程读到EOF后没有释放资源或者读循环在收到-1之后忘了退出。线上如果发现ss -s里CLOSE_WAIT数量持续上涨几乎可以断定是应用没有正确关闭socket而不是网络问题。3.2 TIME_WAIT、CLOSE_WAIT最容易被忽视的两个状态四次挥手完了以后主动关闭方不会立刻释放连接而是要进入TIME_WAIT状态持续等待2MSLMaximum Segment Lifetime最大报文段生存时间。MSL是TCP报文在网络中存活的最长时间Linux里通常设置为30秒或60秒所以TIME_WAIT常见时长是60秒到120秒。为什么需要TIME_WAIT两个原因。第一保证最后的ACK能可靠到达被动关闭方。如果这个ACK在网络中丢了被动关闭方会重发FIN主动关闭方必须还保留连接状态以便重新发送ACK否则被动关闭方永远等不到确认只能用RST断开或者一直重试。第二确保本连接中残留的延迟报文在网络里自然消失。TCP连接靠四元组源IP、源端口、目的IP、目的端口区分如果马上释放端口并建立相同四元组的新连接旧连接的一个延迟数据包可能会被新连接错误地接收。TIME_WAIT状态因此是TCP可靠性的一个保障但它也会带来实际问题。高并发的服务端如果主动关闭连接会产生大量TIME_WAIT连接每个连接占用本地端口和一小部分内核内存。如果本地端口耗尽新连接就无法建立。这就是很多Java客户端在频繁重连时报“Address already in use: connect”的根本原因之一。解决方案不是粗暴地把TIME_WAIT改成0而是在代码里尽量使用长连接或者让服务端负责主动关闭连接再或者开启net.ipv4.tcp_tw_reuse配合对端时间戳机制重用TIME_WAIT连接。注意很多老资料里提到的tcp_tw_recycle在现在的内核中已经不建议使用它依赖时间戳且在后置NAT环境下容易丢包害人不浅。CLOSE_WAIT则是另一类问题。被动关闭方收到FIN回完ACK后如果没有发出自己的FIN就会一直CLOSE_WAIT。正常情况下CLOSE_WAIT是短暂的过渡状态但如果工程代码里线程池关闭超时、数据库连接池没释放、或者某段代码在收到EOF后没走到close分支CLOSE_WAIT会越积越多。排查时先ss -lnt或者ss -ant查出CLOSE_WAIT的连接对端IP和端口再结合应用日志去定位是哪个服务模块没有释放连接。很多人一看到TIME_WAIT多就调内核参数看到CLOSE_WAIT多却不知道怎么办其实CLOSE_WAIT几乎都是应用层原因别急着优化内核。3.3 还有一条关闭路径RST不是所有连接都以FIN作为结束。TCP还有RST标志位用来表示“这条连接已经无法继续立即强制终止”。RST不需要ACK确认收到RST的一方会直接丢弃该连接的所有缓冲数据连接随即进入CLOSED状态。RST通常在什么场景下出现服务端监听队列满时可能对新连接回RST服务端收到一个不属于任何现有连接的数据包时可能回RST一方发送数据另一方已经关闭连接收到数据后也可能回RST防火墙或代理设备在某些策略下也会直接重置连接。应用层感知到的情况往往是“Connection reset by peer”或者“Broken pipe”。排查RST问题比较麻烦因为它不像FIN那样有规范的挥手过程可能瞬间就消失了。抓包时如果看到RST先看它跟之前报文的时序关系。如果RST出现在连接建立刚完成时多半是服务端应用主动拒绝如果出现在请求发送后可能是服务端处理异常直接close如果是空闲连接被RST可能是中间设备或对端存活检测机制在起作用。不要看到一个RST就断定是“对端把连接关了”要结合代码逻辑确认对端是否真的收到并处理了你的数据。4. 热搜里的TCP问题其实都是握手挥手没搞透4.1 “ports are not available”和“Address already in use”用Docker的时候你大概率见过这条报错error response from daemon: ports are not available: exposing port tcp 0.0.0.0:xxxx: bind: address already in use。这个错误看起来是“端口不可用”本质是你在宿主机的IP和端口上去绑定一个监听socket但内核告诉你这个地址已经被占用了。排查这类问题第一步是确认谁占用了端口。用ss -lntp或者lsof -i :xxxx查一下如果显示某个进程正在监听这个端口那就换端口或者停掉那个进程。但还有一种情况比较隐蔽报错来自docker-proxy进程。Docker在发布端口时默认会启动一个用户态代理进程做端口转发如果你之前某次容器删除不干净docker-proxy还残留占用着端口ss会看到一堆docker-proxy进程各自监听一些端口。这种时候要么重启Docker服务要么手动处理好僵尸进程。另一个容易混淆的场景是Java客户端程序重连时报java.net.BindException: Address already in use: connect。这个错误并不是目标端口被占而是本机用于发起连接的临时端口分配不出来。TCP客户端连接一个服务端时内核会从本地的临时端口范围里挑一个空闲端口作为源端口。如果之前的连接大量进入TIME_WAIT状态还没释放或者连接被异常关闭导致端口表项残留源端口就可能耗尽。需要在Java里用Socket.setReuseAddress(true)同时注意这个选项要在绑定本地地址之前设置。我见过不少同事把setReuseAddress加在已经连上之后那基本没有作用。实际解决时可以先看ss -an | grep TIME_WAIT的数量如果上万个TIME_WAIT聚集在同一对客户端和目标服务端之间并且你用的是短连接优先改成连接池复用。要是改动代码成本高再考虑调整net.ipv4.ip_local_port_range扩宽可用端口范围或者开启net.ipv4.tcp_tw_reuse让内核在安全条件下重用TIME_WAIT端口。但记住TCP协议栈的参数调整要配合实际场景不能为了消掉TIME_WAIT而把可靠性牺牲掉。4.2 TCP dup ACK到底是不是网络丢包抓包时经常看到TCP Dup ACK看起来像是网络在报警但其实Dup ACK只是TCP的一种确认机制收到重复确认不一定就说明丢包也可能是报文乱序。TCP接收方每收到一个比期望序号大的数据包时会立刻回复一个确认号不变、仍指向缺失序号的ACK这个ACK就是Dup ACK。发送方连续收到3个重复ACK后会触发快速重传直接重发那个缺失序号的数据段而不必等到重传超时。如果你的抓包里只有一两个Dup ACK后续数据都正常到达那大概率只是路由层面的乱序或者对端使用了多路径传输。如果Dup ACK持续出现并且同一序号被反复要求重传那就说明中间链路确实有丢包。快速重传机制的价值在于它让发送方不必傻等超时。一台服务器的往返延迟如果是100毫秒超时重传可能设置300到500毫秒而快速重传能在收到三个Dup ACK后立刻重发时间大幅缩短。这也是为什么TCP在丢包不那么严重的链路上依然能维持不错的吞吐。排查Dup ACK时我习惯配合ss -s看retrans计数再结合抓包统计重传率。如果丢包集中在某几跳路由器之间那再怎么优化TCP参数都没用得去查链路质量。4.3 nginx反向代理的TCP连接数上限nginx作为反向代理时它既承担着客户端连接又要向后端服务器发起新连接这时候TCP的四元组资源、TIME_WAIT状态、文件描述符都会成为瓶颈。很多人遇到过“nginx连不上后端”或者“连接数上不去”打开错误日志看到一堆connect() failed (99: Cannot assign requested address)。先说说为什么会出现这种情况。nginx每个worker进程能同时持有的连接数受两个因素限制一个是worker_connections配置项它定义了每个worker进程的最大并发连接数另一个是操作系统的文件描述符上限ulimit -n。实际生效值往往是两者中的较小者。如果配置了worker_processes 4worker_connections 1024整个nginx理论上有4096个连接容量但如果你ulimit -n只有1024那么每个worker其实最多1024加起来还是只受fd限制。更隐蔽的问题是nginx与后端之间的短连接。如果nginx没有配置keepalive长连接每个请求都会创建一个新的TCP连接到后端请求结束后这个连接由nginx或者后端主动关闭。主动关闭方会累积TIME_WAIT一旦本地端口数量不够用就会报Cannot assign requested address。这时候要做两件事一是给nginx配置upstream的keepalive参数复用后端连接二是检查net.ipv4.ip_local_port_range是否够宽。默认范围通常是32768 60999将近三万多个端口如果并发量实在太大也可以调大到1024 65535但要注意不要撞上已监听的服务端口。另外很多人忽略worker_connections与网络模型的关系。nginx的每个连接在事件驱动模型里消耗一个文件描述符worker_connections本质就是fd配额。如果你同时开启了HTTP和TCP代理stream模块这两类连接会共享worker的连接预算。设定worker_connections时建议留出20%到30%的余量因为代理场景下每个上下文还可能额外占用fd不是简单的“一个连接等于一个fd”那么直接。4.4 嵌入式TCPESP01S和Modbus TCP的实操细节嵌入式设备上做TCP通信和服务器端不太一样典型例子是ESP01S通过AT指令发送TCP消息。这个场景里三次握手和四次挥手依然存在但你需要通过AT指令的返回结果来观察连接状态。ATCIPSTARTTCP,192.168.1.100,8080成功后模块会返回CONNECT OK这就是三次握手完成的标志。但TCP连接不会永远保持如果网络波动、对端关闭或者长时间没有数据模块可能进入CLOSED状态。很多人在调试时遇到“ESP01S发送TCP消息偶尔失败”先去查Wi-Fi信号却忽略了AT固件里TCP连接的保活机制。ESP01S这类模块的底层TCP栈通常没有内置应用层心跳连接状态是隐式的建议在应用里定期发送应用心跳数据并根据ATCIPCLOSE和重新执行的ATCIPSTART来恢复连接。再有一个容易踩的坑是单次发送数据长度。很多AT固件的ATCIPSEND一次性允许发送的最大字节数有限制常见的是2048字节。如果你向服务器发送超过这个长度的数据模块要么报错要么只发送部分内容。正确的做法是应用层把数据分包每次根据模块返回的提示符发送一个不超过上限的数据块最后再发\r表示发送结束。你在写客户端时要考虑到这点和TCP的MSS最大报文段大小无关纯粹是AT固件的应用层限制。Modbus TCP则是工业场景里最典型的TCP请求响应协议。它默认使用502端口一个TCP连接上可以连续发送多个Modbus请求但每个请求都带一个事务标识符Transaction ID响应里必须原样带回来。这里最常见的问题是“单连接实验”里的串行处理如果你用同一个TCP连接发起多个请求而不等上一个响应回来客户端可能会把响应与请求错配尤其是报文多到触发TCP分段的时候。排查时一定要看事务ID是否对应而不是只看总长度。用C#写Modbus TCP客户端时我习惯用一个锁或者队列保证同一时间只有一个请求在途直到收到对应的响应再放行下一个这样可以把握手挥手的干扰降到最低。4.5 单连接实验与交互信息量一次能跑多少数据热搜里有一个词很有意思“TCP单连接实验”。很多刚接触网络编程的人以为TCP连接建立之后可以无限并行发送数据其实TCP是字节流协议单条连接上的数据是串行确认的不考虑多路径、SACK等复杂机制。单连接上能跑多少数据取决于窗口大小、往返延迟和丢包率。打个比方TCP连接就像一条单车道公路发送窗口决定了路上能同时跑多少辆车往返时间决定了从你发出去到收到确认需要多久。一辆“车”就是没有被确认的数据。如果窗口是64KB往返延迟是50毫秒那么不管带宽多大这条连接的最大吞吐大致等于窗口除以RTT也就是64KB/0.05s约1.28MB/s约10Mbps。这时候提高带宽没有用瓶颈在窗口大小上。所以你在做“单连接实验”时如果想验证一条TCP连接能传输多少数据不要只盯着带宽还要把ss -i里的cwnd和snd_wnd打出来看。很多压测工具报告吞吐上不去根本不是代码问题是TCP窗口和延迟乘积Bandwidth Delay Product没设对。遇到这种场景要么调整socket的SO_SNDBUF要么开启TCP窗口缩放选项。现代Linux默认支持窗口缩放但某些嵌入式设备或老固件可能不支持通信双方如果协商失败窗口就会一直限制在64KB吞吐自然会卡住。另外交互信息量的查询不只是看某一瞬间的吞吐。用LabVIEW和NI实时机做TCP交互时想知道“信息量到底有多少”通常不是简单看netstat字节数而是在上位机和实时机两端都缓存发送/接收计数定期比对。抓包工具能够看到应用层的交互频率但判断通信是否拥塞最好还是在两端分别把时间戳和累计字节数打出来做差分析。TCP的流控和确认机制决定了对端能确认多快你这边就能发多快单连接实验的价值就在于让你直观看到这个因果关系。5. 排查TCP连接问题的方法与工具思路5.1 90%的问题先看队列和状态拿到一个TCP连接问题我建议先不要急着抓包先用ss把连接状态和队列情况看一遍。命令组合基本就这几个ss -antp | grep 8080 ss -s ss -lnt第一条能看到指定端口的连接状态注意看LISTEN、ESTAB、TIME_WAIT、CLOSE_WAIT这些状态的数量分布。第二条看全局统计能快速判断是不是整个系统范围内状态异常。第三条专门看监听队列。如果你发现监听队列的Recv-Q长期等于或大于Send-Q说明有连接卡在握手最后的完成阶段没有及时被应用accept。连接状态往往能直接告诉你问题方向。TIME_WAIT多基本都是连接关闭太频繁CLOSE_WAIT多基本是应用层没释放连接SYN_SENT多可能是客户端到服务端的链路不通或者服务端没监听SYN_RCVD多可能是半连接队列堆积或者有人刷SYN。这些状态不需要高深的网络理论就能定位大概率方向比一上来就抓包高效得多。5.2 抓包之后看什么如果状态看起来正常但业务还是报错这时候再抓包。抓包的重点不是看每一个包而是看标志位和时序。第一步过滤出三次握手的包。找到SYN之后看是否有对应的SYNACK以及随后的ACK。如果SYN发出去了但完全没有回应客户端会继续重传SYN这是链路问题或者目标端口无监听。如果有SYNACK但之后没有客户端ACK问题可能在客户端或者中间设备上。第二步找RST和重传。RST是瞬间的要用Wireshark里的tcp.analysis.flags过滤才能快速标出所有异常包。重传包可以用tcp.analysis.retransmission过滤。看这些包的间隔和序号如果重传的总是同一个序号说明对端一直在丢这个特定的数据段。第三步看握手耗时。Wireshark里可以在统计菜单中查看TCP连接建立的时序客户端发出SYN到收到SYNACK之间的时间就是握手RTT。如果这个时间异常大链路往返延迟高或者服务端处理SYN的队列已经快满了。还可以用tshark统计连接建立时间tshark -r handshake.pcap -q -z conv,tcp这个统计能列出所有TCP连接的四元组、收发字节数和起始时间用来观察连接建立频率特别方便。5.3 五个我踩过的坑和调整经验第一个坑把somaxconn和backlog搞混。我曾经把一个Go服务的backlog从512调到2048结果压测还是不断有连接被拒查了许久才发现somaxconn还是默认128。Go在Linux上监听的backlog会取系统somaxconn所以真正要改的是net.core.somaxconn。调整后连接建立成功率立刻恢复。第二个坑为了消TIME_WAIT开了tcp_tw_recycle。这个参数在后置NAT环境下会让同一个公网IP后面的多个内网用户时间戳递增出现异常连接时好时坏表现成“随机丢包”。后来我把参数关掉改用连接复用和tcp_tw_reuse问题才定位清楚。现在的内核版本建议直接用连接池解决别碰这些容易引发复杂问题的开关。第三个坑在Java客户端代码里设置setReuseAddress(true)但放错了位置。Socket的setReuseAddress必须在bind或connect之前生效如果你拿到已连接的Socket再去调用毫无意义。Java里要自定义本地端口时可以先创建一个未连接的Socket设置好参数再connect否则地址占用报错照样会出现。第四个坑ESP01S发送长数据。我之前写了一个固件ATCIPSEND发送超过2KB的数据模块返回ERROR表面看是TCP问题实际是AT指令的缓冲区限制。分包发送之后就好了。这个经验说明面对嵌入式TCP模块先看模块文档里对数据长度的限制再去怀疑协议栈。第五个坑nginx反向代理大量报错之后第一反应是调大worker_connections。我一度把worker_connections调到65535结果重启后系统负载飙升因为每个worker能处理的事件暴增而业务本身没那么多并发反而让事件循环变得繁忙。后来我改成按实际并发量预留30%余量同时给后端配置了keepalive 100连接数问题才真正解决。TCP三次握手和四次挥手讲到底就是一个状态同步的过程。建立时要同步序列号关闭时要确保双方都确认数据收完中间插入的各种重传、队列、超时机制都是为了让这个过程在有损网络里尽量可靠。我实际排查过这么多连接问题之后最大的体会是不要只记状态名字要把seq、ack、SYN、FIN这些标记放到具体报文里理解。下次再遇到“端口不可用”或者“连接被重置”你就不会只盯着应用日志猜而是先看一眼连接状态和抓包结果问题往往就明朗了。