我接手过一个特别典型的故障:现场设备能ping通,Modbus TCP却死活连不上;另外一台设备更诡异,只有重启后的第一分钟能连上,过了窗口期就再也握手失败。那段时间我把TCP连接建立和断开的过程从头到尾重新捋了一遍,才发现很多人对三次握手、四次挥手的理解停留在"背面试题"的层面,真到了线上问题面前,连状态机都对应不上。
这篇就专门把TCP连接的建立和断开讲透。我会先用逻辑推演的方式说清楚握手和挥手的次数为什么刚刚好,再带你看真实抓包里每个字段的含义,最后落到实际开发中最常见的那几类连接故障。适合后端开发、网络工程师、嵌入式通信和PLC相关的从业者,尤其是那些被"端口被占用""连接超时""重启才能连上"折磨过的人。
1. 三次握手为什么是三次:连接的本质是一次"双方确认"
很多教程告诉你三次握手就是"SYN、SYN+ACK、ACK",但没说明白一个问题:为什么不是两次,也不是四次?要理解这一点,得先搞清楚TCP建立连接到底在干什么。
1.1 连接不是线,而是双方共同维护的一个状态
我们平时说"建立了一条TCP连接",脑子里容易浮现出一根物理线缆从A连到B。但TCP连接根本不是这么回事。它是通信双方各自在内存里维护的一组状态信息:对方是谁、初始序列号是多少、我的发送窗口有多大、对方还能收多少数据。这条"连接"是虚拟的,一旦两端某一侧重启或状态丢失,另一侧完全感知不到,除非真的发一个包过去试试。
所以握手本质上就是一件事:让双方在通信正式开始之前,把各自的状态信息告诉对方,并且确认对方已经收到了这些状态信息。这有点像两个人约定接头暗号,A先说"暗号是123",B听到后回一句"收到,暗号是123,我的暗号是456",A再确认"收到,你的暗号是456"。双方都确认了对方的暗号和自己的暗号已被对方知晓,这才算建立信任。
1.2 两次握手为什么不安全
假设只握手两次:A发SYN,B回SYN+ACK,B就认为连接已建立,开始给A发数据。但问题在于,A可能根本没有收到B回的那个包。原因可能是网络拥塞丢了包,也可能是A发出的SYN因为超时已经被A自己放弃了,但延迟后的旧SYN又到达了B。
这种情况最经典的场景是历史重复SYN:网络里滞留了一个旧的连接请求,比当前新发的请求更晚到达服务端。如果是两次握手,服务端收到旧SYN就直接建立连接,并且开始发送数据,但客户端压根不认这个连接,直接回RST,服务端才发现白忙活一场,期间还可能把数据发给了错误的连接上下文。三次握手能解决这个问题,是因为服务端会等客户端的最后一个ACK。如果客户端收到的是旧SYN对应的SYN+ACK,它会识别出序列号对不上,直接发RST终止,不会进入数据发送阶段。
换句话说,两次握手无法区分"当前请求"和"历史残留请求",而三次握手通过客户端的最终确认,把对"双方身份有效"的判断权交给发起方,因为只有发起方知道自己最近一次到底想连接谁。
1.3 三次握手过程中的序列号同步
握手不只是确认"你在我可以连",它还在完成一件更底层的事:交换初始序列号(ISN,Initial Sequence Number)。
TCP是面向字节流的可靠传输协议,每一个字节都有一个序列号。接收方依靠序列号对乱序到达的数据进行排序,依靠确认号告诉对方"我期望收到的下一个字节是从哪个序号开始的"。如果通信双方的起始序列号没有对齐,后续的数据包就会全部错位,接收方没法判断自己收到的数据是不是新的。
三次握手的包结构把这个过程体现得很清楚:
- 第一次:客户端发送SYN,携带自己的初始序列号,记为
client_isn。 - 第二次:服务端回复SYN+ACK,携带两个信息:
ack = client_isn + 1,表示"我已经收到了你那个SYN,我期望你下一个包从client_isn+1开始";同时带上服务端自己的初始序列号server_isn。 - 第三次:客户端发送ACK,携带
ack = server_isn+1,表示"我也收到了你的序列号"。
这里有个经常被忽略的点:确认号不是"收到你最后那个字节的序号",而是"我期望收到的下一个字节的序号"。很多人第一次看抓包会发现ACK的确认号比对方发来的序列号大1,那正好是因为SYN报文要消耗一个序列号,但本身不携带应用数据。
为什么要用随机ISN而不是固定从0开始?一是为了安全,避免攻击者猜到序列号之后伪造RST报文切断连接;二是为了让同一对四元组上先后建立的连接不容易产生序列号混淆。Linux内核里ISN是通过一个基于时钟的伪随机算法生成的,保证了不同连接之间的初始序列号差异足够大。
1.4 握手失败时发生了什么
理解了握手的机制,再看客户端连接超时这类问题就清晰多了。
-
SYN丢了:客户端发出去SYN,一段时间没收到SYN+ACK,会触发超时重传。Linux默认从1秒开始,指数退避到2、4、8秒,超过
tcp_syn_retries(默认6次)以后放弃,上报Connection timed out。如果服务端在防火墙层面直接丢弃SYN而不返回RST,表现就是"ping通但业务端口连不上"——因为ICMP的连通性和TCP握手是两套机制,ping通只说明IP层可达,不代表端口在监听。 -
服务端拒绝:服务端端口本身没监听,内核会直接回RST,客户端立刻收到
Connection refused。这个错误比超时"友好"得多,因为它说明服务端是活着的,只是那个端口上没有进程。 -
SYN Flood:攻击者发送海量SYN但不完成第三次握手,服务端为每个SYN分配连接控制块,占满半连接队列,正常用户的SYN就排在后面。查看
ss -s输出里的syns计数,或者netstat -s里的SYNs to LISTEN sockets dropped,都能发现这种异常。实际部署中会通过SYN cookies(在SYN+ACK里编码状态,不分配资源)来对抗,但那是另一个话题了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四次挥手为什么是四次:断开比建立更讲究"体面"
建立连接需要三次是因为要同步序列号,断开连接需要四次,原因则完全不同。理解挥手的关键,是要认识到TCP连接是双工的:数据可以在两个方向上独立传输。断开连接,本质上是两个方向各自的"关闭协商"。
2.1 四次挥手的数据包时序
我们假设客户端主动发起关闭,标准的四步是:
- 客户端发送FIN,表示"我的数据发完了,我要关闭这个方向的数据发送"。
- 服务端收到FIN后,回一个ACK,表示"我收到你的FIN了"。注意,此刻服务端未必已经发完自己的数据,所以这个ACK只是确认收到关闭请求,不会立刻跟着FIN。
- 服务端把自己的数据全部发完后,发送FIN,表示"我的数据也发完了,我也要关闭这个方向了"。
- 客户端收到FIN后,回一个ACK。到这里,双方的数据发送都关闭了,TCP连接彻底释放。
为什么不能合并成三次?因为第2步和第3步之间可能隔着很长时间,取决于服务端还有多少数据没发完。如果服务端收到FIN后立刻也没有数据要发了,那确实可以合并成一个FIN+ACK,所以实际抓包中偶尔能看到三次挥手,但那是在"被动关闭方没有剩余数据"这个特殊前提下的简写,不代表四次挥手可以被通用地压缩。
用生活场景类比很直观:A和B在打电话,A说"我说完了,挂了吧"(FIN),B说"知道了,我还有个事没说完"(ACK),接着B又说了五分钟,最后才说"我也说完了,挂吧"(FIN),A说"好的"(ACK),这才挂断电话。如果B一听A说完就立刻挂,那就漏掉了自己需要表达的内容。
2.2 半关闭状态的真实用途
四次挥手过程中存在一个半关闭阶段:客户端已经不再发送数据,但服务端还可以继续发送数据。这个状态在应用层通过shutdown()函数控制。
很多开发者只知道用close()关连接,但close()有个特性:它会把描述符的引用计数减一,只有当引用计数归零时才真正发起FIN。如果同一个socket被多个进程或线程共享,某个线程调close()并不一定触发断开。而shutdown(SHUT_WR)只关发送方向,立刻发送FIN,接收方向仍然可用。这在很多协议里非常实用,比如HTTP/1.0的服务端在发完响应后主动shutdown(SHUT_WR)通知客户端"响应结束了",但还能继续读客户端可能发来的请求。
半关闭在实际开发中还有一层意义:当一端调用close()立刻释放描述符后,如果对端还有数据正在网络里传输,这些数据到达时会因为没有进程接收而被RST掉,导致对端读到"connection reset by peer"。而先调用shutdown(SHUT_WR),等对端也返回FIN之后,再close()释放资源,可以避免这种尴尬。
2.3 挥手被RST打断的场景
正常情况下挥手是FIN交替完成的,但如果一方在挥手期间收到不需要的数据、或者自身状态异常,可能直接发送RST强制终止连接。最常见的触发场景是:一端已经发送FIN并进入FIN_WAIT_2,但另一端迟迟不关闭,这时候如果应用进程退出,内核会发送RST取而代之。客户端看到的现象就是读写时报错Connection reset by peer。
还有一种情况是半开连接(half-open connection):一方崩溃或断电,没有发出FIN,另一方完全不知道连接已经死了。此时如果这一方向对方发送数据,对方因为根本没有对应的连接状态,会回一个RST,发送方才能发现连接早就断了。
3. 用抓包视角复查握手与挥手:从Wireshark看每个字段
原理讲得再多,不如看一次真实的抓包。下面我以一次典型的HTTP请求为例,带你在Wireshark里把握手和挥手的每个关键字段过一遍。
3.1 一次完整的HTTP请求抓包分析
假设客户端IP是192.168.1.100,服务端IP是192.168.1.200,客户端访问服务端的8080端口。过滤表达式直接用tcp.port == 8080或者tcp.flags.syn == 1 || tcp.flags.fin == 1就能筛出握手和挥手包。
按照Wireshark的展示顺序,你会看到类似下面的包:
- 第1个包:192.168.1.100:50001 → 192.168.1.200:8080,
[SYN],Seq=0(Wireshark默认用相对序列号显示,实际ISN是随机大数)。 - 第2个包:192.168.1.200:8080 → 192.168.1.100:50001,
[SYN, ACK],Seq=0,Ack=1。注意Ack=1是因为"1"是相对序列号,代表client_isn + 1。 - 第3个包:192.168.1.100:50001 → 192.168.1.200:8080,
[ACK],Seq=1,Ack=1。握手完成。 - 中间是HTTP请求和响应的数据包。
- 第N个包:客户端 → 服务端,
[FIN, ACK],Seq=X,Ack=Y。 - 第N+1个包:服务端 → 客户端,
[ACK],Seq=Y,Ack=X+1。 - 第N+2个包:服务端 → 客户端,
[FIN, ACK],Seq=Y,Ack=X+1。 - 第N+3个包:客户端 → 服务端,
[ACK],Seq=X+1,Ack=Y+1。
这里面有个细节值得多看两眼:服务端的FIN和ACK是放在同一个包里的,这算是第三次挥手和第四次挥手在抓包里的常见形态。而这个[FIN, ACK]的出现时机,完全取决于服务端应用什么时候调用了close()。如果服务端在收到FIN之后还要做大量计算才关闭,你会看到N+1和N+2之间隔了很大时间差。
3.2 四元组如何区分同一条连接
抓包的时候如果你同时开了多个到同一服务端端口的连接,Wireshark里会显示多条TCP stream。区分它们的依据是四元组:源IP、源端口、目的IP、目的端口。四元组中的任何一个字段不同,都算作不同的连接。
理解这一点对排查握手问题很重要。你如果本机发起多个连接,源端口是内核临时分配的(Linux默认范围在/proc/sys/net/ipv4/ip_local_port_range里,通常是32768到60999),所以即使目标IP和端口完全一样,也能区分不同连接。但如果某个场景下源端口枯竭了,新连接就无法建立,症状就是"连接数到了一定数量之后,新的请求全部超时"。这种情况常见于短连接密集型的应用,配合TIME_WAIT堆积,很容易把本地端口耗干。
3.3 通过抓包判断握手失败的具体环节
线上排查时,我一般先把Wireshark的过滤条件设成tcp.flags.syn == 1 || tcp.flags.rst == 1,然后看三次握手走到哪一步断了:
-
只看得到客户端发SYN,看不到服务端回任何包:大概率是防火墙拦截,或者服务端半连接队列满,内核来不及处理SYN。抓包时如果只在客户端抓,无法区分是"包没到达服务端"还是"服务端回复丢了",最有效的办法是在服务端同时抓包,两边对照。
-
客户端发SYN,服务端回了SYN+ACK,但客户端没有回最后的ACK:一种可能是客户端的ACK丢了,另一种是客户端此时已经放弃(比如超时重传多次),然后服务端会重传SYN+ACK,直到超时关闭半连接。
-
三次握手完成,但紧接着出现RST:这通常是应用层协议校验失败,服务端发现客户端发来的数据不符合预期,直接断开。比如用TCP访问了一个HTTP端口,但对端发的不是合法HTTP请求,服务端就可能直接RST。
4. 状态机:从SYN_SENT到TIME_WAIT的每一站
抓包看到的是一个个独立的包,但TCP真正的运行逻辑体现在状态机的转移上。无论是客户端还是服务端,内核在每一时刻都维护着连接处于什么状态,而绝大多数连接故障,本质上都是两端的状态机没有按预期走下去。
4.1 客户端与服务端的完整状态流
我做了个表格,把主动关闭方(通常客户端)和被动关闭方(通常服务端)的状态流转放在一起对比,这样更容易理解双方的步调差异。
| 阶段 | 主动关闭方状态 | 被动关闭方状态 | 事件 |
|---|---|---|---|
| 建立 | CLOSED → SYN_SENT | CLOSED → LISTEN | 客户端调用connect发起SYN |
| 建立 | SYN_SENT → ESTABLISHED | LISTEN → SYN_RCVD → ESTABLISHED | 服务端收到SYN回SYN+ACK,客户端回ACK |
| 关闭 | ESTABLISHED → FIN_WAIT_1 | ESTABLISHED → CLOSE_WAIT | 主动方发送FIN |
| 关闭 | FIN_WAIT_1 → FIN_WAIT_2 | CLOSE_WAIT | 被动方回ACK |
| 关闭 | FIN_WAIT_2 → TIME_WAIT | CLOSE_WAIT → LAST_ACK | 被动方发送FIN |
| 关闭 | TIME_WAIT → CLOSED | LAST_ACK → CLOSED | 主动方回最后ACK,之后等待2MSL |
这个表里有几个关键点需要特别注意。
SYN_RCVD状态在服务端接收SYN后立即进入。如果客户端最终的ACK丢失,服务端会在SYN_RCVD状态重传SYN+ACK,最多重传tcp_synack_retries次,然后放弃。这个状态是SYN Flood攻击的重灾区。
FIN_WAIT_2是主动关闭方比较尴尬的状态:它收到了对方的ACK,但对方迟迟不发送FIN。正常情况下FIN_WAIT_2会一直等到对方关闭,但如果应用代码忘记关闭连接,这个状态就悬在那里。Linux提供了tcp_fin_timeout参数,默认60秒,超过后直接关闭。
LAST_ACK是被动关闭方发出FIN后等待最终ACK的状态。如果最终ACK丢失,被动方会重传FIN,直到收到ACK或者超时。对端已经主动断开但本端卡在LAST_ACK时,常见原因是对端的TIME_WAIT还没结束,或者网络丢包导致FIN重传一直没被确认。
4.2 CLOSE_WAIT:服务端堆积最隐蔽的异常
线上排查连接数异常时,我第一个看的指标就是ss -ant | grep CLOSE_WAIT。如果这个状态大量出现,基本可以断定是服务端应用代码的问题:客户端已经发送FIN表示要断开,服务端内核也回了ACK,但服务端应用进程一直没有调用close()把本端的socket关闭。
为什么会这样?常见的根因是线程池里的工作线程在处理请求时阻塞住了,比如等待数据库查询、等待外部API响应,迟迟不回到读取循环,自然没有机会执行close()。还有一种情况是代码里用了连接池,连接被取出去之后没有归还也没有关闭。
排查思路很简单:先看ss -antp确认卡在CLOSE_WAIT的进程PID,再jstack或gdb看线程栈。如果是在等待IO,就把超时时间缩短;如果是连接池泄漏,就检查acquire和release是否成对出现。CLOSE_WAIT不会像TIME_WAIT那样自动消失,它会一直占着文件描述符,积累到进程fd上限后,新连接直接报Too many open files。
4.3 TIME_WAIT:主动关闭方必须承受的2MSL
TIME_WAIT可能是网络编程里被讨论最多的状态,也是"端口耗尽"问题的核心来源。主动关闭方在发送最后一个ACK之后,必须进入TIME_WAIT状态,并等待2MSL(Maximum Segment Lifetime,报文最大生存时间)才能完全关闭。
为什么要有这个等待?至少两个目的。
第一,确保最后的ACK能到达对端。如果这个ACK丢了,对端会重发FIN,主动关闭方需要能再次回应ACK。如果主动方立刻进入CLOSED状态,收到对端重发的FIN时,内核已经找不到这个连接了,只能回RST,导致对端在LAST_ACK状态看到连接被重置,无法干净关闭。
第二,让网络中延迟到达的旧报文段“自然消亡”。假设连接关闭后立刻复用相同的四元组建立新连接,旧连接在网络里滞留的数据包可能被新连接误收,造成数据污染。等2MSL,旧报文要么到达并被处理,要么在TTL耗尽后被丢弃,新连接就不会收到历史残留数据。
2MSL究竟有多长?Linux的tcp_fin_timeout默认60秒,但实际TIME_WAIT时长基于系统对MSL的取值,通常是30秒到2分钟之间不等。在Linux上,TIME_WAIT的时间不能直接小于一个经验值,很多优化骚操作(比如tcp_tw_recycle)已经因为各种副作用被移除了,靠调内核参数刷TIME_WAIT并不是稳妥的做法。
在实际业务里,TIME_WAIT并不可怕,可怕的是大量TIME_WAIT堆积在客户端,把本地端口占满。比如一个高并发的短连接服务,每秒建立和关闭上千个连接,每个连接在TIME_WAIT中停留60秒,本地端口很快就用完了。这时候新连接会因为找不到可用本地端口而失败,报错就是Cannot assign requested address。
5. 开发实战里的连接问题排查:从握手挥手到具体报错
理论铺垫已经够多了,这一节把实际开发里最常见的几类连接故障串起来讲。你会发现,很多看似玄学的网络问题,归根结底都是握手或挥手某个环节没有按状态机正常推进。
5.1 "Address already in use"和"端口不可用"的真相
开发时经常碰到这类报错:
error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address- Docker部署时报
ports are not available: exposing port tcp 0.0.0.0:xxx
这两类报错的共同点都是服务端在bind()阶段发现端口已经被占用。但导致占用的原因有区别:
如果你要监听的端口上已经有一个进程在监听,那确实是正常的端口冲突,ss -lntp看一眼就知道是谁占的。但还有一种隐蔽情况:服务端程序退出后,端口进了TIME_WAIT状态,此时立刻重启服务,bind()不设置SO_REUSEADDR就可能失败。因为TIME_WAIT状态下的四元组还残留在内核中,新的监听socket如果地址复用选项没开,会认为端口被占用。
解决办法很简单:服务端程序在listen()之前设置SO_REUSEADDR。TCP规范允许监听socket绑定时复用处于TIME_WAIT状态的地址,不会对安全性造成影响。几乎所有生产级服务(Nginx、Redis、Node.js默认就是开启的)都是这么干的。
如果是Docker场景,除了容器内进程本身的端口占用,还要检查宿主机上的docker-proxy是否占了端口。Docker默认会把容器端口映射绑定到宿主机,如果之前一次映射没清理干净,或者两个容器绑了同一个宿主机端口,就会报ports are not available。
5.2 "只有重启才能连上一分钟":半开连接的典型症状
这类问题在工业通信场景特别多,比如PLC与上位机之间的TCP通信。现象是设备刚重启的时候能连上,过了一阵子就再也连不上了,再次重启又能恢复。热搜里那句"西门子tcp只有每次重启的时候才能连上一分钟",基本就是这个套路。
背后的原理是半开连接。PLC作为服务端监听端口,上位机作为客户端连接。如果上位机那边程序崩溃或者断电,没有发送FIN,PLC内核并不知道连接已经死了,一直以为连接还健在。等上位机重新启动,尝试建立新连接时,因为四元组相同,PLC发现已经有一条相同四元组的连接存在,于是拒绝或忽略新的SYN——表现为"只有重启才能连上"。
这类问题的排查思路:
- 在上位机崩溃重启后,先在PLC端执行
netstat -an或者ss -ant,看是否存在一条ESTABLISHED状态但实际对端已不存在的连接。如果是,基本实锤是半开连接。 - 解决方式是让PLC侧启用TCP Keepalive,或者在上位机应用层增加心跳检测。Keepalive的探测间隔在Linux下是72小时,默认太长,在实际工业场景通常需要调到秒级。
- 如果PLC本身不支持调整Keepalive参数,可以在上位机增加应用层心跳:周期性发送一个短报文,超过N秒没收到响应就主动关闭旧连接,再重新connect。
为什么"只有重启才能连上"而不是"一直连不上"?因为程序重启时,四元组中的客户端端口往往重新分配,跟旧连接不同,所以能绕过冲突建立新连接。一旦新连接建立的四元组又和滞留连接撞上了,就再次失败。这个现象很有迷惑性,但它恰恰是判断半开连接的重要线索。
5.3 Keepalive参数如何在Linux下调整
Linux的TCP Keepalive有三个内核参数:
tcp_keepalive_time:连接空闲多久后开始发送探测包,默认7200秒。tcp_keepalive_intvl:探测包发送间隔,默认75秒。tcp_keepalive_probes:连续几次探测无响应后判定连接断开,默认9次。
按默认参数算,一条死连接要等7200秒才开始探测,中间隔75秒发一个包,连续9次失败才断开,整个过程超过了两个小时。所以生产环境里如果要依赖系统级Keepalive,必须改参数。修改方式:
bash复制sysctl -w net.ipv4.tcp_keepalive_time=30
sysctl -w net.ipv4.tcp_keepalive_intvl=10
sysctl -w net.ipv4.tcp_keepalive_probes=3
不过要注意,这些是全局参数,会影响这台机器上所有TCP连接。更精细的控制是在socket上通过TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT选项单独设置,比如Java的Socket.setKeepAlive(true)加上系统参数配合,或者C/C++直接setsockopt。
还有一个容易混淆的地方:TCP Keepalive的探测包如果对端正常存活但暂时没有数据交互,对端内核会自动回ACK,这条连接会被判定为存活。但如果对端已经断电或网络路径断开,ACK迟迟不来,连续探测失败后,本端连接会被强制关闭,并通知应用层报错。TCP Keepalive解决不了"对端活着但应用无响应"的问题,那种情况要依赖应用层心跳。
5.4 Modbus TCP连接不稳定的排查思路
Modbus TCP的场景很典型,值得单独拎出来说。它本身是基于TCP的工业协议,默认端口502。很多人会碰到"能ping通,但Modbus扫描不通"的怪象,或者"Modbus TCP能连上,但读写超时"。
这类问题一般从这几个方向查:
- 用
telnet IP 502测试端口能否建立TCP连接。能建连但Modbus扫描不通,问题大概率在应用层,比如单元ID配置不对、报文功能码不支持、从站地址不匹配。 - 如果
telnet都连不上,回到握手层面排查。看服务端是否真的在监听502端口,有没有防火墙拦截SYN,是不是半连接队列满了。 - 连接建立后读写超时,看连接是否被服务端主动关闭,比如服务端设置了较短的连接空闲超时,客户端没有心跳或没有周期性的请求保活。
这里有一个行业经验:很多工业以太网设备使用的是老旧的TCP/IP协议栈,对Keepalive的支持很差,对TIME_WAIT的处理也比较粗糙。上位机软件如果频繁重连(每次断开后立即重连),服务端可能处于TIME_WAIT堆积状态,新连接无法建立。遇到这种情况,最简单的办法是调整上位机的重连策略,给每次重连之间加入退避延时,比如1秒、2秒、4秒递增,最大不超过30秒。
5.5 断线自动重连的设计细节
C#、Java、Python里做TCP客户端,很多人都会封装自动重连。但这个"自动重连"要是写得不讲究,很容易在服务端留下大量处于半开或TIME_WAIT的连接。
一个稳妥的重连循环应该包含几个要素:
- 连接建立后设置读写超时,避免
Read方法无限期阻塞。 - 循环里触发断线信号(异常、返回0、超时)后,先关闭当前socket,再进入退避重连逻辑。
- 重连前主动检查服务端状态,可以用TCP Keepalive确认旧连接是否已经没用了。
- 重连采用指数退避加随机抖动,避免多个客户端同时重连造成服务端瞬间拥塞。
伪代码大致长这样:
csharp复制while (!IsCancelled)
{
try
{
Connect();
// 进入正常通信循环
while (true)
{
var data = Read();
if (data == null) break; // 对端关闭
Process(data);
}
}
catch (Exception ex)
{
Log(ex);
}
finally
{
SafeClose();
}
await Task.Delay(ComputeBackoff());
}
细节决定成败的地方在SafeClose():关闭前先尝试shutdown(SocketShutdown.Both),再Close(),这样能尽量走完四次挥手,而不是直接RST。如果每次断开都是直接RST,对端会频繁收到Connection reset,日志会非常难看,也会让对端来不及清理连接。
另外一个经常被忽略的点是客户端断线后,服务端那条连接会进入CLOSE_WAIT还是直接消失,取决于客户端关闭时有没有发送FIN。如果客户端进程被kill -9,没有机会发送FIN,服务端就会残留一条半开连接,只能等Keepalive超时才能清理。所以客户端程序在退出前尽量做好优雅关闭,尤其是C#里用using块包装socket,或用catch块确保finally里执行close。
6. 一个容易被忽略的细节:挥手时的数据与RST
前面聊了很多工具层面的排查,最后想补充一个关于挥手阶段数据传输的细节,这个细节对很多初学者来说是个盲区,但实际项目里踩中概率很高。
6.1 半关闭状态下的双向数据不对称
TCP是全双工的,意味着即使已方发送了FIN,仍然可以继续接收数据。很多初学者想当然地认为"发了FIN就是连接彻底关了",于是客户端在发送完数据、调用shutdown(SHUT_WR)之后,还会去检查对端是否发来了后续数据,这部分逻辑常常写得不对。
举个例子:客户端发送一个请求,然后调用shutdown(SHUT_WR)表示"我的请求发完了,不再发别的了",但它仍然可以read()服务端返回的响应。这个模式下,只要对端没发FIN,客户端就一直读。如果服务端迟迟不响应,客户端就会一直阻塞在读操作上。此时如果给整个socket设置了SO_RCVTIMEO读超时,超时后报错,很多人会误判为连接异常,实际上只是对方还没来得及响应。
6.2 收到RST后继续读写的行为差异
另一个容易踩的坑是RST之后的读写行为。TCP协议规定,收到RST之后,本端连接会被强制关闭。此时再调用read(),会立刻返回错误,错误码通常是ECONNRESET(Connection reset by peer);而再调用write(),第一次可能成功,因为数据只是写到了内核缓冲区,第二次或调用read()时才报EPIPE。
这个差异经常让新手在排查时产生困惑:明明对端已经RST了,为什么write还能返回成功?其实这只是内核缓冲区的假象,数据并没有真的送出去。要准确判断连接是否已失效,最好以read的结果为准,或者周期性发送应用层心跳,用对端的响应来判断连接是否存活。
6.3 服务器主动关闭时如何避免踩到TIME_WAIT
我们讨论了客户端主动关闭的情况,但很多服务端协议需要服务端主动关闭连接。比如HTTP短连接,服务端发完响应后主动断开。这时服务端反而会成为TIME_WAIT的一方,如果QPS很高,服务端的TIME_WAIT会大量堆积。
Linux上减少服务端TIME_WAIT堆积的常用方法是:
- 开启
SO_REUSEADDR,保证服务端端口在重启后可以立即复用。 - 长连接化:让客户端复用连接,而不是每次请求都新建一个连接,从根源上减少挥手次数。
- 设置合理的
tcp_max_tw_buckets上限,超过上限后系统会尽快销毁TIME_WAIT连接,但这是兜底手段,不当默认优化手段用。 - 如果业务允许,可以在应用层协议里约定由客户端先关闭连接,把TIME_WAIT甩给客户端。
在我自己维护的网关服务里,有一个经验是:把对外服务的所有socket的SO_LINGER配置称为"安全区",只在明确知道对端不会继续发送数据时才把linger设为0(此时close会直接RST,不进入TIME_WAIT)。默认不要碰这个选项,因为它会让连接的非正常关闭频率大幅上升。
这么多年下来,我最大的体会是:TCP的握手和挥手不是面试题里的抽象概念,而是每次线上问题排查时真正在用的工具。你看到的每一个SYN、每一个FIN、每一个TIME_WAIT,背后都对应着一段具体的行为逻辑。把状态机装进脑子里,再去看那些"玄学网络故障",你会发现它们基本上都能在握手或挥手的某一环找到答案。
最后分享一个我自己的习惯:排查任何TCP相关故障,第一件事永远是看两端各自的连接状态快照,而不是直接在应用日志里猜。一条连接,两端各是什么状态,对比一下,问题基本就锁定了七成。这个习惯帮我解决过太多所谓"偶发连不上"的问题,你下次遇到类似情况,不妨也先试试。
