☰
三次握手四次挥手:TCP连接建立与关闭的抓包排障指南
2026/9/29 15:38:57 网站建设 项目流程

如果你干过网络、写过后端,甚至只是用 Python 写过一个 socket 通信 Demo,大概率都背过这一对名词:TCP 三次握手、四次挥手。面试会问,期末考试会考,可到了线上环境,连接建不起来、连接卡在 CLOSE_WAIT、对端收到消息却没反应,很多人依然是一脸懵。我想把这两件事彻底讲清楚:三次握手怎么握,为什么必须是三次;四次挥手怎么挥,为什么一定是四次;再加上实际排障必用的抓包分析方法。标题里的“两个社恐程序”不是开玩笑——TCP 连接的两端,确实像两个必须反复确认对方在线、又必须体面告别的进程。

1. 破冰仪式:三次握手拆解到标志位级别

1.1 第一封邀请函:SYN 报文

三次握手的本质是,在不可靠的信道上,双方确认四件事:客户端要确认“自己发送正常、服务端接收正常”,服务端要确认“自己发送正常、客户端接收正常”。注意这里其实是四条确认,却靠三次交互全部完成,这就是协议设计的精妙之处。

第一次交互,客户端发送一个 SYN 报文,里面最重要的字段是序列号 seq。比如客户端随机生成一个初始序列号,在抓包里会看到一个大数字。这个序列号不是随便定的,它要用于后续数据的可靠传输和去重,所以必须随机生成。如果两台机器每次都从同一个固定值开始,旧报文就可能被当成新报文,数据就会错乱。用“社恐”来理解:客户端 A 想找服务端 B 聊天,但不确定 B 在不在线,于是先发一条消息:“我在,你在吗?” 这条消息就是 SYN。SYN 的全称是 Synchronize Sequence Numbers,从名字也能看出来,握手阶段最重要的事情其实是同步序列号,而不只是简单确认“你准备好了没”。

1.2 伺服器的回执:SYN+ACK 报文

服务器如果确实在监听这个端口,收到 SYN 后会回复一个 SYN+ACK 报文。这个包里 SYN 位仍然是 1,ACK 位也是 1,同时带上两个关键数字:一个是服务器自己的初始序列号 seq,比如 5000;另一个是确认号 ack,等于对端的 seq 加 1。

这个回包的含义有两层:先用服务器自己的 seq 告诉客户端“我的发送能力没问题”;再用 ack 告诉客户端“你的 SYN 我收到了”。如果服务器没有监听这个端口,或者防火墙把 SYN 包悄悄丢弃了,就不会有这个 SYN+ACK 出来。你本地用 nc 或者 telnet 连一个不存在的端口,会一直卡在 SYN_SENT 状态,本质就是因为没等到这一步。这一步也解释了为什么网上排查“TCP 连接不上”时,总喜欢先问“有没有看到 SYN_SENT”——状态就摆在那里,说明客户端一直在等服务器的回应。

1.3 第三封确认:纯 ACK 报文

第三次握手其实是一个不带 SYN 的纯 ACK。客户端收到 SYN+ACK 后,先验证对端的 ack 是不是自己刚才那个 seq 加 1,确认没问题,就回一个 ACK,同时把序列号推进到 seq 加 1。从这一刻起,连接进入 ESTABLISHED,双方可以正式传输数据。

从这里能看到为什么叫三次而不是两个半。到第二次交互结束,客户端已经确认了“我发的消息对方能收到,对方发的消息我也能收到”,单从客户端角度来说条件已经满足。但服务器还没有确认“我发的消息客户端能收到”。第三个 ACK 就是专门给服务器的定心丸:客户端确实收到了你的 SYN+ACK,我们的初始序列号都对上了,接下来可以放心发数据。很多讲 TCP 的文章喜欢画箭头图,但真正理解这一步,你就知道第三次 ACK 是服务器视角下的“连接成立判据”。

1.4 为什么必须是三次:两次不够,四次多余

不少新手会问:能不能减少到两次?答案是不能。核心原因是网络上存在过期重复报文。假设客户端发了一个 SYN,因为网络拥塞滞留了很久,客户端超时后发起第二次连接,第二次连接成功建立、使用、关闭。此时第一个过期的 SYN 慢悠悠到达服务器,服务器以为客户端又想连了。

