☰
TCP三次握手与四次挥手全解析:从状态机到抓包排错实战
2026/10/21 13:26:50 网站建设 项目流程

1. 为什么是三次握手:连接从来不是“连上”那么简单

很多人第一次学TCP的时候,会把“三次握手”背成一句顺口溜:SYN、SYN-ACK、ACK,然后就算会了。但等真正到了排查线上问题的时候,才发现自己连tcpdump抓到的包都看不懂,更别说理解什么SYN_SENT、TIME_WAIT这些状态到底在说什么。

我当年也是这么过来的。真正让我把三次握手吃透的,不是背状态机,而是有一次帮朋友排查一个“服务端偶尔连不上”的问题。抓包抓了半天,发现一堆SYN_RECV堆积,然后才意识到:三次握手根本不是“三次打招呼”,而是两台机器在一个不可靠的网络上互相确认“我准备好收你数据了”的过程。每一包都有它存在的必要,多一次浪费,少一次出事。

1.1 TCP连接的本质:双向状态的同步

先说一个我踩过的认知误区:TCP连接不是什么物理链路。它不像一根电话线,拨通了就一直通着,而是双方各自在内核里维护一个连接状态。这个状态里记录了对方的IP、端口、序列号、窗口大小、拥塞参数等等。

三次握手的本质,就是让客户端和服务端各自确认两件事:

  • 我发给你的包,你能收到;
  • 你发给我的包,我也能收到。

注意,是两个方向都要确认。这就是为什么不能只来一次握手。如果只发一个SYN就算建立连接,那客户端根本不知道服务端有没有收到自己的请求,服务端也不知道客户端是不是真的存在。网上有个很形象的类比:两个人隔着墙喊话,至少要喊三个来回,才能确定彼此都能听见。第一次你说“喂”,第二次我回“听到了,你也听听我说话”,第三次你再说“我也听到了,咱俩能聊了”。这个类比虽然简化了,但方向是对的。

1.2 三次握手每一步在做什么

三次握手里面的三个报文,每个都承担了不同的确认职责,并且携带了后续通信要用到的关键参数。我习惯把它们分成三句话来讲:

第一包,客户端发SYN报文。这一包主要干了三件事:宣告自己的初始序列号ISN(Initial Sequence Number),把SYN标志位置1,告诉服务端“我要和你建立连接”。这个SYN包还顺便带上了客户端的窗口大小、是否支持SACK等选项,本质上是一次“自我介绍”。

第二包,服务端回SYN-ACK。这一包同时做了两件事:确认收到了客户端的SYN(通过ACK号等于客户端的ISN+1来表示),同时发送自己的SYN并宣告自己的ISN。所以它叫SYN-ACK,两个标志位一起置1。

第三包,客户端回ACK。这一包的核心意义是:确认收到了服务端的SYN,ACK号等于服务端的ISN+1。为什么这包必须存在?因为如果没有这包,服务端永远不知道自己发的SYN-ACK有没有到客户端手上。它就会一直等,等到超时重传,然后大量连接积压在半连接队列里,表现为“连不上”。

这三包完整走完,客户端知道“我能发给服务端,服务端能发给我”,服务端也知道“我能发给客户端,客户端能发给我”,双向都通了,连接才算真正建立。

1.3 为什么不能是两次握手

这是面试官最喜欢追问的一个点,也是实际排错中理解问题严重性的关键。如果省掉第三包,只保留SYN和SYN-ACK,会发生什么?

问题出在已失效的连接请求上。假设客户端发了第一个SYN,因为网络拥堵,这个包在链路里卡了很久。客户端等不到回复,超时重传了第二个SYN,这次服务端回了SYN-ACK,连接正常建立,业务跑完,连接关闭。结果这时候,那个卡了很久的旧SYN又姗姗来迟,飘到了服务端。

