面试官最爱问的TCP三件套里,三次握手和四次挥手绝对排得上号。别管你是刚接触网络的新人,还是已经写过几年业务代码的老兵,只要系统一卡、接口一慢、连接一挂,最终排查时大概率都会绕回这两个基础过程。这篇文章不讲虚的,直接用图文思路把这两个过程掰开揉碎,从标志位到状态迁移,从抓包验证到排障实战,全部过一遍。
1. 为什么非要“握手”和“挥手”:先理解TCP连接的本质
1.1 连接不是一根线,而是双方的共识
很多人把TCP连接想象成一根物理网线,觉得“连接建立”就是两端把线插好,断开就是把线拔了。这个直觉在网络世界里是错的。TCP没有实体链路,它只是双方在内存里维护的一组状态和计数器,拿着这些状态去决定“我现在能不能发数据”“我发的数据对方收到没有”。
你可以把它想象成两个人在电话里对暗号:
- 第一个人说“你听得见吗”,第二个人回“听得见,你听得见我吗”,第一个人再回“我也听得见”,然后才开始聊正事。
这就是共识。双方必须确认三件事:我能发、我能收、对方也能发也能收。只有达成这个共识,数据才能可靠地按序送达。断开也一样,明明话说完了,还要互相确认“我要挂了”“好的你挂吧”“我也要挂了”“好的你挂吧”,才能干净利落地挂断电话。
这类状态在TCP里就是经典的三次握手(Three-Way Handshake)和四次挥手(Four-Way Wave)。搞懂它们,你才能真正看懂netstat里的SYN_SENT、ESTABLISHED、FIN_WAIT_2、TIME_WAIT、CLOSE_WAIT这些状态,也才能在下游接口超时、连接数飙高的时候,快速判断问题到底出在哪个环节。
1.2 为什么是三次而不是两次或四次
三次握手的最核心诉求是:让双方各自确认自己的收发能力都正常,并且对初始序列号达成一致。
举个反例,如果只用两次握手:客户端发SYN,服务端回SYN+ACK,服务端就认为连接建立完毕。但客户端收到SYN+ACK之后,并不知道自己发的初始序列号有没有被服务端正确记住,也不知道服务端是不是真的准备好了接收数据。更严重的是,如果客户端第一次发的SYN因为网络延迟,在服务端已经发出SYN+ACK之后才姗姗来迟,服务端可能已经为它建立了一个连接状态,而客户端根本不知道这回事,于是形成“服务端苦苦等待、客户端毫无响应”的半连接。
如果搞四次呢?不是不行,而是没必要。三次已经能让双方都确认“我发的你能收到,你发的我也能收到”,再多一次只会增加一次往返延迟。TCP当初设计成三次,是在可靠性和性能之间取的最优解。
2. 三次握手详解:图解每一跳到底在干什么
2.1 三次握手完整过程
先看标准的时序图,我把报文交互的走向画成下面这种简图,你再对照着每一行的含义看:
客户端(主动方) 服务端(被动方) │ │ │ ① SYN=1, seq=x │ │ ───────────────────────────────→ │ │ │ │ ② SYN=1, ACK=1, seq=y, ack=x+1 │ │ ←─────────────────────────────── │ │ │ │ ③ ACK=1, seq=x+1, ack=y+1 │ │ ───────────────────────────────→ │ │ │ │ 连接建立,开始传数据 │第一步,客户端发送一个SYN报文,随机生成一个初始序列号x,进入SYN_SENT状态。
第二步,服务端收到后,如果同意建立连接,会返回一个SYN+ACK报文。这个报文同时承担两个角色:一方面用SYN表示“我也准备好同步序列号了”,自身随机生成序列号y;另一方面用ACK确认“你的x我已经收到,下次请发x+1”,所以确认号ack=x+1。此时服务端进入SYN_RECEIVED状态。
第三步,客户端收到SYN+ACK后,会再回一个ACK报文,确认号ack=y+1,表示“你的y我也收到了”。此时客户端进入ESTABLISHED状态,服务端收到这个ACK后也进入ESTABLISHED状态。
这里有个常被忽略的细节:**前两次握手不能携带数据,第三次握手时,客户端已经可以携带数据了。**不过实际场景里,绝大多数应用还是等ESTABLISHED之后再发业务数据,按照标准语义来说,第三次握手附带的载荷是允许的。
2.2 关键字段:序列号、确认号、标志位
要说三次握手,离不开TCP报文头里的这几个字段。
序列号seq:表示本报文段数据部分的第一个字节在整个字节流中的编号。因为是随机初始值,所以对方不能提前猜到,这也是防止某些恶意伪造的手段之一。
确认号ack:表示“我期望收到对方的下一个字节编号”。收到ack=x+1,客户端就知道“对方已收到我发的x,下一个该发x+1”。因此确认号一定是对方序列号基础上加1,表达“确认了你发的那一个字节”。
标志位:SYN表示同步序列号,ACK表示确认号有效,FIN表示发送方没有数据要发、希望关闭连接,RST表示异常强制复位连接。
这三个字段组合起来,就实现了可靠传输的记账本功能。三次握手不仅仅是“确认对方在线”,更重要的是把双方的初始序列号对齐。后续数据流中每个字节都能按序号核对,丢了哪个就重传哪个,整个可靠传输机制都建立在这套序列号之上。
2.3 实际抓包怎么验证
理论讲完还是要落到抓包上。我自己常用tcpdump验证握手过程,命令很简单:
sudo tcpdump -i eth0 -nn 'tcp port 8080 and (tcp[tcpflags] & tcp-syn != 0)'连上8080端口后,会看到类似这样三行关键记录:
1 0.000000 192.168.1.10.50001 > 192.168.1.20.8080: Flags [S], seq 1000 2 0.000128 192.168.1.20.8080 > 192.168.1.10.50001: Flags [S.], seq 2000, ack 1001 3 0.000152 192.168.1.10.50001 > 192.168.1.20.8080: Flags [.], ack 2001第一行Flags [S]只有SYN,第二行Flags [S.]是SYN+ACK(Wireshark里显示[SYN, ACK]),第三行Flags [.]只有ACK。注意第二行的序号是2000,确认号是1001,也就是1000+1;第三行确认号是2001,即2000+1。这种“对方seq+1”的规则贯穿所有TCP交互,一定要养成看抓包日志的习惯。
如果客户端发起连接后一直停在SYN_SENT,抓包只能看到第一行反复重传,那就说明服务端根本没回应,大概率是端口没监听或者被防火墙拦截。如果服务端停在SYN_RECEIVED,而客户端又是正常的,那就需要检查半连接队列是否被打满。这块留到后面讲排查时细说。
3. 四次挥手详解:断开连接为什么要多一步
3.1 四次挥手完整过程
挥手比握手多一次,根本原因在于TCP是全双工的。每个方向的数据通道都要独立关闭,而一个FIN报文只能关闭一个方向。
正常断开过程:
客户端(主动关闭方) 服务端(被动关闭方) │ │ │ ① FIN=1, seq=u │ │ ───────────────────────────────→ │ │ (客户端进入FIN_WAIT_1) │ │ │ ② ACK=1, seq=v, ack=u+1 │ │ ←─────────────────────────────── │ │ (服务端进入CLOSE_WAIT,客户端进入FIN_WAIT_2) │ │ │ ③ FIN=1, ACK=1, seq=w, ack=u+1 │ │ ←─────────────────────────────── │ │ (服务端进入LAST_ACK,客户端进入TIME_WAIT) │ │ │ ④ ACK=1, seq=u+1, ack=w+1 │ │ ───────────────────────────────→ │ │ (服务端收到ACK后进入CLOSED,客户端2MSL后CLOSED)第一步,主动关闭方(通常先结束数据发送的一方)发送FIN报文,表示“我的数据发完了,我要关闭我这边的发送通道”。主动关闭方进入FIN_WAIT_1。
第二步,被动关闭方收到FIN后,立即回一个ACK,表示“我知道了,你那边先关吧,我这边的数据可能还没发完”。被动关闭方进入CLOSE_WAIT,主动关闭方进入FIN_WAIT_2。
第三步,被动关闭方把自己的数据发完后,发送FIN+ACK报文,表示“我这边也发完了,可以关闭了”。此时被动关闭方进入LAST_ACK状态。
第四步,主动关闭方收到FIN后,回一个ACK,然后进入TIME_WAIT状态。被动关闭方收到这个ACK后,直接进入CLOSED状态。
这里有一个二次确认的逻辑:被动关闭方的FIN必须等到自己的数据完整发完才能发出,因此它不会和第二步的ACK合并。这也是为什么挥手是四次而不是三次的根本原因——当然,如果被动方在收到FIN时确实没有任何数据要发送了,内核也允许把第二步和第三步的ACK与FIN合并发送,这就是抓包时偶尔会看到“三次挥手”的原因,但标准模型依然按四次来理解。
3.2 为什么必须要有TIME_WAIT和2MSL
每次主动关闭连接的一方,最后都会进入TIME_WAIT状态,且要等待2MSL(Maximum Segment Lifetime,最大报文段生存时间)才彻底关闭。这个设计常被新人当成“多余的等待”,但它是在处理两个非常现实的问题。
第一个问题:**保证最后一个ACK能到达对端。**如果最后一个ACK丢了,被动关闭方会重发它的FIN。主动关闭方如果直接进入CLOSED,就无法回应这个重发的FIN,被动方就会一直卡在LAST_ACK,连接永远释放不了。因此TIME_WAIT里的主机必须保持状态,以便收到对端重传的FIN时再发一次ACK。
第二个问题:**让旧连接的延迟报文在网络中自然消失。**假设没有TIME_WAIT,端口立刻释放,这时如果之前某个数据包因为网络拥堵还在链路里游荡,恰好又到达对端,对端会把它当成新连接的数据,造成数据错乱。等待2MSL,能确保网络中属于自己的旧报文段都寿终正寝。
MSL在不同系统上定义不同,Linux通常默认30秒或60秒,所以TIME_WAIT一般持续60到120秒。在大量短连接的服务器上,这个状态会让netstat里出现成片TIME_WAIT,看起来吓人,但大多是正常现象。关键是区分“正常的TIME_WAIT”和“异常的堆积”。
3.3 CLOSE_WAIT和TIME_WAIT之间的差异
实战排查里,这两个状态是最常被问到的。
TIME_WAIT是主动关闭方特有的状态,它表示“我已经发出最后ACK,正在等待旧包消亡”。出现量大,一般说明这台机器主动发起了大量短连接。对服务器来说,如果它是作为客户端去访问下游服务,那TIME_WAIT堆积在所难免。通常不需要过度干预,但如果量大到端口不够用,就要考虑开启连接复用、调整tcp_tw_reuse等参数。
CLOSE_WAIT是被动关闭方收到的FIN但还没发出自己的FIN时停留的状态,翻译过来就是“对端已经关了发送通道,而我这里还有数据没发完,或者应用程序还没调用close()”。大量CLOSE_WAIT几乎可以断定是应用层代码漏关了连接。尤其是用各种HTTP客户端库时,如果没有正确释放Response Body、没有关闭连接、没有设置超时,经常会在系统里积累一堆CLOSE_WAIT,最终把文件描述符耗尽。
两者的排障思路完全不同。看到大量TIME_WAIT先考虑系统参数和连接复用策略,看到大量CLOSE_WAIT则重点检查业务代码里连接释放逻辑,不要上来就调内核参数,方向错了再调也没用。
4. 常见问题与排查技巧实录
4.1 面试高频问题:第二次握手能不能携带数据
这也是不少人栽过的坑。标准TCP语义里,前两次握手不能携带应用数据,第三次握手可以携带。原因很直接:服务端在第二步发送SYN+ACK时,还没有确认客户端的接收能力是否正常,此时贸然携带数据,万一客户端收不到或拒绝接收,这些数据就白传了,还要触发重传逻辑。等第三次握手客户端回ACK后,服务端知道客户端能正常收发,这时再带数据就不浪费。
另外还有一个听上去有点绕的问题:**为什么主动关闭方最后要等2MSL,而不是直接关闭?**如果面试官追问,最好的回答方式就是拿“最后一个ACK丢失”来推演。你把四次挥手里第四步的ACK划掉,对端必然重发FIN,这时只有TIME_WAIT状态还在的主人能回应它。这个推演过程能区分你是背答案还是真理解。
4.2 大量TIME_WAIT的处理方法
先别急着把tcp_tw_reuse开到1,先搞清楚自己属于哪种情况。
如果服务器端口资源真的紧张了,比如对外短连接量大,导致本地端口耗尽,可以分几步处理:
第一,确认TIME_WAIT数量:
netstat -ant | awk '/^tcp/ {print $6}' | sort | uniq -c | sort -rn第二,查看端口范围还剩多少:
sysctl net.ipv4.ip_local_port_range默认端口范围通常是32768 60999,也就是大约28000个端口。如果并发短连接数量接近这个值,就会出现Cannot assign requested address之类的报错。
第三,选择安全的内核参数调整方案。比如开启tcp_tw_reuse,让内核在发起新连接时复用处于TIME_WAIT状态的端口:
sysctl -w net.ipv4.tcp_tw_reuse=1 sysctl -w net.ipv4.tcp_timestamps=1注意tcp_tw_reuse只对“本机作为客户端发起新连接”的场景有效,它不能让服务端立刻回收TIME_WAIT。如果业务允许,也可以减少FIN_WAIT_2超时时间,或者使用长连接替换频繁短连接,后者通常更治本。
提示:在容器环境或云主机里,很多内核参数未必能直接修改,动手前先确认sysctl是否可写,避免改了个寂寞。
我自己踩过一次坑:某服务调用下游API,qps一高就报端口不够,当时直接开tcp_tw_reuse,短期有效,但过了一周又复现。后来查代码才发现是HTTP客户端没启用连接复用,每次请求都新建TLS连接,加了个连接池之后,TIME_WAIT数量直接掉了90%。先把代码层面的连接复用做对,再考虑调内核。
4.3 大量CLOSE_WAIT的定位思路
CLOSE_WAIT堆积通常不是网络问题,而是应用程序问题。
典型的复现路径是:客户端主动断开连接,服务端收到FIN后,内核把连接标记为CLOSE_WAIT,等待应用程序调用close()回收。如果应用因为代码缺陷、锁竞争、线程池被打满等原因一直没执行close(),这条连接就永远停在CLOSE_WAIT。日积月累,文件描述符被占满,新连接就进不来了。
快速定位步骤:
- 统计状态:
ss -ant | awk '{print $1}' | sort | uniq -c- 找出
CLOSE_WAIT连接的对端地址和本地端口:
ss -ant state time-wait state close-wait- 如果是Java应用,用
jstack查看线程栈;如果是自己写的服务,去查所有读取流/响应流是否都调用了close()。重点是带超时控制的连接,比如数据库连接池、HTTP连接池,这类连接最容易被忽略。
注意:
CLOSE_WAIT和TIME_WAIT都不可怕,可怕的是你不知道它们为什么出现。先判断是主动关闭还是被动关闭,再判断是系统参数问题还是代码问题,这条思路比任何参数模板都重要。
4.4 SYN重传、半连接队列和SYN洪泛
当服务端收到大量SYN但来不及处理,新连接请求会被放进半连接队列(SYN队列)。如果队列满了,多余的SYN会被丢弃,客户端那边就会表现为SYN_SENT状态反复重传。
Linux下可以这样查看半连接队列溢出:
netstat -s | grep -i "drop" ss -lnt如果看到listen queue of a socket overflowed计数持续上涨,说明应用程序accept()速度跟不上,或者队列本身太小。针对短连接高并发场景,可以适当调大net.ipv4.tcp_max_syn_backlog,以及应用程序监听socket的backlog参数。注意:不是所有溢出都是攻击,很多是应用层处理慢导致的,先排查线程池和阻塞调用。
如果是恶意SYN洪泛,纯调参效果有限,通常需要结合专业的防护手段,比如syn proxy、限制单IP连接数等。这类内容涉及面太广,这里不展开,你只需记住:半连接队列满的表现和排查入口在netstat -s里看drop计数。
4.5 一次实际排障记录:接口偶发超时
最后分享一个真实案例,某内部系统的接口平均耗时50ms,但每天会有少量请求超时到10秒以上。我起初怀疑DNS或网络抖动,抓包对比后发现,超时请求的握手阶段异常:客户端发了SYN,服务端立即回SYN+ACK,但客户端的ACK迟迟没出现,然后客户端在1秒后重传了SYN。
问题不在服务端,而在客户端的内核参数tcp_syn_retries设置得太激进,导致客户端在收到SYN+ACK后提前把这条连接置为不可用。后来翻配置发现,是某次优化“加快重连”时,把tcp_syn_retries从3改成了0,结果弄巧成拙。把参数恢复后,超时现象消失。
这个案例想说明一点:三次握手看起来只有三个报文,但每个报文都受到客户端和服务端两边的内核参数影响。排查时要同时看两端的系统日志和抓包结果,不能只盯着一边看。
5. 从入门到实战:一套可复用的验证脚本
纸上谈兵结束,给你一套可以直接跑的验证思路。在一台Linux机器上起一个简单的TCP服务,然后抓包观察握手和挥手全过程。
先用nc测试最方便:
# 终端A:监听8080 nc -l 8080 # 终端B:抓包 sudo tcpdump -i any -nn 'tcp port 8080' # 终端C:发起连接,然后立即断开 nc 127.0.0.1 8080这种方式能看到完整的握手、数据发送、挥手过程。如果nc版本支持-k参数保留连接,也和真实服务行为更接近。如果要在真实业务接口上抓包,建议把tcpdump存成pcap文件,再导入Wireshark分析,界面更直观:
sudo tcpdump -i eth0 -nn -s 0 -w handshake.pcap 'tcp port 443'打开pcap后,在Wireshark的统计菜单里能看到TCP会话的时序图。你可以直接在图上看到SYN、SYN+ACK、ACK的时间间隔,也能看到FIN、ACK、FIN+ACK、ACK的顺序,以及每个连接的持续时间、状态、重传次数。这是把知识变成直觉最快的方式。
最后再分享一个小技巧
如果你真想彻底吃透三次握手和四次挥手,别只看概念,找个周末把下面三件事做一遍:用nc起连接并用tcpdump抓包,对照netstat观察每一步的状态变化;故意关掉服务端进程,看客户端连接时卡在哪个状态;再把客户端的tcp_syn_retries改成4,看丢包时的重传行为。三个实验做下来,你对TCP状态机的理解会比背十遍面试题都扎实。
以后再看netstat输出,看到SYN_SENT心里就有数“是发出去没回应”,看到CLOSE_WAIT提醒自己“是不是代码忘关连接”,看到TIME_WAIT明白“这是在等旧包消亡,大概率正常”。能到这一步,这篇文章就没有白读。