如果是两次握手,服务器这时就会分配连接资源、进入 ESTABLISHED,一直等待一个它等不到的数据;客户端则认为连接早已关闭,根本不会理它。服务器不仅白白占资源,还可能被这种“幽灵连接”拖垮。三次握手后,服务器收到过期的 SYN 会回复 SYN+ACK,但客户端不会回应这个 ACK,服务器等不到第三次握手就回收资源,自然规避了这个问题。三次握手还有额外的好处:它可以在握手过程中同步双方协商的选项字段,比如 MSS、窗口缩放因子、SACK。MSS 大小不对时,会出现“连接能建立但传输很慢或卡住”的诡异问题,本质上就是双方在握手时没有就最大报文段达成一致。

2. 告别仪式:四次挥手与 TCP 状态机

2.1 挥手过程拆解:“我先走了,你们继续”

TCP 是双工的,意味着 A 发给 B 的数据和 B 发给 A 的数据是两条独立通道。所以关闭连接也比想象中麻烦:每一方都要单独关闭自己的发送通道。四次挥手的过程如下。

假设客户端主动关闭。第一步,客户端发 FIN 报文,表示“我的数据发完了,我不再发送数据了”。第二步,服务器收到 FIN 后回复 ACK,表示“收到你的告别”。注意这里服务器还没关,它可能还有数据要发。第三步,服务器把剩余数据发完后,也发一个 FIN,表示“我也发完了,可以关了”。第四步,客户端收到服务器的 FIN 后,再回一个 ACK,整个连接才彻底关闭。我用一句话总结过四次挥手:先提出离开的人要先走,但走之前要等对方也打完收工。对“社恐”程序来说,这个过程就像聚餐结束,一个人说“我先回家了”,另一个人说“好的,我再坐一会儿”;等他真要走了,再跟对方说“我这就走”,对方回一句“好,路上小心”,这才各自散场。

2.2 为什么一定是四次:半关闭机制

四次挥手最容易解释的问题是“为什么 ACK 和 FIN 不能合并成一个包”。因为服务器收到客户端的 FIN 时,并不代表它立刻就没有数据要发了。它可能正在写日志、正在回一个耗时很长的请求结果、正在把缓冲区里的数据排空。第二个 ACK 的功能仅仅是确认收到 FIN,第三个 FIN 则要等剩余数据全部发完之后才能发出。

这就是 TCP 的半关闭设计:连接可以一端先关闭发送方向,另一端继续发送。这个机制在 HTTP/1.1 的 keep-alive、以及一些需要“全收再全发”的请求响应场景里都有体现。如果把 ACK 和 FIN 强制合并,服务器就必须保证收到 FIN 后一瞬之间没有数据要发,这在现实里几乎不可能。所以四次挥手不是规矩繁复,而是由全双工的本质决定的必然结果。

2.3 挥手状态机:CLOSE_WAIT 和 TIME_WAIT 是排障重点

挥手过程中有三个状态最值得记住。被动关闭方收到 FIN 并回 ACK 后进入 CLOSE_WAIT;主动关闭方发出 FIN 后经过 FIN_WAIT_1、收到对端 ACK 进入 FIN_WAIT_2,最后收到对端 FIN 后进入 TIME_WAIT,等待 2MSL 才完全关闭。

CLOSE_WAIT 是线上经典问题。如果代码里 Socket 异常退出后没有调用 close(),被动关闭方会一直停留在 CLOSE_WAIT,连接数缓慢上涨,最终把文件描述符耗尽。我遇到过一次很典型的泄漏:服务进程看起来还活着,但大量连接卡在 CLOSE_WAIT,新请求全部超时。用 netstat 一查,发现是读超时的分支里漏了 close socket。遇到这种问题不要急着调 TCP 参数,基本都是应用层没好好收尾。

提示:CLOSE_WAIT 过多,几乎可以断定是某个业务分支没有执行 close(),别去怀疑内核参数。

