TCP 三次握手、四次挥手这个事儿凡是搞网络、搞后端、搞运维的几乎都躲不开。刚入行的时候我也以为这就是背下来的八股文直到后来线上真的出了连接堆积、握手超时这类故障才意识到当初没把这两个过程彻底弄明白吃亏的是自己。所以这篇东西我不想给你念 RFC 文档而是站在一个天天跟 TCP 打交道的从业者角度把三次握手和四次挥手从头到尾拆开揉碎讲清楚每一步到底发生了什么、为什么必须是这个次数以及排障的时候这些知识要怎么用。这篇文章适合刚接触 TCP/IP 的初学者也适合已经写了不少代码但从来没认真看过抓包的开发还有那些被 TIME_WAIT、CLOSE_WAIT 折磨过的运维朋友。看完之后你不仅能对着面试官把原理讲明白更重要的是线上出问题的时候你知道从哪儿下手。1. 先说人话三次握手到底在干什么三次握手说白了就是通信双方在正式发数据之前先互相确认三件事你的发送能力没问题、我的发送能力没问题、咱俩都能正常收到对方的消息。这跟两个人第一次见面互相确认身份是一个道理。1.1 一次真实的握手过程长什么样我不喜欢空谈理论咱们直接看抓包结果。在 Linux 上随便找一个 HTTP 服务用 tcpdump 抓一下建立连接的过程sudo tcpdump -i eth0 host 192.168.1.100 and port 80 -n当你用浏览器访问这个服务时会看到类似下面的输出14:50:01.123456 IP 192.168.1.50.55001 192.168.1.100.80: Flags [S], seq 1000 14:50:01.123568 IP 192.168.1.100.80 192.168.1.50.55001: Flags [S.], seq 2000, ack 1001 14:50:01.123589 IP 192.168.1.50.55001 192.168.1.100.80: Flags [.], ack 2001这三行就是传说中的三次握手。第一行是客户端发起的 SYN 包seq 序列号设为 1000第二行是服务端回应的 SYNACK 包既确认了客户端的序列号ack 1001又带上自己的序列号seq 2000第三行是客户端对服务端序列号的确认ack 2001。到这里双方都确认了对方已经收到自己的同步信息连接建立成功状态变成 ESTABLISHED。很多人第一次看抓包会疑惑为什么 SYN 包的 seq 是从 1000 这种整数开始的而不是从 0 开始其实这个序列号是经过计算的初始值跟真实的数据传输起点有关。你可以把它理解成每个人说话前先给自己的话编个号方便对方按顺序整理。最初的设计里这个初始序列号会随时间变化防止跟之前的连接混淆。1.2 握手过程中双方的状态是怎么翻牌的三次握手不光是发三个包那么简单每一方都在同步更新自己的状态理解这些状态对排查问题特别关键。最开始服务端进程监听在某端口上状态是 LISTEN客户端这边是 CLOSED。客户端第一次发出 SYN状态从 CLOSED 变成 SYN_SENT表示我正在等对方回应。服务端收到 SYN 后状态变成 SYN_RCVD同时回一个 SYNACK告诉客户端我收到你的同步请求了。客户端收到 SYNACK状态变成 ESTABLISHED然后回一个 ACK。服务端收到这个 ACK状态也变成 ESTABLISHED。这里有个容易忽略的点客户端是在发出 ACK 之后立即进入 ESTABLISHED而服务端是在收到 ACK 之后才进入 ESTABLISHED。也就是说在第三个 ACK 到达服务端之前客户端已经认为自己连接建立好了但服务端还停留在 SYN_RCVD。如果这个 ACK 丢了服务端会超时重传 SYNACK所以客户端即使在 ESTABLISHED 状态下也要做好收到重复 SYNACK 的心理准备。1.3 为什么一定是三次两次难道不行吗这是面试里最常问的问题也是理解整个握手设计的关键。咱们反过来想如果只握手两次会出什么事假设只有两次客户端发 SYN服务端回 SYNACK然后双方直接认为连接建立。那么当网络中有一个滞留了很久的旧 SYN 包突然跑到服务端服务端以为客户端要建立新连接就回了 SYNACK 并进入 ESTABLISHED开始等客户端发数据。可客户端压根没打算建立连接收到这个 SYNACK 也不会理会服务端就一直傻等浪费资源甚至可能被这种假连接拖垮。三次握手就解决了这个问题。客户端收到服务端的 SYNACK 之后还要再回一个 ACK服务端只有在收到这个 ACK 后才真正建立连接。旧 SYN 包的场景下客户端根本不会回应第二次 SYNACK服务端就会放弃不会建立起半吊子连接。从另一个角度看三次握手也是让双方都确认“我能发、我能收、你能发、你能收”这四件事。第一次客户端发 SYN服务端收到服务端知道“客户端能发我能收”第二次服务端回 SYNACK客户端收到客户端知道“客户端能发我能收服务端能发我能收”第三次客户端回 ACK服务端收到服务端这才知道“我能发客户端能收”。只有三次握手才能让双方都确认自己和对方的收发能力都没问题两次握手无论如何都差一个方向的确认。提示三次握手还有一个隐形好处就是双方可以协商一些连接参数比如最大报文段长度MSS、窗口缩放因子Window Scale等。这些参数是在握手阶段互相告诉对方的如果你强行用两次握手这些协商就没地方放了。2. 四次挥手好聚好散比建立连接更难建立连接是为了干活断开连接则要考虑怎么把没说完的话说完。四次挥手之所以比三次握手多一次根本原因在于 TCP 是双工的两边都能独立地发送和接收数据断开的时候每一侧都需要单独确认对方的关闭意愿。2.1 挥手过程的四个包还是用抓包来看当客户端主动关闭连接时会看到四个包14:50:10.123456 IP 192.168.1.50.55001 192.168.1.100.80: Flags [F.], seq 5000 14:50:10.123468 IP 192.168.1.100.80 192.168.1.50.55001: Flags [.], ack 5001 14:50:10.123512 IP 192.168.1.100.80 192.168.1.50.55001: Flags [F.], seq 7000, ack 5001 14:50:10.123524 IP 192.168.1.50.55001 192.168.1.100.80: Flags [.], ack 7001第一步客户端发送 FIN 包表示“我的数据发完了准备关闭发送方向”第二步服务端回 ACK表示“我知道了但我可能还有数据要发”第三步等服务端的数据也发完了服务端再发 FIN 包表示“我这边也完事了”第四步客户端回 ACK表示“收到关闭完成”。注意第二步和第三步之间是可以有间隔的服务端确认客户端的 FIN 之后不一定立刻发自己的 FIN。它可以继续发剩余数据等全部发完再关闭。这个特点就是 TCP 半关闭连接的两个方向可以独立关闭一边关了另一边还能继续传数据。2.2 挥手双方的状态变换全过程我把所有状态变化写成一张表方便你对照抓包看阶段主动关闭方状态被动关闭方状态初始ESTABLISHEDESTABLISHED发送 FIN 后FIN_WAIT_1CLOSE_WAIT收到对方 ACKFIN_WAIT_2CLOSE_WAIT对方发送 FINFIN_WAIT_2LAST_ACK发送 ACK 后TIME_WAITCLOSED等待 2MSL 后CLOSED早已关闭被动关闭方收到 FIN 后会进入 CLOSE_WAIT同时回 ACK。这个状态非常关键因为它代表“对方想关了但我还没关我还有数据要发”。如果程序代码里忘了关闭 socket连接就会一直卡在 CLOSE_WAIT这是后端排查里特别常见的场景后面我会专门讲。主动关闭方收到 FIN 后会进入 TIME_WAIT等 2MSLMaximum Segment Lifetime报文最大生存时间才彻底关闭。这里的等待不是白等的主要是为了保证最后的 ACK 能送达对端万一 ACK 丢了对方会重发 FINTIME_WAIT 期间还能再回一次 ACK。2.3 为什么必须四次为什么不能合并成三次很多人问三次握手能省一次挥手为什么不能合并成三次假如服务端收到 FIN 后立刻把自己的 FIN 和 ACK 一起发出去那确实可以只发三个包但是这样做有一个前提服务端在收到 FIN 的那一刻已经没有数据要发了。可是实际传输中服务端很可能在收到 FIN 时还有数据没发完。比如客户端发完请求后要关闭连接但服务端还有响应数据正在传输。如果服务端强行把 FIN 和 ACK 合并就等于告诉客户端“我不但确认了你的关闭我自己也不发了”可它明明还有响应要发这就会造成数据丢失。TCP 这样设计本质上是允许两个方向独立关闭。ACK 只是确认收到对方的 FIN而 FIN 才是本端主动发起的关闭请求。这两个动作在时间上可能相差很久所以不能强行合并。看到这里你就明白挥手是四次而不是三次不是协议设计得啰嗦而是 TCP 要保证数据的完整性和双方独立的关闭意愿。2.4 TIME_WAIT 为什么要等 2MSLTIME_WAIT 是最容易被忽视、也最影响线上系统的一个状态。主动关闭方发送完最后一个 ACK 之后会进入 TIME_WAIT并且等待 2MSL 才关闭。MSL 是报文在网络中存活的最长时间一般取 30 秒到 2 分钟不等Linux 上常见的是 60 秒所以 2MSL 通常是 2 分钟。为什么要等这么久两个原因。第一防止最后一个 ACK 丢失。如果最后一个 ACK 丢了服务端会重发 FIN客户端如果没有 TIME_WAIT 状态直接 CLOSED那它收到重发的 FIN 后没法回应服务端就会一直重试连接永远关不掉。第二让网络中残留的旧报文自然消失。如果一个连接刚关闭立刻用相同的 IP 和端口建立新连接旧连接在网络中滞留的报文可能被新连接误收。等 2MSL 之后所有旧报文都已经在网络中消亡新连接就不会被干扰。注意TIME_WAIT 时间长是有代价的。在高并发的短连接场景下主动关闭方会产生大量 TIME_WAIT 连接占用本地端口和内存。所以线上经常要在服务器参数里调优比如启用 tcp_tw_reuse或者在 Java/C 等程序的连接池里复用连接减少短连接导致的 TIME_WAIT 堆积。3. 把握手、挥手放进真实场景里看理解了三步四步的原理下一步要做的就是把这些概念跟生产环境挂上钩。很多人背得滚瓜烂熟一到线上看到netstat输出里的各种状态还是懵就是因为不知道这些状态在真实交互中对应什么行为。3.1 短连接一次请求一次握手加挥手短连接就是每次请求都要建立连接、传输数据、关闭连接。最典型的就是早期 HTTP/1.0 的行为请求一个网页浏览器和服务端握手拿到 HTML 后立刻挥手断开接着请求 CSS、JS、图片又要重新建立一次连接。短连接的缺点是显而易见的握手一次需要 1 个 RTTRound-Trip Time往返时间挥手又要多个包在高延迟网络下光建立和关闭连接就要占不少时间。而且主动关闭方通常是服务端会产生一堆 TIME_WAIT端口和内存都有压力。我见过一个线上案例某个老系统用短连接访问后端服务压测一上来服务端 TIME_WAIT 直接飙到几万个ss -s显示 TIME-WAIT 占了大半本地端口耗尽新连接都建立不了了。后来把代码改成连接池复用TIME_WAIT 瞬间降下来性能提升非常明显。3.2 长连接一次握手管多次请求长连接就是建立连接后持续复用同一个 TCP 连接处理多个请求HTTP/1.1 的 Keep-Alive 和 HTTP/2 的多路复用都是基于这个思路。一次握手之后连接一直是 ESTABLISHED 状态直到某个方向决定关闭才走四次挥手。长连接的好处是省掉了反复握手挥手的开销尤其在 RTT 较大的跨地域场景下效果明显。但长连接也有自己的问题连接长时间空闲时中间的网络设备可能会把空闲连接回收导致一方还在用另一方其实已经断了。这就是为什么很多长连接协议要用心跳机制比如 TCP 的 KeepAlive 参数或者应用层自己发心跳包。关于 TCP 自带的 KeepAlive默认是两小时才探测一次很多场景下不够用所以应用层心跳更靠谱。3.3 握手挥手背后的性能账我算一笔简单的账帮助你理解短连接和长连接的选择。假设网络 RTT 是 30ms一次 HTTP 请求的数据传输时间是 20ms。短连接方式建立连接要 1.5 个 RTT三次握手理论上是 1.5 RTT第一次客户端到服务端 0.5 RTT服务端回客户端 0.5 RTT客户端再确认 0.5 RTT再加上传输 20ms最后挥手还要 1 RTT 左右总计差不多 45ms 20ms 30ms一请求一开销。长连接方式第一个请求多付一次握手成本后续请求全都省了握手和挥手每个请求大约就是 20ms 的传输时间加排队时间。在高 QPS 场景下这个差异会被放大得非常明显。所以很多中间件、数据库客户端宁可多维护连接状态也要用连接池保持长连接。4. 排障实录从握手挥手的细节定位线上问题前面讲的都是原理下面这部分才是实战中最值钱的经验。我在工作里踩过不少跟握手、挥手相关的坑把典型的几种情况整理一下。4.1 连接建立不了的排查链路线上出现“连不上服务”的报错很多人第一反应去看防火墙规则但光看规则不够要结合握手状态来判断问题到底在哪一层。先用telnet ip port或者nc -vz ip port测一下端口通不通。如果一直卡着不动那大概率是 SYN 发出去了没收到响应。这时候在客户端和服务端分别抓包看 SYN 到底丢在哪。常见原因有三种服务端没监听端口或者服务进程挂了表现为服务端网卡上有 SYN 进来但没人回 SYNACK。防火墙拦截了 SYN 或 SYNACK。比如云安全组、iptables 规则、firewalld 配置这个用iptables -L -n或者看安全组规则来排查。服务端的半连接队列满了。内核里有个参数叫net.ipv4.tcp_max_syn_backlog当 SYN 请求太多、服务端来不及处理新的 SYN 会被丢弃客户端表现为一直 SYN_SENT。看到这里应该明白如果 SYN_SENT 一直存在问题多半在客户端到服务端的路径上如果抓包看到 SYNACK 发出去了但客户端没收到那问题可能在回程路径上或者在服务端的连接队列。4.2 CLOSE_WAIT 堆积是最常见的代码问题CLOSE_WAIT 堆积是后端开发最容易踩的坑。它出现的原因是服务端收到了客户端的 FIN内核回了 ACK但应用程序没有正确关闭 socket导致连接卡在 CLOSE_WAIT 状态。我记得有一次排查一个 Java 服务netstat -anp | grep CLOSE_WAIT | wc -l出来上万个内存和线程都被拖垮。最后定位到代码里读请求流的时候异常分支没有走到close()导致每一个异常请求都泄漏一个连接。修法很简单用 try-with-resources 或 finally 里关闭资源但排查的过程确实费劲。如果你的服务也出现大量 CLOSE_WAIT优先检查这几处是否所有分支都关闭了 socket 或包装类。是否读取完请求后没有主动关闭连接长连接场景下要区分是业务正常 idle 还是泄漏。是否把连接存储到容器里没有清理。4.3 TIME_WAIT 过多该不该处理TIME_WAIT 过多不是错误但到了影响端口分配和内存的时候就得想办法优化。优化思路有个优先级我建议按顺序来第一减少不必要的连接建立和关闭用连接池、长连接替代短连接这是最根本的解法。第二开启内核参数net.ipv4.tcp_tw_reuse允许在 TIME_WAIT 阶段复用连接注意tcp_tw_recycle在现在的内核里因为 NAT 场景有坑不建议开启。第三调整net.ipv4.ip_local_port_range扩大本地端口范围给 TIME_WAIT 更多的空间。注意如果 TIME_WAIT 都集中在服务端而且服务端是被动关闭方为主那说明是客户端先发起的关闭服务端只需要回 FIN不会自己产生 TIME_WAIT。大量 TIME_WAIT 在服务端反而说明是服务端主动关闭了连接比如设置了不合理的超时时间。4.4 实用的命令与工具速查排查网络问题时我经常用的命令不多但都很管用# 查看系统连接状态汇总 ss -s # 统计各种状态连接数量 netstat -n | awk /^tcp/ {state[$NF]} END {for(key in state) print key,\t,state[key]} # 查看具体端口的连接状态 ss -tan | grep :8080 | head -20 # 抓包分析握手挥手 sudo tcpdump -i any port 8080 -nn -w /tmp/tcp.pcap用 Wireshark 打开抓包文件可以非常直观地看到 SYN、ACK、FIN 的交互时序配合过滤器tcp.flags.syn 1或者tcp.flags.fin 1一眼就能定位问题在哪一步。5. 从三次握手、四次挥手延伸出的关键知识点把握手和挥手讲完之后再补充几个跟它强相关的概念日常面试和工作中都会碰到。5.1 状态编码和标志位的实际含义TCP 报文头里的控制位其实就对应了握手的 SYN、ACK 和挥手的 FIN。和握手相关的还有 RST、PSH、URG 等。RST 是用来异常断开的比如连接不存在、端口不可达、收到无意义的报文时内核会直接发 RST 而不是走四次挥手。所以你在抓包时看到 RST基本意味着哪里出错了而不是正常的关闭流程。PSH 标志位表示接收方收到数据后应立即交给应用层不要等缓冲区填满。平时不太会被注意但在抓包分析时如果看到 PSHACK说明这是一次正常的数据交付。5.2 TCP 与 UDP 的本质差异TCP 三次握手、四次挥手之所以是讨论的重点恰恰是因为 UDP 完全没有这些机制。UDP 不建立连接、没有序列号、没有重传机制直接发数据报所以面试里常考的“TCP 和 UDP 的区别”本质上就是在讲“可靠性 vs 效率”的取舍。TCP 适合对数据完整性要求高的场景比如网页、文件传输、数据库连接UDP 适合对实时性要求高、丢一些数据影响不大的场景比如音视频通话、游戏帧同步、DNS 查询。做架构选型的时候搞清楚业务能不能接受丢包就知道该选哪个了。5.3 从握手挥手看防火墙的会话状态很多运维知道要在防火墙里放行端口但不知道防火墙还有状态检测机制。现代防火墙为了安全会记录 TCP 连接状态只允许已经建立过握手的连接的数据包通过。这就产生了一个常见的运维坑如果防火墙因超时清除了会话记录但服务端和客户端之间的 TCP 连接还活着那么后续数据包经过防火墙时可能会被判定为攻击流量而拦截业务就“莫名其妙”卡住了。从这个角度说理解三次握手和四次挥手的包特征也是配置防火墙、排查网络策略的基础。我给新人的建议是别把三次握手、四次挥手当成面试八股多花点时间自己抓包看一看。找个本机服务用tcpdump抓一次完整请求观察每一个包的标志位、序列号和状态变化比死记硬背一个月都管用。等你哪天线上遇到 CLOSE_WAIT 堆积、SYN 超时、TIME_WAIT 耗尽的时候就会感谢当年把这几张状态转换图啃下来的自己。