TCP三次握手与四次挥手:从报文细节到线上排障实战
2026/9/15 18:13:53 网站建设 项目流程

写这篇文章之前,我先说个事。干后端这两年,我看过太多人背八股文:三次握手、四次挥手,画图画得比教科书还标准,一问“为什么四次不是三次”“TIME_WAIT留着干嘛”“线上全是CLOSE_WAIT怎么查”,当场卡壳。TCP这个协议最烦人的地方在于——你越觉得它基础,它越会在线上给你挖坑。今天这篇不画大图,不讲概念,就是把握手和挥手这两个过程从报文层面拆开,按真实事件顺序讲清楚,再告诉你这些机制到底保护了什么、失效时会看到什么现象,最后用手上的工具给你演示一遍从建立到断开的状态流转。中间会穿插大量一线排查经验,看完你至少能解决掉一类线上连接问题。

1. 三次握手:从SYN到ACK,连接建立的完整链路

1.1 为什么是“握手”:连接建立要解决的三件事

很多人第一次接触TCP,听到“握手”这个说法会觉得很抽象。其实换个场景就明白了:

假设你和另外一个人在完全陌生的环境里通话,电话一接通,你必须确认三件事——你说话对方能不能听见,对方说话你能不能听见,以及双方是否都在用同一套“语言规则”来理解这段对话。TCP的连接建立,本质上就是干这件事。

从协议层面拆解,三次握手要完成三个目标:

  • 确认双方的收发能力。A要证明自己能发能收,B也要证明自己能发能收。
  • 同步初始序列号(ISN)。TCP是面向字节流的,每个字节都有编号,双方必须知道对方的起始编号,后续数据才能按序拼接。
  • 协商选项参数。比如MSS(最大报文段长度)、窗口缩放因子等,这些都在握手报文里捎带过去。

这三件事少一件,连接都不算真正建立。这里有个很多人忽略的细节:第一次握手(客户端发SYN)只能证明“客户端能发、服务端能收”;第二次握手(服务端回SYN+ACK)才能证明“服务端能发、客户端能收”,同时让客户端确认自己发的SYN确实到了;但此时服务端还不知道自己发的SYN-ACK有没有到,所以必须由客户端再回一个ACK,第三次握手的作用说白了就是让服务端确信自己的报文能到达客户端

这个逻辑链必须记清楚:三握手里没有一个步骤是多余的。

1.2 交互过程逐包拆解:SYN、SYN-ACK、ACK 各自携带什么

过程不多说,直接上抓包视角的完整流程。假设客户端IP是192.168.1.10,服务端是192.168.1.20,端口8080:

第1步:客户端 -> 服务端(SYN)

TCP 192.168.1.10:51000 -> 192.168.1.20:8080 [SYN] Seq=1000000 Win=64240 Len=0 MSS=1460

客户端状态从CLOSED进入SYN_SENT。Seq是一个随机生成的初始序列号,不是从0开始——这是为了防预测和防历史报文串扰,老版本内核如果使用可预测的序列号,很容易被伪造RST攻击。

第2步:服务端 -> 客户端(SYN+ACK)

TCP 192.168.1.20:8080 -> 192.168.1.10:51000 [SYN, ACK] Seq=2000000 Ack=1000001 Win=65535 Len=0 MSS=1460

服务端收到SYN后,状态从LISTEN变成SYN_RCVD。它要做两件事:一是为这个连接分配传输控制块(TCB),也就是内核里那棵socket树上的节点;二是回一个SYN+ACK,其中Ack=1000001,表示“我收到了你的序列号1000000,下个字节请从1000001发给我”,同时把自己的序列号2000000告诉客户端。

这里的Ack为什么是Seq+1?因为SYN报文本身要占一个序号,它属于流中的一个控制字节,后面的数据字节才能从1000001接着排。

第3步:客户端 -> 服务端(ACK)