TIME_WAIT 则是主动关闭方的专属状态,持续 2MSL(通常是 1 到 4 分钟)。它存在的意义有两个:第一,如果最后一个 ACK 丢了,服务器会重发 FIN,客户端在 TIME_WAIT 期间还能再回一个 ACK;第二,等待足够长的时间,让本次连接的所有报文在网络里自然消亡,避免相同四元组的新连接收到旧报文。服务器主动关闭频繁时会出现大量 TIME_WAIT,很多人的第一反应是调内核复用参数,但更稳妥的做法是先分析为什么服务器会成为主动关闭方。TIME_WAIT 不是洪水猛兽,它恰恰是协议保证可靠性的代价。

3. 实操:用抓包工具亲眼看到握手和挥手

3.1 准备一个最小可复现环境

原理讲再多,不如自己看一眼。我在本机演示,环境以 Linux/macOS 为例。先开一个终端起 TCP 服务端:

nc -l 8080

再开一个客户端连接:

nc localhost 8080

此时连接已经建立,但目前还没有任何数据传输。接着打开另一个终端,用 tcpdump 抓回环接口的包:

sudo tcpdump -i lo -nn port 8080 -vv -S

参数说明:-i lo 抓回环接口,因为连接的是 localhost;-nn 不做域名和端口逆解析;-vv 输出更多细节;-S 显示绝对序列号,否则看到的相对序列号容易让人一头雾水。macOS 自带 tcpdump,Windows 用户可以用 Wireshark 抓 Npcap 的回环接口,效果一样。最重要的是先启动抓包,再发起连接,否则会漏掉开场那三个包。

3.2 抓包看三次握手:三行包揭示连接建立

执行连接后,tcpdump 会打出类似这样的三行(时间戳和端口号会因环境而变):

IP 127.0.0.1.34567 > 127.0.0.1.8080: Flags [S], seq 2746472802 IP 127.0.0.1.8080 > 127.0.0.1.34567: Flags [S.], seq 1032264410, ack 2746472803 IP 127.0.0.1.34567 > 127.0.0.1.8080: Flags [.], ack 1032264411

第一行 Flags [S] 就是 SYN,seq 是客户端初始序列号;第二行 Flags [S.] 表示 SYN+ACK,同时带上了服务器自己的 seq 和 ack;第三行 Flags [.] 是纯 ACK,不带数据。如果用 Wireshark,直接过滤 tcp.port == 8080,三个包会按顺序展示,标志位一目了然。

这里有个特别容易忽略的点:seq 为什么都加了一?因为握手包虽然没有数据载荷,但 SYN 和 FIN 都会占用一个序列号,所以后续包的确认号是 seq+1,而不是 seq。另外第三个 ACK 经常不会单独成为一个“可感知”的时间点,它常常跟客户端的第一份数据一起发出,这叫捎带确认。你用 Wireshark 看 HTTP 请求时,第三个 ACK 往往会紧跟 GET 请求一起出现,所以别以为少了数据。

3.3 抓包看四次挥手:主动关闭方的位置很关键

继续用 nc 场景。在客户端连接状态按 Ctrl+C,nc 会主动关闭连接,tcpdump 里会看到四行:

IP 127.0.0.1.34567 > 127.0.0.1.8080: Flags [F.], seq 2746472803, ack 1032264411 IP 127.0.0.1.8080 > 127.0.0.1.34567: Flags [.], ack 2746472804 IP 127.0.0.1.8080 > 127.0.0.1.34567: Flags [F.], seq 1032264411, ack 2746472804 IP 127.0.0.1.34567 > 127.0.0.1.8080: Flags [.], ack 1032264412

Flags [F.] 是 FIN+ACK 的组合,因为 TCP 头里的确认位在连接建立后基本都是 1,表示“这个包确认了之前的数据”,而 FIN 位单独表示“我要关闭”,两者并不冲突。第一行是客户端 FIN,第二行服务器 ACK,第三行服务器 FIN,第四行客户端 ACK。完整四步,一次不少。

你还可以试试反过来杀掉服务端,看看主动关闭方换成服务器后,FIN 包的来源就变了。不要怕看到 FIN 带着 ACK 标志位就觉得跟理论不一致,真正判断四次挥手的关键,是看第二个 ACK 和第三个 FIN 之间是否隔着数据——如果服务器还有剩余数据要发,你会看到 ACK 和 FIN 之间夹了一串数据包。动手抓一次包,比记十遍理论都深刻。

