面试官最爱问的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服务端回SYNACK服务端就认为连接建立完毕。但客户端收到SYNACK之后并不知道自己发的初始序列号有没有被服务端正确记住也不知道服务端是不是真的准备好了接收数据。更严重的是如果客户端第一次发的SYN因为网络延迟在服务端已经发出SYNACK之后才姗姗来迟服务端可能已经为它建立了一个连接状态而客户端根本不知道这回事于是形成“服务端苦苦等待、客户端毫无响应”的半连接。如果搞四次呢不是不行而是没必要。三次已经能让双方都确认“我发的你能收到你发的我也能收到”再多一次只会增加一次往返延迟。TCP当初设计成三次是在可靠性和性能之间取的最优解。2. 三次握手详解图解每一跳到底在干什么2.1 三次握手完整过程先看标准的时序图我把报文交互的走向画成下面这种简图你再对照着每一行的含义看客户端主动方 服务端被动方 │ │ │ ① SYN1, seqx │ │ ───────────────────────────────→ │ │ │ │ ② SYN1, ACK1, seqy, ackx1 │ │ ←─────────────────────────────── │ │ │ │ ③ ACK1, seqx1, acky1 │ │ ───────────────────────────────→ │ │ │ │ 连接建立开始传数据 │第一步客户端发送一个SYN报文随机生成一个初始序列号x进入SYN_SENT状态。第二步服务端收到后如果同意建立连接会返回一个SYNACK报文。这个报文同时承担两个角色一方面用SYN表示“我也准备好同步序列号了”自身随机生成序列号y另一方面用ACK确认“你的x我已经收到下次请发x1”所以确认号ackx1。此时服务端进入SYN_RECEIVED状态。第三步客户端收到SYNACK后会再回一个ACK报文确认号acky1表示“你的y我也收到了”。此时客户端进入ESTABLISHED状态服务端收到这个ACK后也进入ESTABLISHED状态。这里有个常被忽略的细节**前两次握手不能携带数据第三次握手时客户端已经可以携带数据了。**不过实际场景里绝大多数应用还是等ESTABLISHED之后再发业务数据按照标准语义来说第三次握手附带的载荷是允许的。2.2 关键字段序列号、确认号、标志位要说三次握手离不开TCP报文头里的这几个字段。序列号seq表示本报文段数据部分的第一个字节在整个字节流中的编号。因为是随机初始值所以对方不能提前猜到这也是防止某些恶意伪造的手段之一。确认号ack表示“我期望收到对方的下一个字节编号”。收到ackx1客户端就知道“对方已收到我发的x下一个该发x1”。因此确认号一定是对方序列号基础上加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.]是SYNACKWireshark里显示[SYN, ACK]第三行Flags [.]只有ACK。注意第二行的序号是2000确认号是1001也就是10001第三行确认号是2001即20001。这种“对方seq1”的规则贯穿所有TCP交互一定要养成看抓包日志的习惯。如果客户端发起连接后一直停在SYN_SENT抓包只能看到第一行反复重传那就说明服务端根本没回应大概率是端口没监听或者被防火墙拦截。如果服务端停在SYN_RECEIVED而客户端又是正常的那就需要检查半连接队列是否被打满。这块留到后面讲排查时细说。3. 四次挥手详解断开连接为什么要多一步3.1 四次挥手完整过程挥手比握手多一次根本原因在于TCP是全双工的。每个方向的数据通道都要独立关闭而一个FIN报文只能关闭一个方向。正常断开过程客户端主动关闭方 服务端被动关闭方 │ │ │ ① FIN1, sequ │ │ ───────────────────────────────→ │ │ 客户端进入FIN_WAIT_1 │ │ │ ② ACK1, seqv, acku1 │ │ ←─────────────────────────────── │ │ 服务端进入CLOSE_WAIT客户端进入FIN_WAIT_2 │ │ │ ③ FIN1, ACK1, seqw, acku1 │ │ ←─────────────────────────────── │ │ 服务端进入LAST_ACK客户端进入TIME_WAIT │ │ │ ④ ACK1, sequ1, ackw1 │ │ ───────────────────────────────→ │ │ 服务端收到ACK后进入CLOSED客户端2MSL后CLOSED第一步主动关闭方通常先结束数据发送的一方发送FIN报文表示“我的数据发完了我要关闭我这边的发送通道”。主动关闭方进入FIN_WAIT_1。第二步被动关闭方收到FIN后立即回一个ACK表示“我知道了你那边先关吧我这边的数据可能还没发完”。被动关闭方进入CLOSE_WAIT主动关闭方进入FIN_WAIT_2。第三步被动关闭方把自己的数据发完后发送FINACK报文表示“我这边也发完了可以关闭了”。此时被动关闭方进入LAST_ACK状态。第四步主动关闭方收到FIN后回一个ACK然后进入TIME_WAIT状态。被动关闭方收到这个ACK后直接进入CLOSED状态。这里有一个二次确认的逻辑被动关闭方的FIN必须等到自己的数据完整发完才能发出因此它不会和第二步的ACK合并。这也是为什么挥手是四次而不是三次的根本原因——当然如果被动方在收到FIN时确实没有任何数据要发送了内核也允许把第二步和第三步的ACK与FIN合并发送这就是抓包时偶尔会看到“三次挥手”的原因但标准模型依然按四次来理解。3.2 为什么必须要有TIME_WAIT和2MSL每次主动关闭连接的一方最后都会进入TIME_WAIT状态且要等待2MSLMaximum 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语义里前两次握手不能携带应用数据第三次握手可以携带。原因很直接服务端在第二步发送SYNACK时还没有确认客户端的接收能力是否正常此时贸然携带数据万一客户端收不到或拒绝接收这些数据就白传了还要触发重传逻辑。等第三次握手客户端回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_reuse1 sysctl -w net.ipv4.tcp_timestamps1注意tcp_tw_reuse只对“本机作为客户端发起新连接”的场景有效它不能让服务端立刻回收TIME_WAIT。如果业务允许也可以减少FIN_WAIT_2超时时间或者使用长连接替换频繁短连接后者通常更治本。提示在容器环境或云主机里很多内核参数未必能直接修改动手前先确认sysctl是否可写避免改了个寂寞。我自己踩过一次坑某服务调用下游APIqps一高就报端口不够当时直接开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服务端立即回SYNACK但客户端的ACK迟迟没出现然后客户端在1秒后重传了SYN。问题不在服务端而在客户端的内核参数tcp_syn_retries设置得太激进导致客户端在收到SYNACK后提前把这条连接置为不可用。后来翻配置发现是某次优化“加快重连”时把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、SYNACK、ACK的时间间隔也能看到FIN、ACK、FINACK、ACK的顺序以及每个连接的持续时间、状态、重传次数。这是把知识变成直觉最快的方式。最后再分享一个小技巧如果你真想彻底吃透三次握手和四次挥手别只看概念找个周末把下面三件事做一遍用nc起连接并用tcpdump抓包对照netstat观察每一步的状态变化故意关掉服务端进程看客户端连接时卡在哪个状态再把客户端的tcp_syn_retries改成4看丢包时的重传行为。三个实验做下来你对TCP状态机的理解会比背十遍面试题都扎实。以后再看netstat输出看到SYN_SENT心里就有数“是发出去没回应”看到CLOSE_WAIT提醒自己“是不是代码忘关连接”看到TIME_WAIT明白“这是在等旧包消亡大概率正常”。能到这一步这篇文章就没有白读。