TCP 192.168.1.10:51000 -> 192.168.1.20:8080 [ACK] Seq=1000001 Ack=2000001 Win=65535 Len=0

客户端收到SYN+ACK后,状态进入ESTABLISHED,回一个ACK。服务端收到这个包后,也从SYN_RCVD进入ESTABLISHED。此时握手完成,双方可以开始正常收发数据。

以上三段报文在wireshark里看就是三行,但要注意第2步的报文是SYN和ACK两个标志位同时置1,这不算“四次握手”,这是把两步合并成一步的优化——因为SYN和ACK本来就是两个方向独立的确认,既然能在同一个报文里装下,就没必要拆开发两次。

1.3 用抓包工具亲眼验证一次握手

我不建议只看截图,自己在服务器上抓一次会比什么都有用。最简单的方式,用Python起一个迷你服务端,再用curl发起请求:

import socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 8080)) server.listen(5) conn, addr = server.accept() print(f"connection from {addr}") data = conn.recv(1024) conn.sendall(b"hello") conn.close()

同一台机器上另开两个终端,一个跑这个服务端,另一个开抓包命令:

tcpdump -i any tcp port 8080 -nn -S

然后在第三个终端执行:

curl -v http://127.0.0.1:8080/

你会看到类似下面的输出:

21:31:04.123456 IP 127.0.0.1.51000 > 127.0.0.1.8080: Flags [S], seq 1000000 21:31:04.123489 IP 127.0.0.1.8080 > 127.0.0.1.51000: Flags [S.], seq 2000000, ack 1000001 21:31:04.123491 IP 127.0.0.1.51000 > 127.0.0.1.8080: Flags [.], ack 2000001

第一行[S]是SYN,第二行[S.]是SYN+ACK,第三行[.]是纯ACK。实操的时候有个小经验:如果抓到的第一个包不是你预期中的SYN,而是[R](RST)或者[RA](RST+ACK),先别去看协议栈,大概率是服务端端口根本没监听,或者防火墙把SYN包丢了。我遇到过很多次“连不上”的问题,最后都是这一步定位出来的。

另外补一个内核参数:net.ipv4.tcp_syn_retries,它控制SYN的重发次数,默认配置在不同内核版本可能不同,常见值是5或6。如果客户端发出SYN后一直收不到SYN+ACK,会按指数退避重传,直到超过这个次数才返回超时。你排查连接卡在SYN_SENT时,可以先确认下这个值,免得傻等太长时间。

1.4 握手阶段的资源开销:半连接队列与全连接队列

三次握手不是只发三个包就完了,内核还要为连接分配内存。服务端收到SYN后建立的连接叫半连接,存放在半连接队列(syn queue);收到第三个ACK后连接转为全连接,进入accept queue,等待应用层调用accept()取走。

这里有两个队列,很可能成为线上故障点:

  • net.ipv4.tcp_max_syn_backlog:半连接队列长度。
  • net.core.somaxconn和 listen() 的 backlog 参数:全连接队列长度,两者取较小值。

如果应用层accept()不够快,全连接队列会满,新来的连接即使完成了三次握手,也会被内核丢掉或者延迟accept。现象是客户端显示连接已建立(三次握手完成了),但发数据没响应。很多人这时去查防火墙、查网络,绕了一大圈,结果问题出在业务代码处理连接的速度上。

排查手段很简单:

ss -lnt

看Recv-Q和Send-Q两列。在LISTEN状态下,Recv-Q表示accept queue当前的连接数,Send-Q表示队列最大长度。如果Recv-Q一直压着接近Send-Q,说明上层服务消化连接的速度跟不上。

2. 四次挥手:断开连接为什么比建立多一次

2.1 挥手和握手的不对称性

建立连接时,双方其实处于“都对对方一无所知”的状态,所以核心是同步。但断开连接时,情况完全不一样——两个方向的数据发送是互相独立的,A可能已经发完所有数据,B的发送却还没结束