4. 实战排障手册:从端口占用到粘包

4.1 端口监听失败:bind 报错先查这三步

实际项目中,程序启动报错最常遇见的类型就是“listen 失败”。我之前在一个本地工具上看到类似 failed to listen tcp on 10808 的报错,第一反应不是去改配置,而是查这个端口到底被谁占了。三条命令基本必用:

ss -lntp | grep 10808 lsof -i :10808 netstat -antop | grep 10808

如果能看到另一个进程的 PID,说明端口被占用。解决路径无非三种:改监听端口;停掉旧的占用进程;或者让程序使用 SO_REUSEADDR / SO_REUSEPORT 选项再监听。要注意 SO_REUSEADDR 解决不了两个进程同时在 LISTEN 状态抢同一个端口的问题,它主要是为了处理 TIME_WAIT 状态下的端口复用。如果是 Linux 想多进程共享同一个监听端口,要看 SO_REUSEPORT,并且内核版本和代码都要支持。

除了端口占用,bind 到小于 1024 的端口还会遇到权限报错,需要 root 运行或者用 setcap 授权。防火墙拦截也可能导致 listen 成功但外部连不进来,这时用 iptables -L 或者 firewall-cmd 查一下相关规则。Android 调试时遇到的 adb 5037 端口报错,本质也是同一个问题,先看端口占用,再考虑重启 adb server。

提示:遇到 bind 失败,排查顺序永远是“端口占用 → 权限 → 防火墙”。顺序反了,你会一头扎进防火墙里查半天,最后发现只是端口冲突。

4.2 连接建不起来:从 SYN 重传开始排查

连接建立困难是另一类高频问题。客户端一直显示 SYN_SENT,服务器却什么都收不到,或者抓包看到 SYN 包在反复重传。先别急着怀疑 MTU,按顺序排查。第一步,确认服务端进程监听在哪张网卡,ss -lntp 看 Listen 地址是 0.0.0.0 还是 127.0.0.1,如果只听 127.0.0.1,外部永远连不上。第二步,确认防火墙有没有放行目标端口。第三步,在服务器上抓包,看看 SYN 到底有没有到达网卡:

sudo tcpdump -i any port 目标端口

如果网卡收到了 SYN 但应用没反应,多半是半连接队列满了,或服务进程 accept 不过来了。此时能观察到 SYN 到达但 SYN+ACK 不回,或者回了也会被丢弃。另外还有一种冷门情况:相同四元组的 SYN 被当作旧包丢弃,因为协议栈会对比 seq 是否在窗口内,如果客户端快速重连且 seq 随机得不好,可能被服务器拒绝。这个原因虽然少见,但确实能解释“换了客户端偶尔能连上,固定客户端偶尔连不上”的灵异现象。

嵌入式设备上也会遇到类似问题。比如 ESP32 通过 AT 指令连服务器,AT+CIPSTART 返回失败,先看调试串口的错误码,再对照模块手册查是 DNS 解析失败、端口不通还是服务器主动拒绝。协议栈再小,三次握手的逻辑也是完善的,问题往往出在物理链路、广播域或防火墙,而不是协议栈本身。

4.3 连接建立了,数据却乱套:粘包与拆包

聊完握手挥手,还有一个实战里绕不开的问题:TCP 粘包。很多人以为 TCP 会给消息划边界,实际上不会。TCP 是字节流协议,发送方调用一次 send(),接收方可能在一次 recv() 里收到多条消息,也可能一条消息被拆成几次到达。这就是“TCP 没有消息边界”的含义,与 UDP 的数据报模型有本质区别。

我在 C++ 里处理这个问题时,最常用的方案是“长度前缀法”。每条消息开头写 4 字节长度,接收端先读 4 字节算出长度,再读指定长度的正文。核心逻辑类似这样:

// 发送端 uint32_t len = htonl(payload.size()); send(sockfd, &len, sizeof(len), 0); send(sockfd, payload.data(), payload.size(), 0); // 接收端 uint32_t raw_len; recv(sockfd, &raw_len, sizeof(raw_len), MSG_WAITALL); uint32_t len = ntohl(raw_len); std::vector<char> buf(len); recv(sockfd, buf.data(), len, MSG_WAITALL);