两次握手的情况下,服务端收到这个迟到的旧SYN,会误以为客户端要建立新连接,于是回一个SYN-ACK,然后傻等客户端的数据。问题是客户端知道自己根本没有发起新连接,自然不会理这个SYN-ACK。服务端就白白占着一个半开连接的资源,直到超时。如果这种迟到的SYN特别多,服务端的半连接队列很快就被塞满,正常用户的连接反而进不来了。

三次握手就好在:服务端收到旧SYN后回SYN-ACK,但客户端不认账,不回最后的ACK,服务端就永远不会进入ESTABLISHED状态,旧连接自然超时释放。这是三次握手最重要的一个保护作用。

2. 三次握手的完整拆解:状态机、报文与参数细节

说实话,前面讲的是概念,这节才是真正能用起来的东西。做网络排错,你得对三次握手过程中双方的状态变化了如指掌。看到一条连接卡在哪了,就得立刻知道它是在等什么包。

2.1 三次握手的完整状态转移

客户端这边的流程是:一开始什么都没有,调用connect()之后进入SYN_SENT状态,发出SYN包。收到服务端的SYN-ACK后,进入ESTABLISHED状态,同时发出最后的ACK。

服务端这边的流程是:监听状态下,收到客户端的SYN,从LISTEN进入SYN_RECV状态(有的资料叫SYN_RCVD),回复SYN-ACK。收到客户端的ACK之后,从SYN_RECV进入ESTABLISHED状态。

我把这个状态转移画成了一张简化的流程,实际操作中你抓包对照看会更直观:

客户端 服务端 | | | -------- 1. SYN (seq=x) ----------> | | SYN_SENT | LISTEN -> SYN_RECV | <-------- 2. SYN+ACK (seq=y, ack=x+1) | | ESTABLISHED | | -------- 3. ACK (seq=x+1, ack=y+1) -> | | | SYN_RECV -> ESTABLISHED

注意看,x是客户端的初始序列号,y是服务端的初始序列号。确认号ack永远是对方序列号加1,这是TCP确认机制里最容易理解也最容易弄混的地方:ack表示的是“下一个我期望收到的字节序号”,所以收到seq=x的包,回ack=x+1。

2.2 报文里那些容易被忽略的字段

抓包的时候,我不建议只看标志位就完事。三次握手的报文里,有几个字段是后续传输质量的根基,值得仔细看:

第一个是ISN。初始序列号不是从0开始的,而是由双方各自随机生成(早期的实现是时间相关)。为什么必须随机?如果序列号可预测,攻击者可以伪造序列号插入数据流,这就是TCP会话劫持的一个前提条件。现代系统默认开启tcp_timestamps,序列号还会和时间戳联动,具体实现随内核版本有差异。

第二个是MSS(Maximum Segment Size)。这是SYN报文里最重要的选项之一,表示“我能接受的最大报文段大小”。协商规则是取双方MSS中的较小值,常见值是1460字节,因为以太网MTU是1500字节,减去IP头20字节再减去TCP头20字节,正好1460。如果MSS协商不合适,后续就可能因为分片导致性能问题。

第三个是窗口大小Window。SYN包里会带上接收窗口的初始值,告诉对方“你最多可以一次发我这么多数据,不用等我ACK”。这是实现流量控制和后续窗口缩放(Window Scale)协商的基础。

第四个是SACK Permitted。选择性确认功能是否启用,也在这个阶段协商。开启SACK之后,如果中间丢包,接收方可以精确告诉发送方“哪些段丢了,哪些段收到了”,避免发送方傻乎乎地重传后面所有数据。

2.3 三次握手中几个容易忽略的性能参数

实战中要关注的还有两个内核参数:net.ipv4.tcp_syn_retries和net.ipv4.tcp_synack_retries。

这两个参数控制的是SYN和SYN-ACK的超时重传次数。默认值一般是5到6次。这个重传策略非常有讲究:重传间隔是退避的,如果第一次重传在1秒后,第二次就是2秒,第三次4秒,以此类推。而且这个超时值会跟随系统的RTT动态调整,不是乱来的。如果你发现客户端一直卡在SYN_SENT状态,且反复重传,要么是网络不通,要么是服务端根本没在监听端口。