打个比方。你在微信和别人聊天,你单方面说“我要下线了”,对方可能还有后半段话没打完。合理的方式是:你告诉对方你要关了,对方回复“收到,你等我一下”,然后等他把话说完,再主动告诉你“我也说完了,可以关了”,最后你确认一句“好”。整个过程,每一方都需要独立地表达一次“我说完了”并收到对方的确认。这就是四次挥手比三次握手多一次的根源。

2.2 四次挥手的完整报文序列

拆开看标准流程。假设客户端主动关闭连接:

第1步:主动方(客户端) -> 被动方(服务端):FIN

TCP 192.168.1.10:51000 -> 192.168.1.20:8080 [FIN, ACK] Seq=1000100 Ack=2000100

FIN标志位置1,表示客户端的发送方向关闭了,不再发送新的数据。客户端状态从ESTABLISHED进入FIN_WAIT_1。这里有个不少人疑惑的点——为什么FIN通常带着ACK?因为挥手前连接里可能有尚未确认的数据,FIN报文顺手把之前收到的数据Ack一下,是正常的。

第2步:被动方 -> 主动方:ACK

TCP 192.168.1.20:8080 -> 192.168.1.10:51000 [ACK] Seq=2000100 Ack=1000101

服务端收到FIN后,回一个ACK,告诉客户端“你的FIN我收到了”。服务端状态进入CLOSE_WAIT,客户端状态进入FIN_WAIT_2。但注意,此时服务端仍然可以继续往客户端发数据。TCP是双向的,客户端关闭了发送方向,不意味着服务端也必须立刻关闭自己的发送方向。

第3步:被动方 -> 主动方:FIN

服务端把剩余数据发完后,发送FIN,表示“我也发完了,可以关了”:

TCP 192.168.1.20:8080 -> 192.168.1.10:51000 [FIN, ACK] Seq=2000200 Ack=1000101

服务端状态从CLOSE_WAIT进入LAST_ACK。

第4步:主动方 -> 被动方:ACK

TCP 192.168.1.10:51000 -> 192.168.1.20:8080 [ACK] Seq=1000101 Ack=2000201

客户端确认了这个FIN,回复ACK。服务端收到后进入CLOSED,客户端则进入TIME_WAIT,等待2MSL后彻底关闭。

再次强调那个理解上的关键,并不是说FIN一定只能一个单独报文发,也不是说每挥手就一定是4个报文排得整整齐齐。实际上第2步的ACK和第3步的FIN经常合并成一个报文发出(尤其是服务端已经没有待发数据的情况下),此时从报文数量上看是3个,但状态流转依然是“两个独立的关闭方向各自确认”,逻辑上仍然是四次步骤。

2.3 TIME_WAIT:为什么要等2MSL

四次挥手最容易让人困惑的是最后那个TIME_WAIT状态,客户端明明已经收到服务端的FIN并回了ACK,为什么不能立刻进入CLOSED,非要等2MSL(Maximum Segment Lifetime,报文最大生存时间)?

两个原因,缺一不可:

第一,保证被动方能够收到最后一个ACK。客户端发的第4步ACK可能丢失,服务端在LAST_ACK状态下收不到ACK,会重发FIN。客户端如果已经进入CLOSED,就没人回应这个重发的FIN,服务端永远关不掉这个连接。等2MSL,是为了给重传留足时间窗口。

第二,让旧连接的报文在网络中自然消亡。2MSL约等于报文在网络里往返一趟再返回来能存活的最长时间。等待这个时间过后,连接上的所有孤儿报文都消失了,之后再建立一个使用相同四元组的新连接,就不会把老连接里的残留数据当成新连接的数据。

Linux上TIME_WAIT状态一般持续60秒,这个值取决于系统对MSL的设定(常见默认MSL是30秒,2MSL即60秒)。生产环境里,如果短连接非常多而且由同一端主动关闭,TIME_WAIT会堆积得很恐怖,我见过一台机器几万个TIME_WAIT的。后面第4章会讲怎么处理。