现实场景里 send 和 recv 都要写循环,因为一次调用不一定能发完或收完。这里用 htonl/ntohl 统一字节序,避免大小端不一致的机器之间通信出错。也可以用固定分隔符标定边界,比如 \r\n,适合文本协议;或者用定长消息,适合控制类帧。Modbus TCP 就是典型的组合方案:MBAP 头里的长度字段加上协议数据单元,本质上就是长度前缀法。很多人在嵌入式上把 Freemodbus TCP 移植到 W5500 或 lwIP 时遇到的“收发错位”问题,基本都是没有按长度字段完整读帧导致的。

5. TCP 和 UDP 怎么选:两种性格的程序

5.1 谁更可靠,谁更自由:一张表看明白

TCP 和 UDP 都是传输层的协议,但性格完全不同。TCP 面向连接,有三次握手、可靠传输、流量控制、拥塞控制;UDP 无连接,发出去就不管了。它们的取舍可以从几个维度直接对比:

维度TCPUDP
是否建立连接是(三次握手)否
可靠性可靠,丢包重传尽力而为,丢包不管
顺序保证有序不保证,可能乱序
消息边界字节流,无边界数据报,有边界
传输效率相对低,头部和确认开销大相对高
典型场景HTTP/HTTPS、数据库、文件传输DNS、音视频通话、游戏同步

很多人问,既然要保证可靠,为什么不全用 TCP?因为 TCP 的可靠性是有代价的:重传会导致延迟不可控,队头阻塞会让后面的数据等待前面的重传。实时音视频、对战类游戏的帧同步,丢掉一帧旧数据远比重传这一帧、让后面的帧全部卡住要好,所以 UDP 反而更合适。UDP 上面还要自己实现应用层重传、序化、ACK,后来的 QUIC 协议就是基于 UDP 打造的一套可靠传输机制。再比如 RTSP 视频流,TCP 虽然重传开销大,但能显著减少画面花屏;UDP 延迟低,在弱网下却更容易丢弃关键帧。

5.2 从通信 Demo 到嵌入式:选型思路

回到实际项目。如果你写一个客户端/服务端的命令工具,要传文件、要做请求响应,直接用 TCP,省心。如果只是定期心跳包,丢一两个无所谓,UDP 完全够用。写 TCP demo 时,不管是 C、C++ 还是 Python,都逃不开 socket、bind、listen、accept、connect、close 这套 API。用 ASIO 写 TCP server 时,重点反而不在协议本身,而在会话管理和异步收发的生命周期。别把 socket 对象放在栈上,连接一断开就析构,容易踩野指针;用 shared_ptr 管理 session,配合异步读写,整个流程会顺很多。

嵌入式场景也一样。lwIP、W5500 这些协议栈实现的 TCP 与 PC 上的 TCP 本质相同,只是资源更紧张、缓冲区更小。ESP32 通过 WiFi 接收 TCP 消息时,要特别注意收发缓冲区的管理,别在回调里做耗时操作。测网络带宽时可以用 iperf3 一把梭,它能直接告诉你实际吞吐和重传情况。最后再提醒一句,很多人把 TCP 和 HTTP 放在一起比较,其实是不同层的东西:HTTP 是应用层协议,基于 TCP 传输;TCP 是传输层协议。一个 HTTP 请求在 TCP 连接上传输,传完响应后决定关闭还是复用。HTTP/1.1 默认 keep-alive,本质就是在省去重复三次握手的开销。理解了握手和挥手,再看 HTTP/2 多路复用、HTTP/3 切到 QUIC,你会觉得整个协议栈的逻辑都串起来了。

我个人在实际排查和写代码时的体会是:TCP 的握手和挥手不只是一个为了面试的知识点,它其实是一套“建连-传数-断连”的完整心智模型。遇到连接卡住,先看它卡在哪个状态;遇到端口报错,先看是谁占用了端口;遇到收发错位,先看有没有消息边界。把这些流程刻进大脑之后,你就是那个能在别人还在猜问题的时候,一眼看出连接卡在哪一步的人。希望这篇文章能帮你彻底吃透这两个仪式,之后不管是在电脑上抓包、在服务器上调参、还是在开发板上调 lwIP,都能少踩几个坑。

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

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

立即咨询