注意:SYN_SENT和SYN_RECV这两个状态是排查连接问题的分水岭。客户端卡在SYN_SENT,问题多半出在网络路径或服务端防火墙;服务端大量SYN_RECV堆积,多半是半连接队列(tcp_max_syn_backlog)溢出或者被SYN Flood攻击了。

3. 四次挥手:拆开后才发现每一包都在解决具体问题

三次握手解决的是“建立”的问题,四次挥手解决的是“关闭”的问题。为什么要多一次?我见过很多解释,最靠谱的还是从“全双工”这个角度来讲。

TCP连接是全双工的,意思是客户端发数据给服务端,同时服务端也可以在发数据给客户端,这两个方向是独立工作的。那么关闭连接的时候,每个方向都需要单独关闭:我和你的上传通道要关,你和我的上传通道也要关。每个方向各需要一次FIN加一次ACK,一共四次。

3.1 四次挥手的完整流程

主动关闭方发出FIN,表示“我没有数据要发给你了”。但这时候被动关闭方如果有数据要发,还是可以继续发的,这个“半关闭”状态其实就是TCP的一大特性。

被动关闭方收到FIN之后,回一个ACK,然后进入CLOSE_WAIT状态。注意,被动方这时候要做的是:把缓冲区里剩下的数据尽量发完,然后应用程序调用close(),发出自己的FIN。

主动关闭方收到这个FIN之后,回最后一个ACK,然后进入TIME_WAIT状态,等待2个MSL(Maximum Segment Lifetime)时间之后才真正关闭。

完整流程如下:

客户端(主动关闭) 服务端(被动关闭) | | | -------- 1. FIN (seq=u) ----------> | | FIN_WAIT_1 | ESTABLISHED -> CLOSE_WAIT | <-------- 2. ACK (ack=u+1) --------- | | FIN_WAIT_2 | | | (处理剩余数据,调用close) | <-------- 3. FIN (seq=v) ----------- | | CLOSE_WAIT -> LAST_ACK | | TIMEWAIT | | -------- 4. ACK (ack=v+1) ----------> | | | LAST_ACK -> CLOSED

如果抓包的时候看到被动关闭方卡在CLOSE_WAIT状态,十有八九是应用程序没调close(),最常见的原因就是代码里的资源没释放,导致连接一直挂着。一条两条无所谓,多了就是文件描述符泄漏。

3.2 为什么主动关闭方要等2MSL

这是整个四次挥手里面最抽象、也最值得讲清楚的一个点。所谓MSL,是报文段在网络里的最大生存时间,协议规范建议值一般是30秒到2分钟。实际Linux系统里,tcp_fin_timeout配合相关宏定义,常见值是60秒,对应2个MSL就是2分钟上下。

客户端发完最后的ACK之后,进入TIME_WAIT状态等2MSL,原因有两个:

第一个是为了保证最后一个ACK能到达对方。如果这个ACK丢了,服务端长时间收不到,会超时重发FIN。客户端在TIME_WAIT期间如果收到重发的FIN,就知道自己刚才那个ACK丢了,于是再补发一个ACK。这样两边就能干净利落地关闭。

第二个原因是让旧的报文段在网络中自然消亡。设想一下:如果一条连接的端口和四元组(源IP、源端口、目的IP、目的端口)被立即复用,而网络里还有一些旧连接的数据包在游荡,新连接就会收到这些“穿越”过来的脏数据。等2MSL,就是确保旧包都消失在网络中,不会污染新连接。

3.3 TIME_WAIT的代价和应对

听上去很合理,但实际操作里TIME_WAIT是很多人的心头恨。因为高并发的短连接场景下,主动关闭方会出现大量TIME_WAIT状态的连接,比如常见的反向代理、爬虫程序。每个TIME_WAIT连接会占用一个四元组和一小块内核内存,如果端口被占满,就没法发起新的连接了。