2.4 半关闭:一个被忽略但真实存在的用法

四次挥手之所以能拆成中间两段,是因为TCP支持半关闭(half-close),也就是一端关闭发送后另一端仍能继续发数据。这个能力在实际应用里有重要价值。

最典型的场景是HTTP的Connection: close 之后,服务端一般会先发完响应数据再主动关闭连接,此时“客户端发FIN -> 服务端继续发数据 -> 服务端再发FIN”这个顺序就体现了半关闭的意义。在socket编程里,体现为shutdown()close()的区别:

  • shutdown(sock, SHUT_WR):只关闭发送方向,还能继续收数据。
  • close(sock):彻底释放socket描述符,收发两个方向都不能用了。

如果你在写文件传输类程序,发送方发完数据后调用shutdown而不是close,能让接收方及时感知到“数据结束了”(read返回0),但接收方还能把结果回传。这个细节在面试和实际故障排查里都容易踩,我确实见过有同事用close导致对端收到RST,文件传输直接中断排查半天的。

3. 协议设计边界:为什么不能是两次握手或三次挥手

3.1 两次握手的致命缺陷:历史报文串扰

先思考一个场景:客户端发起TCP连接,发出的第一个SYN因为网络拥塞被卡在路上很久,客户端超时后重新发了一个新的SYN建立起了连接,双方通信完毕并正常关闭。结果,那个被卡住的旧SYN此时才到达服务端。

如果TCP只有两次握手,会发生什么?服务端收到这个迟到的旧SYN,会认为客户端要建立新连接,于是分配TCB、进入连接状态,并回一个SYN+ACK。可客户端根本不存在这样一个新的连接,不会理它。服务端就白白浪费一个连接资源,一直挂着直到超时。

三次握手怎么解决这个问题?因为客户端只有在确实发起连接时才会响应服务端的SYN+ACK,也就是第三个ACK才是关键确认。服务端在收到ACK之前都处于SYN_RCVD,不进入ESTABLISHED,不会把资源当成正式连接。如果收到的是迟到旧SYN对应的SYN+ACK,客户端不会回ACK,服务端等不到第三个包就会释放半连接。

网上有个很流行的说法是“三次握手的核心是确认双方收发能力”,我觉得这只是表层的解释,更本质的原因是通过一个额外的往返确认,避免历史报文对新数据的污染。这一点在做协议设计时很关键,不光是TCP,很多自研协议在握手阶段也会加入自己的“挑战-应答”机制,底层逻辑是一样的。

3.2 握手的步数能不能再压缩:为什么不把ACK捎带进数据里

有朋友会问:既然第三次握手只是ACK,能不能省略掉——客户端收到SYN+ACK后直接开始发数据,反正服务端看到第一个数据包就知道客户端收到了自己的SYN?

设计上的答案很明确:不行。TCP不是磁带的“一发不可收拾”,它要保证每个字节都被确认,连接建立这个动作更不能依赖“后续数据包”隐含确认。如果客户端第一个数据包丢了,服务端根本不知道对方有没有成功建立连接,连接状态会一直悬着。更麻烦的是,服务端在收到数据的瞬间其实是”未收到ACK“的状态,此时如果数据包因为乱序或者丢失提前到达,服务端会莫名其妙认为连接建立了,从而分配资源,这又回到上一节的资源浪费问题。

说到底,TCP追求的是一种“确定性的连接状态”,三次握手里没有任何一步是依赖猜测或者捎带推断的,每一步都有明确的ACK作为完成标志。这也是它和UDP带外传输的本质不同。

3.3 如果挥手只挥三次会怎样

先别急着回答,我的建议是:这个问题要先区分“报文数量”和“状态转移次数”两个概念。现实中确实经常出现只看到3个报文的挥手过程,但状态依然是4次转移,原因是第2步ACK和第3步FIN被合并了。所以“三次挥手”,准确说应该是“把两个方向关闭确认的其中一个合并掉”,这在有半关闭的场景下做不到逻辑自洽。

