
先说我自己的一个观察三次握手和四次挥手大概是整个TCP/IP体系里被讲得最烂、却也是被理解得最浅的知识点。网上搜出来的文章九成以上在背状态流转图从SYN_SENT到ESTABLISHED再到FIN_WAIT_2背得滚瓜烂熟。但真扔到线上环境里看到一堆TIME_WAIT不知道怎么处理抓包看到SYN重传不知道怎么定位甚至有人以为粘包是握手没做对。这些问题的根源都是同一个——把TCP协议当成了面试八股而不是一套需要在实际网络环境里理解其行为的通信机制。我写这篇不是复述教科书。我会把三次握手、四次挥手背后的设计理由、抓包实测、状态机流转以及线上真实的连接异常案例全部串起来讲。适合刚接触网络协议的新手建立正确认知也适合干了两三年开发但总在连接问题上栽跟头的工程师对照自查。1. 三次握手不是走流程是TCP解决可靠性的最小成本方案1.1 TCP在IP之上到底干了什么先说清楚TCP的定位。IP层只负责把数据包从一台主机送到另一台主机它不保证不丢包、不保证顺序、不保证不重复。TCP要做的事情是在这个不可靠的IP之上给两个进程之间提供一条可靠的双向字节流。这里的关键词是“双向”。TCP连接不是一条单向管道而是两个方向上各自独立的数据流。所以建立一个连接时双方不仅要确认“你能收到我的数据”还要确认“我也能收到你的数据”。这就不只是说一句“喂你在吗”就完事而是要同时完成双向的序列号同步和收发能力确认。我经常用一个不完全严谨但很好懂的类比两个人第一次合作干活A问B“你那边准备就绪了吗”B回答“就绪了你那边呢”A再回一句“我也就绪了开始吧”。这三次对话缺一不可少了任何一次至少有一方不知道对方是否真的进入了工作状态。TCP的三次握手本质就是这套对话的工程化版本。客户端发SYN服务端回SYNACK客户端再回ACK。三次报文交换完成后双方都确认了自己能发能收、对方能发能收同时交换了各自的初始序列号这才敢放心传出业务数据。1.2 为什么两次握手不行很多人以为三次握手是为了“保险”觉得多握一次总比少握一次好。恰恰相反三次是精算后的最小次数两次不够四次浪费。用状态机来想。假设只有两次握手客户端发SYN服务端回SYNACK后立刻认为自己连上了。问题在于服务端无法确认这个SYNACK是否真的到了客户端手里。如果这个包在网络里丢了客户端还在等服务端却以为连接已经建立任务都分发下去了。客户端等不到响应会重发SYN服务端每收到一个SYN就创建一次资源两边对连接状态的认知完全错位。更隐蔽的问题是旧SYN的干扰。如果客户端之前发过的一个SYN因为网络延迟、重传等原因晚到了服务端收到后照样创建一个半开连接。客户端此时根本不知道这回事。没有第三次握手把“这个SYN是我当年发过的旧包不是新连接”这个消息传回去服务端就会一直白白挂着这个假连接。第三次握手的作用本质上是一次确认的确认。客户端收到服务端的SYNACK确认“服务端确实知道我要建连且我知道了服务端的初始序列号”然后回一个ACK告诉服务端“你发的我都收到了你也别等了”。只有这步完成双方才算真正达成一致。1.3 为什么不是四次、五次第四次握手能提供什么新信息吗没有了。第三次ACK到达服务端时客户端已经确认了服务端的收发能力服务端也通过接收ACK确认了自己的收发能力和客户端的接收能力。双方对连接参数、时序、初始序列号都对齐了哪怕世界上只剩这一个连接需要建立也不需要多握一次。再往深一层说第三次握手允许捎带数据。也就是说客户端在回ACK的同时就可以把第一个业务数据包一起发出去这等于把“确认连接建立”和“开始传输数据”合并到了同一次报文交换里。三次握手在时序上也不浪费一个额外的RTT。1.4 SYN攻击与半连接队列的隐性关联理解了三次握手的状态流转就能看懂一个经典攻击场景SYN Flood。攻击者疯狂伪造源IP发SYN包服务端收到后回SYNACK进入SYN_RCVD状态并等待客户端的ACK。因为客户端根本不存在所以这个半连接永远无法完成。如果攻击速度快半连接队列内核里为尚未完成握手的连接预留的队列会被塞满后续正常用户发来的SYN连排队的位置都没有表现为服务器拒绝服务。处理方式之一是启用内核的tcp_syncookies。服务端不把半连接放进队列而是把连接的关键信息加密编码进SYNACK的序号里等客户端返回ACK时再根据这个“cookie”还原连接参数。这会牺牲部分TCP扩展选项但能在队列被打满时保住正常连接。这也解释了为什么很多服务被打到拒绝连接时调大backlog往往没用——攻击不是慢吞吞地排队而是直接占满队列得靠syncookies这类机制兜底。2. 抓包看三次握手seq、ack到底怎么算的2.1 一次真实的tcpdump输出理论说再多不如抓一次包。我在本地起了一个简单的TCP服务端用curl发起一次请求用tcpdump抓包过滤条件很简单tcpdump -nn -i lo tcp port 8080 -S-s参数关闭相对序号显示这里我加上-S。抓到的关键三帧长这样11:33:21.001 IP 127.0.0.1.51234 127.0.0.1.8080: Flags [S], seq 3164392231, win 65495, options [mss 65495,sackOK,TS val ...,nop,wscale 7], length 0 11:33:21.001 IP 127.0.0.1.8080 127.0.0.1.51234: Flags [S.], seq 1745713020, ack 3164392232, win 65483, options [mss 65495,sackOK,TS val ...,nop,wscale 7], length 0 11:33:21.001 IP 127.0.0.1.51234 127.0.0.1.8080: Flags [.], ack 1745713021, win 65495, length 0注意看三个信息SYN包的seq是一个很大的随机数不是从0或1开始的第二个包SYNACK里的ack等于第一个包的seq加1第三个ACK包里的ack等于第二个包的seq加1。这是因为SYN标志本身会消耗一个序列号即使SYN包里没带数据接收方也会认为“对方的下一个数据应该从我这个序号的下一位开始”所以ack就是对方的seq1。从这个真实抓包里也能看出三次握手的开销是三次报文交换、一个RTT的时间以及交换双方各自的初始序列号。如果以后有面试官问“TCP初始序列号为什么随机”你至少能说出两个理由防止被攻击者猜中序列号之后伪造注入数据防止旧连接遗留的重复报文被错误地当成新连接的数据接收。2.2 第三次握手能带数据前两次不能这是我特别想强调的一点。第三次ACK在TCP协议栈里允许携带应用数据而第一个SYN、第二个SYNACK不行。原因很简单前两次报文交换完成之前服务端还没有确认客户端的序列号有效双方也不知道对方的序号空间这时候如果直接带业务数据接收方根本不知道该往哪个位置放。而在第三次握手时客户端已经知道服务端的seq服务端也知道了客户端的seq所以客户端可以在发出ACK的同时把第一个业务数据包紧跟着发出去。我之前调试过一个内部RPC框架连接建立后第一条业务消息之所以能这么快就是因为它被合并到了三次握手的第三次包之后没有额外等待一个完整RTT。这是TCP设计里非常精明的地方。2.3 握手完成不代表应用层连上了accept队列的坑这个坑我见过不止一次包括我自己团队也踩过。现象是客户端抓包看到三次握手已经完成SYN、SYNACK、ACK一帧不少客户端connect()也返回成功了但服务端应用层的accept就是不出来或者连接刚建立就被重置。原因通常在listen套接字的accept队列上。内核为每个listen socket维护两个队列半连接队列SYN队列存放SYN_RCVD状态的连接和全连接队列accept队列存放已完成握手、等待应用层调用accept()取走的连接。三次握手完成连接进入的是全连接队列而不是直接交付给你的业务代码。如果应用层accept()速度跟不上或者因为某个worker线程阻塞导致没人调accept全连接队列就会越积越多。队列满之后内核对新完成握手的连接处理方式比较粗暴要么直接丢弃第三次ACK导致客户端反复重传要么直接回RST告诉客户端这个连接我不要。排查方法很直接ss -lnt看监听端口的Send-Q和Recv-Q。Recv-Q表示当前全连接队列里等待accept的连接数Send-Q是队列上限。如果Recv-Q持续等于Send-Q说明队列满了客户端就会表现出“TCP握手都完成了但连接马上被断开”或者“connect超时”。我碰到过的实际场景是一个Java服务shutdown时没等线程池把现有连接处理完就重启重启期间老进程的监听socket没人accept瞬间堆积了一堆全连接新进程起来后怎么都连不上直到旧进程彻底退出才恢复。TCP握手成功和应用层连接可用的区别这个坑值得你牢记。3. 四次挥手是怎么被“逼”出来的半关闭与TIME_WAIT3.1 全双工连接决定了两个方向必须独立关闭TCP连接是双向的所以关闭也必须分两个方向独立进行。你说“我不再给你发数据了”这是发送方向的关闭你还得告诉对方“不过你发给我的数据我还在收”这是接收方向保留。这种“只关一半”的能力在TCP里叫半关闭对应socket编程里的shutdown()。四次挥手的完整流程是主动关闭方发FIN表示“我的数据发完了我不再主动发送数据”被动关闭方收到FIN后回ACK表示“我收到你要关闭的请求了”此时被动方向的数据照发不误被动方自己的数据也发完之后再发FIN表示“我的数据也发完了你也关吧”主动方最后回一个ACK双方关闭。这个流程为什么必须四步而不是三步是因为被动方没法保证“我收到你的FIN时我自己已经没有数据要发了”。它可能还有一屁股数据要传传完才轮到它发FIN。三个方向的关闭事件在时间上是被动的没法提前和ACK合并。3.2 每个状态背后的含义我不打算只背状态名而是把每个状态和实际发生的事件挂钩。主动关闭方的路径ESTABLISHED - FIN_WAIT_1主动方调close()发出FIN。此时它还在等对方的ACK。FIN_WAIT_1 - FIN_WAIT_2收到对方对FIN的ACK。此时主动方的发送方向已关闭但接收方向还开着继续等对方把剩余数据传完。FIN_WAIT_2 - TIME_WAIT收到对方的FIN并回ACK。此时一切数据都传完了进入TIME_WAIT。被动关闭方的路径ESTABLISHED - CLOSE_WAIT收到对方FIN回ACK。CLOSE_WAIT的准确含义是“对方已经关了发送方向但我被动方还可以继续发送数据只是需要由应用层决定什么时候也close”。如果你的服务器进程里有大量CLOSE_WAIT积压十有八九是代码里没在对端关闭后调用close()连接就这么挂着。CLOSE_WAIT - LAST_ACK被动方应用层close()发出FIN进入LAST_ACK等待对方最后的ACK。LAST_ACK - CLOSED收到最后一次ACK关闭。主动方和被动方的关系用一个表看得更清楚主动关闭方状态触发事件被动关闭方状态ESTABLISHED主动调用close()发FINESTABLISHEDFIN_WAIT_1收到ACKCLOSE_WAITFIN_WAIT_2被动方收完数据后发FINCLOSE_WAITTIME_WAIT收到FIN并回ACKLAST_ACKCLOSED等待2MSL后CLOSED3.3 FIN_WAIT_2为什么要设超时FIN_WAIT_2是很微妙的一个状态主动方已经关了发送方向但接收方向还开着等被动方发FIN。如果被动方一直不发FIN呢可能它还在传数据也可能它对端进程已经挂了但操作系统没有收到关闭指令更可能是网络问题导致它的FIN丢了。如果主动方无限期等下去这个连接会占用一个文件描述符和内核资源。所以内核为FIN_WAIT_2设置了超时时间默认由net.ipv4.tcp_fin_timeout控制在Linux上通常是60秒。超过这个时间主动方会强制关闭连接不再等对方的FIN。这也是为什么你在线上看到FIN_WAIT_2通常不会积压太久——除非你改过这个参数或者内核状态异常。4. 线上到底怎么看TIME_WAIT、CLOSE_WAIT与端口耗尽4.1 TIME_WAIT不是bug但大量堆积是事故先纠正一个常见误解TIME_WAIT本身不是问题它是TCP协议为了保证通信安全而设置的一个“监狱”让你不能立刻离开得老老实实待够2倍MSL报文在网络里的最大存活时间。这期间主要是等两件事第一万一你最后发的那个ACK丢了对方会重发FIN你还在TIME_WAIT状态里就能再回一个ACK确保对方能正常关闭。如果没等够时间就直接CLOSED对方重发的FIN会找不到你只能一直在LAST_ACK里干等。第二保证属于这个连接的所有旧报文都已经在网络中消失不会串到新连接的数据流里。TIME_WAIT的时间足够长旧连接的所有包要么送达要么消亡新连接不会收到脏数据。TIME_WAIT的问题出在量上。短连接场景里一秒钟可能建立几千个新连接每次连接关闭都由主动方进入TIME_WAIT并保持几十秒这些连接就会大量堆积。每个TIME_WAIT都占用一个四元组源IP、源端口、目标IP、目标端口当四元组数量吃紧时新连接就建不起来。最经典的线上报错是Cannot assign requested address因为本地端口都卡在TIME_WAIT里没有释放客户端无法为新建连接分配一个可用的四元组。我之前做一次高并发短连接压测时target的并发量一上去客户端进程过一会儿就疯狂报这个错。用ss一看TIME_WAIT数量以万为单位。4.2 调整内核参数的边界与风险网上很多教程会教你这么调sysctl -w net.ipv4.tcp_tw_reuse1 sysctl -w net.ipv4.tcp_fin_timeout30 sysctl -w net.ipv4.ip_local_port_range1024 65535这里面tcp_tw_reuse是相对安全的一个选项但它只对“主动发起连接的一方”生效并且要求开启时间戳选项net.ipv4.tcp_timestamps默认是开着的。它做的事情是在发起新连接时如果某个TIME_WAIT四元组里的旧连接已经超过了安全期限并且时间戳比较能证明旧包不会乱入就允许这个四元组被复用。注意服务器端被动accept产生的TIME_WAIT靠它是回收不了的。tcp_fin_timeout调小可以缩短FIN_WAIT_2的等待时间对TIME_WAIT影响不大。ip_local_port_range扩大端口范围治标不治本端口从几万到几十万也只是把爆雷时间往后推。需要特别提醒的是tcp_tw_recycle这个参数早些版本的Linux内核里存在用来快速回收TIME_WAIT但它对NAT环境极不友好。同一个NAT后面可能有多个客户端它们到达服务器的source IP和端口不同但由于时间戳信息可能错乱服务器会误判“这个连接的时间戳倒退了”从而丢弃报文。后来Linux 4.12直接把tw_recycle删掉了所以新内核不需要、也不可能再去设置它。我个人的建议是不要一上来就调参数先看架构。如果是频繁短连接优先考虑用连接池、HTTP Keep-Alive、或者把服务改成复用长连接从根上减少TIME_WAIT的生成。调参只是给架构缺陷打补丁。4.3 CLOSE_WAIT积压排查思路比参数重要TIME_WAIT在客户端多CLOSE_WAIT在服务端多。如果一个服务端进程里有大量连接处于CLOSE_WAIT说明对端都已经关闭了但你这边一直没调用close()。排查路径分几步第一步先确认症状ss -tanp | grep CLOSE_WAIT | wc -l第二步找出这些CLOSE_WAIT连接对应的进程和协议端口ss -tanp | grep CLOSE_WAIT第三步用lsof查看具体进程的文件描述符lsof -nP -p pid | grep TCPCLOSE_WAIT堆积最常出现在这几类代码里读取循环没有正确处理“对端关闭”的事件read()返回0或-1后没有close()线程池拒绝新任务时旧连接的清理逻辑没走到用了连接池但没有配置空闲连接驱逐。我调过一个数据同步服务问题就是每次对端断开后worker线程里有个子分支直接return了close()写在分支外面导致所有被动关闭的连接全部积压在CLOSE_WAIT。找到之后在return之前补上close问题立刻消失。CLOSE_WAIT和TIME_WAIT最大的区别是TIME_WAIT是内核自己会超时清理的CLOSE_WAIT是应用层的责任如果代码不关socket它永远等你。5. 那些容易把人绕进去的细节重传、同时关闭、半关闭与粘包5.1 SYN重传与连接无法建立的定位思路三次握手的包都可能丢。客户端发的SYN丢了或者服务端回的SYNACK丢了客户端都会因为收不到SYNACK而重传SYN。重传间隔不是固定的1秒而是采用指数退避常见序列是1秒、2秒、4秒、8秒直到超过tcp_syn_retries默认6次。如果线上看到大量SYN重传我会先怀疑几个原因防火墙或安全组把SYN包丢了服务端的半连接队列满了服务端回包链路有丢包服务端根本没有监听该端口。定位方式很简单抓包看有没有SYNACK回来tcpdump -nn -i eth0 tcp port 8080如果只有客户端持续发SYN没有任何响应那问题大概率在中间的安全策略或者服务端的监听状态。如果服务端回SYNACK但客户端不重传且连接也没建立那就要看accept队列是否打满也就是前面说的全连接队列问题。5.2 同时打开与同时关闭教科书之外的对称场景三次握手的标准描述是“客户端主动、服务端被动”但实际上TCP允许两端同时发起连接。A发SYN给BB发SYN给A双方在收到对方SYN后就都进入SYN_RCVD或者SYN_SENT互相回SYNACK之后再回ACK。这种情况下报文数只有三次而不是四次状态流转路径也不同于标准单向流程。同理四次挥手也可能出现“同时关闭”场景双方同时给对方发FIN各自进入FIN_WAIT_1收到对方的FIN后回ACK进入CLOSING状态然后进入TIME_WAIT最后关闭。虽然这种场景在常规客户端-服务器架构里很少见但在P2P架构或双向主动连接的系统中会真实发生。5.3 shutdown和close的区别决定了半关闭是否优雅给写网络程序的朋友提个醒close()和shutdown()的语义完全不同。close()会立刻释放文件描述符如果此时内核的发送缓冲区里还有数据系统会尝试发送完剩余数据但同时关闭了读写两个方向。shutdown(fd, SHUT_WR)只关闭发送方向接收方向保留之后你还能read()对端发来的数据。shutdown(fd, SHUT_RD)则关闭接收方向。实际开发里向对端发送“我发完了”的FIN时如果只是想表达“我的数据发完了但还能收你的”就必须用shutdown(SHUT_WR)而不是close()。很多协议的半关闭模式比如HTTP的Connection: close服务端发送完响应后调用shutdown通知客户端没有更多数据就是靠这个机制实现的。如果直接close()且发送缓冲区还有数据内核会继续发送剩余数据然后才发FIN如果对端已经关闭且还有未读数据close()甚至可能触发RST而不是正常的四次挥手。RST会直接杀掉连接对端可能看到的是“Connection reset by peer”。在写关闭逻辑时先确认对端数据是否读干净、发送缓冲区是否清空再决定是shutdown还是close能少踩很多坑。5.4 粘包是应用层问题别怪TCP网上经常有人问“为什么三次握手之后发消息还是乱包”。这句话本身就混淆了两个层次三次握手解决的是连接建立的问题和消息边界一点关系都没有。TCP是流式协议它不关心你一次write()是不是对应一次read()。应用层两次write()的数据可能被合并到一个TCP段里发出去也可能一个大write()被拆成多个TCP段。你读到的数据是“流”不是“消息”。处理方案无非三种固定消息长度定长包在消息之间用特定分隔符比如HTTP用\r\nFTP用\r\n分割命令在消息头部加长度字段最通用在网络编程框架里最常见的就是4字节内容长度前缀加payload。这和三次握手的机制毫无关系是TCP之上的应用层协议设计问题。5.5 小心SO_LINGER这个双刃剑很多高性能服务会设置SO_LINGER来加速关闭连接。简单说如果设置SO_LINGER并让linger时间为0close()时内核不会发FIN进入四次挥手而是直接发RST连接立刻释放也不会有TIME_WAIT。看起来是不是很爽但代价是发送缓冲区里没发完的数据会被直接丢弃对端会收到一个RST而不是优雅的FIN。对端可能把RST当成异常导致业务数据丢失。谨慎地使用SO_LINGER可以解决一些TIME_WAIT堆积的极端场景但绝大多数情况下正确的做法是把连接做成可复用的长连接让TIME_WAIT自然发生、自然消失而不是强行绕过协议机制。我自己在实际工程里的体会是TCP协议最值得敬畏的部分不是握手和挥手本身而是这套状态机在异常场景下依然能保持自洽。抓包工具和ss命令只能让你看到状态真正能把线上连接问题定位清楚靠的是对这些状态背后“为什么要这样设计”的理解。如果你在排查网络问题时能根据一个连接停在FIN_WAIT_2还是CLOSE_WAIT马上判断出是主动方还是被动方没做好关闭逻辑TCP这块的基本功就算真的过关了。