我见过有人一看到TIME_WAIT多就急着调参数,其实得先分清情况。TIME_WAIT多,常见应对手段包括:

  • 打开net.ipv4.tcp_tw_reuse,允许在发起新连接时复用处于TIME_WAIT的连接的端口(仅对客户端有效)。
  • 调整net.ipv4.tcp_fin_timeout,缩短TIME_WAIT的时长。
  • 更根本的办法是调整应用协议,让服务端主动关闭连接,把TIME_WAIT的压力分摊出去。

注意:tcp_tw_recycle这个参数曾经也被推荐,但它已经因为在NAT环境里会引发严重问题,在较新的Linux内核里已经被移除了。如果调大并发量,除非是纯公网环境且完全清楚后果,否则别碰它。我一直建议:宁可多留一点TIME_WAIT,也不要用一个可能导致数据错乱的参数。

4. 用抓包把三次握手和四次挥手“看”清楚

前面讲的都是理论,但“纸上得来终觉浅”。我自己学TCP最有收获的一个阶段,就是反复抓包,把抓到的报文逐个字段对照RFC看。抓包这件事,难度其实不高,关键是要知道过滤什么、看什么、每一个字段代表什么意思。这里我分享一套我常用的操作。

4.1 抓包前的准备

最省事的方式是在本机用tcpdump抓回环接口,因为这种流量不会经过物理网卡,也不用担心混杂模式等问题。如果你想抓的是两台机器之间真实的交互,比如访问一个Web服务,那就得看部署位置了。

我建议起步阶段这样抓:在本机起一个最简单的HTTP服务,然后本机访问本机。比如用Python起一个服务,在另一个终端抓包:

# 终端1:起一个简单HTTP服务 python3 -m http.server 8080 # 终端2:抓回环接口上8080端口的包,并且不做DNS反向解析 tcpdump -i lo port 8080 -n -S -vvv

这个命令里-n是不要解析主机名,-S是把序列号显示为绝对值而不是相对值,-vvv是输出尽量详细。说实话,-S这个参数是我自己用的时候踩过坑才记住的,因为Wireshark默认显示相对序列号,第一次分析经常对不上号。

然后在另一个终端里发起一次HTTP请求:

curl http://127.0.0.1:8080/

完了之后回来看终端2的输出,应该就能看到标准的建连、请求、回应、断开过程。

4.2 三次握手的逐包分析

tcpdump输出的SYN包大概长这样:

IP 127.0.0.1.53842 > 127.0.0.1.8080: Flags [S], seq 1686224283, win 65495, options [mss 65495,sackOK,TS val 44737467 ecr 0,nop,wscale 7], length 0

这里我习惯重点确认四个信息:Flags [S]表示这是SYN、seq是客户端的初始序列号、win是通告窗口大小、options里面能看到MSS和SACK协商。

然后看服务端的回包:

IP 127.0.0.1.8080 > 127.0.0.1.53842: Flags [S.], seq 2841863794, ack 1686224284, win 65483, options [mss 65495,sackOK,TS val 44737467 ecr 44737467,nop,wscale 7], length 0

注意看两点:标志位是[S.],表示SYN加ACK;ack等于上一包seq加1,也就是1686224283 + 1 = 1686224284。这说明服务端准确收到了客户端的SYN。

最后是客户端的确认包:

IP 127.0.0.1.53842 > 127.0.0.1.8080: Flags [.], ack 2841863795, win 512, options [nop,nop,TS val 44737467 ecr 44737467], length 0

只有., 表示纯ACK,ack等于服务端seq加1。到这一步,三次握手完成。我每一步都盯着seq和ack的递增关系看,看多了之后对TCP的确认机制就有肌肉记忆了。

4.3 四次挥手的逐包分析

HTTP请求返回后,连接断开。抓包同样能看到四个包。我截了一次典型的交互流程:

22:15:00.01 IP 127.0.0.1.53842 > 127.0.0.1.8080: Flags [F.], seq 1686224445, ack 2841864022 22:15:00.01 IP 127.0.0.1.8080 > 127.0.0.1.53842: Flags [.], ack 1686224446 22:15:00.01 IP 127.0.0.1.8080 > 127.0.0.1.53842: Flags [F.], seq 2841864022, ack 1686224446 22:15:00.01 IP 127.0.0.1.53842 > 127.0.0.1.8080: Flags [.], ack 2841864023

这里就非常直观了:客户端发FIN并带上最后的seq,服务端回复ACK,然后服务端处理完剩余数据再发自己的FIN,客户端最后回复ACK。四个包,两个方向各一对,每个方向都是一次“请求关闭+确认”,谁也不占谁便宜。如果哪次抓包看到的挥手顺序不是这样的,就要警惕了。

5. 实战中的异常场景与排查思路

理论讲得再多,最后还是要落到“连接出问题了怎么查”。我把这些年遇到过的跟三次握手、四次挥手相关的典型问题,整理成一个速查表,再加上几个实操场景的复盘,希望对后面做运维或后端开发的朋友有帮助。

5.1 建连异常的排查速查表

现象直接原因排查思路
客户端一直卡在 SYNC_SENT发出的SYN包没有被响应看tcpdump有没有抓到SYN重传,用ping或nc -vz做基础联通性测试
服务端大量 SYN_RECV 堆积半连接队列被打满看ss -s输出的状态统计,检查tcp_max_syn_backlog和net.core.somaxconn,确认应用有没有调用listen(fd, backlog)
握手偶发失败,抓包看到SYN重传中间网络丢包用mtr或tracepath查路径损耗,关注TCP的tcp_syn_retries重传间隔
应用连接超时,内核日志有 SYN cookies 数值变化半连接队列溢出触发SYN Cookie机制看/proc/net/netstat里的ListenOverflows和ListenDrops字段,优化backlog参数
大量连接处于 CLOSE_WAIT应用没有关闭连接用lsof -p看进程的文件描述符,检查代码里socket的关闭逻辑,多半是线程池任务没释放连接

5.2 SYN Flood与半连接队列的故事

我刚接触网络排错的时候,遇到过一台业务机突然连不上。登录上去看,ss -s显示SYN_RECV几百上千条。一开始以为是正常访问量大了,后来发现客户端老是超时,这才意识到半连接队列满了之后,正常的SYN也会被丢弃。

这个问题的根源是应用服务根本来不及处理这么多建连请求,或者说,半连接队列配置得太小。Linux内核有个机制叫SYN Cookie,在半连接队列满的情况下会自动启用,用来保证基本的建连能力。可以看一下/proc/sys/net/ipv4/tcp_syncookies,默认是1。

但这也不是万能的。SYN Cookie会丢失部分TCP选项,比如SACK、窗口缩放等,对某些高性能传输场景会有副作用。我的习惯是:先通过ss -lnt看当前监听队列的使用情况,再用netstat -s里的被动连接失败计数判断是不是队列溢出,然后适度调大somaxconn和tcp_max_syn_backlog,同时检查应用层有没有哪个线程卡住了导致accept()不及时。

注意:net.core.somaxconn和net.ipv4.tcp_max_syn_backlog是两个不同的东西。前者是应用listen()的backlog上限,后者是内核半连接队列上限。两个都要配合着调,只调一个往往效果有限。

5.3 TIME_WAIT与CLOSE_WAIT的现场复盘

有一次我负责的一个短连接接口,单机峰值QPS大概有两三千,通过反向代理转发到后端。某天突然发现客户端报“连接失败”,登录到反向代理机器上看:

# 查看当前TCP连接状态统计 ss -ant | awk '{print $1}' | sort | uniq -c 1532 TIME_WAIT 2894 ESTABLISHED 1024 CLOSE_WAIT