假设一种极端情况:客户端说“我不发了”(FIN),服务端立刻回一个“我也不发了”(FIN+ACK),客户端回ACK,看起来是三次。但服务端此时真的没有数据要发了吗?不一定。如果服务端还有数据没发完,这个合并就失败了,必须拆开。标准四挥手的设计不假设双方数据发送同时结束,所以独立保留两次FIN。这也是为什么很多教科书在讲挥手时,一定会强调CLOSE_WAIT和FIN_WAIT_2这两个中间状态,它们的存在就是给“服务端发送未完数据”留的时间。

3.4 SYN Flood:攻击者看准的就是握手的不对称性

既然聊到握手设计,顺便讲下网络安全里一个经典问题,SYN Flood。

半连接是TCP设计里相对昂贵的东西:服务端收到SYN后,会为它分配TCB,虽然不像完整连接那样开窗口缓冲区那么大,但一样要消耗内存和CPU。攻击者如果持续伪造源IP发送SYN,不完成第三次握手,服务端的半连接队列会被撑爆,正常的SYN进不来,这就是分布式拒绝服务的经典打法。

防护手段说几个常见的:

  • 调大tcp_max_syn_backlog,给半连接队列扩容。
  • 开启net.ipv4.tcp_syncookies。开启后服务端在队列满时不再保存半连接信息,而是把序列号通过hash算出来塞进SYN+ACK的Seq里,等客户端回ACK时再通过Ack值还原信息。代价是丢失了一部分TCP选项协商能力,但在被攻击时这是保命手段。
  • 利用SYN Proxy(四层负载均衡设备常见功能),由代理去完成握手验证,验证通过的连接才转发给后端。

ss -lnt看的时候如果发现某个端口Recv-Q(即半连接数量)非常大,而且源地址五花八门、数量巨大,基本就要往这个方向怀疑了。

4. 状态流转与线上排查:把协议知识变成排障能力

4.1 一张状态表,定位连接卡在哪个环节

TCP的所有状态变化都可以用netstat或ss观察到,学TCP如果不是为了排障,意义就少了一半。排查的第一步,是你打开ss -ant能立刻知道每一行连接正处于什么状态。

状态含义常见出现位置
LISTEN服务端在监听端口accept队列等待中
SYN_SENT客户端发了SYN,等SYN+ACK客户端连接外网不通时
SYN_RCVD服务端收到SYN,等ACK半连接队列,疑似SYN Flood时大量出现
ESTABLISHED连接正常建立数据收发中
FIN_WAIT_1主动方发了FIN,等ACK短暂出现,很难抓到
FIN_WAIT_2主动方收到ACK,等被动方FIN对端长时间不发FIN时会堆积
CLOSE_WAIT被动方收到FIN,等应用层close()业务代码没关socket时大量堆积
LAST_ACK被动方发了FIN,等最后的ACK很少见,通常一闪而过
TIME_WAIT主动方等2MSL短连接高并发场景常见

这张表我打印过很多次,每次排查连接相关故障,先看状态分布,基本能缩小一半怀疑范围。最典型的一类:一大片CLOSE_WAIT,不用多想,就是业务层没释放连接。

4.2 CLOSE_WAIT 堆积:最常见也最好定位的服务端问题

CLOSE_WAIT堆积的原因非常确定——对端发来FIN,内核通知应用层“对端关闭了”,但应用层没有调用close()把本端的socket也关掉

常见的坑有两个,都是代码层面的:

一个是读取到EOF后忘记关闭。比如你封装了一个读数据函数,recv返回0或者read返回-1就跳出循环,却忘了关闭socket描述符。

另一个是异常路径忘记关闭。try块里正常关闭了,catch块里没有处理资源释放。这种最隐蔽,平时测试很难触发,一上线流量一冲就一堆CLOSE_WAIT。