TIME_WAIT多可以理解,毕竟是反向代理主动断开连接。但CLOSE_WAIT有1000多条就不对劲了。CLOSE_WAIT状态的意思是:对端发了FIN,本端已经确认了,但本地应用程序还没关闭这个socket。这些连接就这么占着文件描述符,堆积多了,进程就报Too many open files。

最后排查下来,问题是后端服务响应完了之后没有正确关闭连接,导致代理这边的连接一直留着等对方先断,代理又被设置了比较长的代理读超时时间,于是socket就一直挂在CLOSE_WAIT。解决方法是修改应用代码,确保在finally块里关闭连接。所以我现在看连接异常,第一眼必看CLOSE_WAIT和TIME_WAIT的比值。TIME_WAIT多一般不可怕,CLOSE_WAIT多就一定要警惕。

5.4 连接中断的排查:RST报文从哪来的

还有一种情况,抓包的时候没看到正常挥手,而是看到一堆RST包。RST报文的出现,说明TCP协议栈觉得“这个连接已经不正常了,直接掐断”。

常见触发原因有这么几个:

  • 向一个没有被监听的端口发连接请求,服务端会回RST;
  • 连接已经被关闭,但还有数据包过来,接收方回RST;
  • 内核因为有数据未处理完而强制终止连接;
  • 中间设备(比如防火墙、负载均衡)主动插入RST。

遇到RST的时候,我一般先看应用日志里有没有对应的时间点,再看防火墙会话老化策略,最后回到代码层面,确认是不是同一套socket被多个线程同时读写。TCP连接是很敏感的东西,尤其是在高并发下,一个不规范的关闭顺序就会引发一连串的RST。

我个人比较建议后端开发把“四次挥手”的代码写进基础库,统一封装socket的关闭逻辑,而不是让每个业务erp各写各的。因为这看似简单,但不同语言、不同框架的默认行为差别不小。比如有些HTTP库是keep-alive的,你用完不显式关闭,它就帮你复用;但如果你换个环境,连接池配置又不一样,就会引发各种close超时。

6. 从面试问到线上排错,这套知识怎么用

最后多聊几句学习方法。三次握手和四次挥手看起来是两个孤立的知识点,但真正有价值的是把它放进一个完整的TCP生命周期里去理解。我自己的学习路线是分三步走的:

第一阶段,理解状态机。不用背,但要能在纸上画出来,能解释每一次状态变化是收到了哪个包触发的。这个阶段建议配合Wireshark抓包,把状态转移和实际报文一一对应起来。

第二阶段,理解参数。重点搞清楚seq、ack、flags、window、mss这几个字段在各个阶段的实际值是怎么算出来的。能看懂报文,就不会再觉得TCP是个黑盒。

第三阶段,理解异常。遇到连接问题,先不看应用日志,先看内核状态统计,再抓包。状态卡在哪个阶段,基本就说明了是哪个环节出了问题。这需要一个反复练习的过程,但一旦建立起来,排查问题的速度会快很多。

关于工具,我日常用的是tcpdump加Wireshark。命令行抓包方便,Wireshark分析图形界面直观。不过说实话,学会看tcpdump的原始输出比依赖Wireshark的图形界面更重要,因为服务器上没有图形界面,你总不能每次都把pcap文件拖到本地看。

之前有一次系统出问题,我就是直接在服务器上用tcpdump抓包,抓完就同一终端里分析,省去了来回拷贝文件的时间。把常用过滤表达式记熟了,完全可以在命令行里完成绝大部分的TCP排障工作。

最后分享一个小技巧:分析TCP问题,不要只看一瞬间的抓包,要结合ss -s的状态汇总和nstat的累计计数来看。抓包看的是微观情况,计数看的是宏观趋势,两者结合才能定位问题。我曾经被一份只有几秒的抓包误导了很久,后来才发现问题的根源是持续了半个小时的半连接队列溢满,抓包窗口根本没覆盖到关键时段。TCP分析这事儿,耐心比技术更重要。

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

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

立即咨询