排查命令:

ss -ant | grep CLOSE_WAIT | wc -l ss -antp | grep CLOSE_WAIT

第二行能显示进程PID,然后用lsof看具体是哪些fd没关,再回到代码里去对照。如果CLOSE_WAIT数量一直涨,不用怀疑网络和内核,问题一定在你的进程内部。

4.3 TIME_WAIT 过多:端口耗尽与优化策略

TIME_WAIT多半出现在主动关闭连接的一方。高并发短连接场景里(比如HTTP服务自己就是主动断开方),TIME_WAIT堆积几百上千是常事,通常问题不大,最多占点内存。但极端情况下,比如客户端或代理机器上也堆了大量TIME_WAIT,可能出现端口不够用——因为TCP四元组里的客户端端口是有限的,连接不能无限复用。

处理方式我按优先级排一下:

  1. 最推荐的方案:改造业务层,减少短连接,用长连接池。连接复用了,TIME_WAIT自然少。
  2. 开启net.ipv4.tcp_tw_reuse=1。注意:这个参数的作用是让客户端在发起新连接时,如果本地有TIME_WAIT状态的连接且四元组不冲突,可以直接复用该端口。它只对发起连接的一方有效,服务端别指望靠它解决问题。
  3. 别乱开tcp_tw_recycle。这个参数在新内核里已经不推荐甚至移除了,它依赖时间戳,在NAT后面会坑很多人,因为不同的客户端时间戳可能不一致,导致合法连接被丢弃。我碰到过不止一次,开了tw_recycle之后线上间歇性连接失败,关掉就恢复正常。
  4. 调整net.ipv4.ip_local_port_range扩大可用端口范围,治标不治本,但能应急。

一个微妙的地方是:TIME_WAIT其实是协议为了保证可靠性而付出的代价,未必全是坏事。之前在排查一个偶发连接异常问题时,我甚至怀疑过是TIME_WAIT关闭得太快导致旧报文复用了新连接。所以处理这个问题时,先确认它到底有没有影响业务,再动手优化,别为了指标好看把协议的保护机制给弄没了。

4.4 面试常追问:三个值得深挖的细节

最后把面试和日常理解中很容易出错的几个点拎出来说一下。如果你要对同事讲或者面试,这三个点讲清楚比背完整流程更能体现水平。

第一,为什么客户端最后进入TIME_WAIT而不是服务端?因为主动关闭方是客户端,主动方发送最终的ACK,它需要承担2MSL等待,被动方收到ACK后就可以进入CLOSED。如果你在服务端看到大量TIME_WAIT,说明服务端自己在主动断开连接,这种场景常见于HTTP短连接模式。

第二,初始序列号为什么不从0开始?防猜,也防历史报文串扰。如果序列号可预测,攻击者可以构造一个合法的SYN或RST包把连接干掉,这属于协议栈安全的基本要求。所以现代内核里ISN通常是用时间相关的随机算法生成。

第三,握手和挥手报文里的Seq/Ack换算关系。SYN和FIN标志位各占一个序列号,所以Ack值总是对方的Seq+1。很多文档里说“ACK是Seq+1”,但这段表述在纯数据报文里并不成立——纯ACK报文的Ack等于对方的下一个字节序号,不等于对方本包Seq+1。只有SYN和FIN会占号,这算是细节中的细节,理解了它,你就能看懂wireshark里所有报文的编号逻辑。

最后的个人建议:别光看文章和图,找两台虚拟机甚至两个容器,在一台机器上tcpdump,在另一台上telnet或curl,把三次握手和四次挥手各抓一遍。看着终端里一行一行刷出来的报文,再去对状态流转,很多背不下来的东西就会变成肌肉记忆。之后遇到连接挂死、端口耗尽、连接泄漏,你也不会只想到重启服务,而是先打开ss看一眼状态分布,很多问题五分钟内就能定位了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询