2. TCP三次握手、四次挥手在 TCP/IP 协议栈里TCP 三次握手和四次挥手几乎是所有人绕不开的第一道坎。我在刚开始接触网络编程时也以为这不过就是“客户端发 SYN、服务端回 SYN-ACK、客户端再回 ACK”这么简单直到后来排查线上连接故障、用 Wireshark 抓包看真实连接建立和释放过程才意识到这两个过程背后藏着序列号、队列、重传计时器、状态迁移等一大串机制。毫不夸张地说搞懂三次握手和四次挥手你才能真正理解 TCP 作为一个可靠传输协议是如何“管好一条连接”的也才能在生产环境里快速定位连接超时、端口占用、CLOSE_WAIT 堆积、TIME_WAIT 过多这类高频问题。这篇内容不打算只画两张箭头图就结束。我会从报文结构、状态迁移、抓包验证、常见排障这几个角度把三次握手和四次挥手彻底拆开尽量把每个关键选择背后的“为什么”说清楚。适合刚学完 TCP/IP 基础的后端开发者、网络运维以及准备面试但不想背答案的求职者。1. 三次握手不是三次确认而是同步与确认的组合很多资料把三次握手简化成“三次确认”这个说法不够准确。三次握手的本质是用最小次数的报文交换让通信双方在一条尚未建立的信道上完成两件事一是同步彼此的初始序列号ISN二是确认对方的收发能力都正常。这两件事相互交叉构成了三次交互。1.1 报文组合与握手流程的底层逻辑先看标准流程。客户端主动发起连接整个过程涉及三个报文第一次握手客户端发出 SYN 报文设置SYN1携带一个随机初始序列号seqx。此时客户端进入SYN_SENT状态。第二次握手服务端收到后如果同意建立连接回复SYN1, ACK1的报文确认号ackx1同时带上自己的初始序列号seqy。服务端进入SYN_RCVD状态。第三次握手客户端收到服务端的 SYN-ACK 后再发一个 ACK 报文设置ACK1确认号acky1。客户端进入ESTABLISHED状态服务端收到这个 ACK 后也进入ESTABLISHED状态。请注意一个细节第二次握手报文同时携带了 SYN 和 ACK 两个标志位。这是因为服务端要完成两件事——回应客户端的 SYN所以在 ack 里填 x1以及向客户端发送自己的 SYN所以自己也要生成一个 y。SYN 和 ACK 合并到一个报文里是 TCP 把“确认捎带”piggyback发挥到极致的体现它把三次握手压缩成了三段时间上的双向往返。这里还有一个关键点序列号是干嘛用的TCP 是面向字节流的它要把应用层的数据拆成多个报文段传输接收方要依赖序号把这些报文段按原始顺序重组。序列号就是字节流中每个字节的编号。三次握手把双方的初始序列号同步好后面才能按序号发送、确认、去重、排序。所谓“同步初始序列号”本质是双方各自告诉对方“我从哪个编号开始编号”并且让对方确认“我听到你从哪个编号开始了”。1.2 为什么必须是三次两次和四次各自有什么问题这是面试最爱问的问题也是理解整个握手设计的关键。先说为什么不能是两次。假设只有两次握手客户端发 SYN服务端回 SYN-ACK然后服务端就认为连接建立了。这时候有个非常现实的问题——如果客户端由于某种原因并没有真正准备好接收数据呢更典型的情况是网络中有延迟的旧报文。客户端和服务端通信过一次客户端发送了连接请求但这个报文在网络里被阻塞了很久客户端超时重传了一个新 SYN旧的 SYN 后来也到了服务端。服务端只回应了旧 SYN 的 SYN-ACK并进入 ESTABLISHED开始等待客户端发送数据而客户端早就认为这条旧连接已经废弃了不会再发送任何数据。这样服务端就会白白消耗资源挂着一条半死连接。三次握手的第三次 ACK 提供了唯一的确认信号只有客户端确认收到了服务端的 SYN-ACK并且愿意进入 ESTABLISHED服务端才正式认定这条连接有效。旧 SYN 对应的第三次 ACK 永远不会来服务端就会因超时而释放资源。再说为什么不需要四次。三次握手已经相互确认了双方的收发能力——第一次握手让服务端知道客户端能发、服务端能收第二次握手让客户端知道服务端能发、客户端能收同时服务端也知道客户端能收第三次握手让服务端确认客户端能收。至此双方对彼此的收发能力都有了确认四次是多余的。协议设计追求的是“在满足需求前提下的最低成本”多一次交互就多一个 RTT 的时延也不利于高并发场景。从另一个角度理解第一次握手解决的是“证明客户端会发、服务端会收”第二次握手解决的是“证明服务端会发、客户端会收”但第二次握手本身也需要被确认。如果服务端发送的 SYN-ACK 丢了客户端不知道服务端是否收到了自己的 SYN会超时重传 SYN服务端一直收不到确认也会重传 SYN-ACK。第三次握手就是对这个“未知”的最终闭环。这里可以看到三次握手本质是一个“三次信令”的确认模型A 告诉 BB 告诉 A 并捎带 B 自己的信息A 再确认收到 B 的信息。1.3 握手过程中的队列、超时与重传机制三次握手不是只在用户态做几个状态标记内核协议栈里还有两层队列在支撑。服务端收到 SYN 后连接会进入半连接队列也叫做 SYN Queue此时连接处于SYN_RCVD状态占用传输控制块TCB。第三次握手 ACK 到达后连接从半连接队列移入全连接队列accept queue进程调用accept()后才能真正开始读写。这两个队列的大小直接对应net.ipv4.tcp_max_syn_backlog和net.core.somaxconn、net.ipv4.tcp_abort_on_overflow等内核参数生产环境出现握手超时很多是因为全连接队列满了内核直接丢弃 ACK 或者向客户端回 RST。超时与重传方面客户端发出 SYN 后如果迟迟收不到 SYN-ACK会触发 SYN 重传。按照 Linux 的默认行为第一次重传间隔为 1 秒之后逐次放大重传次数由net.ipv4.tcp_syn_retries控制默认 6 次总耗时约 1248163263 秒。服务端同样有tcp_synack_retries来控制 SYN-ACK 的重传次数。掌握了这两个参数你就知道为什么线上连接失败经常表现成“卡住几十秒才报错”——这其实是内核在给你做“最后一次努力确认”。另外还要提醒一点初始序列号 ISN 不是固定的。很早以前的实现是固定算法生成容易被攻击者预测从而构造伪造的 RST 报文来切断连接。现代系统都使用随机化的 ISN并且配合时间戳选项TCP Timestamps来防止序列号回绕带来的歧义。这也是为什么你要是在抓包时看到 SYN 报文的 seq 是随机数不要觉得奇怪这恰恰是安全机制的一部分。2. 四次挥手连接释放里的状态机与 TIME_WAIT三次握手建立连接的过程大家都比较熟真正让很多人在生产环境里头疼的是四次挥手尤其是 TIME_WAIT 和 CLOSE_WAIT 这两个状态。我在排查连接数异常、端口被占满的问题时十次有八次最终都落在挥手阶段。2.1 挥手流程与每一跳报文的职责TCP 是全双工协议一条连接上有两个方向的数据流。关闭连接时两个方向要分别关闭所以出现四次交互第一次挥手主动关闭方发送FIN1报文携带sequu 等于已发送字节数1表示“我这边的数据发完了不再发送应用数据”。主动方进入FIN_WAIT_1。第二次挥手被动关闭方收到 FIN 后回复 ACK确认号acku1进入CLOSE_WAIT。主动方收到后进入FIN_WAIT_2。请注意此时被动方只是确认收到关闭请求它自己的数据仍然可以继续发送这个方向还没有关闭。第三次挥手被动方在完成自己的数据发送后发出 FIN 报文或捎带前面累计的 ACK表示“我这边也发完了”进入LAST_ACK。第四次挥手主动方收到 FIN 后回复最后的 ACK确认号ackw1然后进入TIME_WAIT。被动方收到这个 ACK 后进入CLOSED。主动方在 TIME_WAIT 状态等待 2MSL 后才进入CLOSED。第一次和第四次挥手通常对应用户态主动调用close()或shutdown()的一方第二次和第三次之间必然存在一个时间差因为被动方要处理完自己的数据。这也是为什么说“四次”而不是“两次”——两个方向的关闭是独立的被拆分成了两对各带确认的交互。这里有一个必须强调的细节第二次挥手中的 ACK 和第三次挥手中的 FIN 能否合并在一起发送严格来说可以条件是被动方收到 FIN 时已经把所有数据发送完了这时内核会在一个报文里同时置 ACK 和 FIN实现“三次挥手”的极特殊变体。但常规情况下被动方收到 FIN 后还会有一批在途数据要发送所以FIN_WAIT_2状态会持续一段时间表现成标准的四次交互。这个变体在我的抓包经历里很少见知道有这回事就行排查时别被少见现象带偏。2.2 各连接状态的含义与迁移异常状态怎么认从建立到释放TCP 总共要经过 11 种状态。三次握手和四次挥手各产生一批状态我用一张表帮你把状态和触发条件对应起来状态含义典型出现阶段LISTEN服务端在监听端口等待连接握手前SYN_SENT客户端已发 SYN等待 SYN-ACK第一次握手后SYN_RCVD服务端已收 SYN等待最终 ACK第二次握手后ESTABLISHED连接建立可传输数据握手完成后FIN_WAIT_1主动方已发 FIN等待对方 ACK第一次挥手后FIN_WAIT_2主动方已收到 ACK等待对方 FIN第二次挥手后CLOSE_WAIT被动方已收到 FIN等待自身应用关闭第二次挥手后LAST_ACK被动方已发 FIN等待对方最终 ACK第三次挥手后TIME_WAIT主动方发完最终 ACK等待 2MSL第四次挥手后CLOSING双方同时关闭都在等对方 ACK罕见异常双发 FINCLOSED彻底关闭最终状态你可以在 Linux 上用ss -tan或netstat -tan查看这些状态。实际生产环境里最让人头大的两个状态是CLOSE_WAIT和TIME_WAIT堆积。CLOSE_WAIT 大量出现几乎一定说明被动关闭方的应用层没有正确关闭 socket——比如 Java 程序里InputStream读到了 EOF 但代码没走到close()或者 Nginx 上游连接没释放。TIME_WAIT 大量出现则常见于主动关闭方是高并发的短连接客户端这是正常现象但如果量超过端口范围就会引发绑定失败需要在代码或内核层面做优化。2.3 为什么 TIME_WAIT 必须是 2MSL以及 2MSL 的计算逻辑TIME_WAIT 是整个状态机里最容易被忽略、却又最需要尊重的一个状态。主动关闭方发送最后一个 ACK 后必须等待 2MSLMaximum Segment Lifetime报文最大生存时间后才进入 CLOSED。为什么是 2 倍而不是 1 倍或者干脆不要两个原因。第一要确保最后一个 ACK 能到达被动方。如果这个 ACK 在网络中丢失被动方会超时重发 FIN主动方需要能够再回一次 ACK。如果主动方直接进入 CLOSED管理连接的内核控制块已经被回收收到重发的 FIN 后只能回 RST这会被被动方认为异常错误。等 2MSL 就能覆盖“最后一次 ACK 丢失 被动方重传 FIN”的完整往返。第二要让本次连接中所有“迟到的报文”在网络中彻底消失。MSL 是一个报文在网络中存在的最长时间超过这个时间就被丢弃。那么一个报文从 A 到 B最多消耗 1 个 MSL从 B 回到 A 也最多 1 个 MSL所以一个往返最多 2MSL。等待这段时间后本次连接产生的所有旧报文都已经消亡不会和将来复用相同四元组的新连接产生混淆。关于 MSL 的具体数值RFC 1122 推荐的典型值是 120 秒因此 2MSL 常见值是 240 秒4 分钟。但不同操作系统差别很大Linux 的tcp_fin_timeout参数默认是 60 秒也就是说 TIME_WAIT 实际时长通常被控制在 60 秒左右Windows 的 TIME_WAIT 时长约为 240 秒。这个差异在很多跨平台连接测试中会造成时间行为上的不同比如 Windows 服务重启后端口短期内无法复用而 Linux 上则相对快。理解这个参数差异你在做端口复用、压测调优时就不会被平台差异惊到。3. 抓包实测用真实报文看懂握手的每个细节纸上谈兵说再多不如亲手抓一次包。我在带新人时都会要求他们用 Wireshark 完整抓一次 HTTP 连接的建立和关闭过程亲眼看到 SYN、SYN-ACK、ACK、FIN、ACK 这几个报文长什么样。这一节就来复现这个过程把报文逐一拆开。3.1 Wireshark 抓包与过滤器设置抓包工具有很多命令行上tcpdump轻量高效图形界面就用 Wireshark两者抓到的报文格式是相通的。这里以 Wireshark 为例。打开抓包界面先选对网卡然后设置抓包过滤器只抓与目标端口相关的流量。比如本地用 Python 起了个服务监听 8899 端口测试程序连这个端口过滤器可以写成tcp.port 8899或者如果你只需要观察握手和挥手还可以加上tcp.flags.syn 1 || tcp.flags.fin 1但建议展开抓全流量反正报文不多。抓包完成后用显示过滤器进一步筛选tcp.stream eq 0这个过滤器可以把属于同一条 TCP 连接的所有报文一次性挑出来避免和其他连接互相干扰。你会在跟踪结果里看到从三次握手、数据传输到四次挥手的完整生命周期。Wireshark 会为每个报文显示相对序列号Relative Sequence Number相对 seq而不是绝对序列号这看起来更简洁但只要知道设置里的“Relative sequence numbers“默认开启就不至于被吓到。3.2 三次握手报文逐帧拆解我以一个非常简单的场景为例客户端用 Python 脚本发起 HTTP 请求服务端用 Python 内置库监听。抓到的连接开头四个报文大致如下第一个报文SYN客户端到服务端。展开 TCP 协议层可以看到Sequence Number: 0相对值实际绝对序列号是一个随机值比如 2867295721。关键标志位里SYN位为 1。这个报文还带有选项例如MSS最大段大小表示本端愿意接收的最大报文段大小Windows 下通常是 1460 字节对应 MTU 1500 减去 IP 头和 TCP 头的固定开销。第二个报文SYN-ACK服务端返回。明显特征是SYN和ACK两个标志位同时为 1Acknowledgment Number: 1相对值表示它期望下一个收到的是客户端序列号 1 的报文。同时服务端声明自己的 MSS、是否支持 Window Scale、是否支持 SACK 等。第三个报文ACK客户端发出。这个报文的长度一般为 0没有任何载荷只有 20 字节 TCP 头Acknowledgment Number: 1相对值对应服务端的序列号 1。到此连接进入 ESTABLISHED可以携带数据和载荷。三个报文看似简单但有几个细节值得注意。第一个是时序正常握手三个报文的间隔是亚毫秒级的。一旦你看到 SYN 之后隔了 1 秒、3 秒才出现 SYN-ACK说明网络上发生了丢包或中间设备干预这时就要考虑 MSS、MTU 或防火墙策略问题了。第二个细节是 SYN-ACK 里服务端也会带自己的MSS值客户端后续发送的每个 TCP 分段都不能超过双方 MSS 的较小值否则会触发 IP 分片带来性能损耗和丢包风险。第三个细节是 TCP 选项里的Window Scale窗口缩放因子它把 TCP 窗口最大值从 65535 字节扩展到 1GB 级别在长肥网络高带宽高延迟上非常重要。3.3 四次挥手报文与异常断开RST的判断正常关闭时你会在抓包结果末尾看到典型的四个报文FIN、ACK、FIN、ACK。第一个 FIN 由主动关闭方发出对端立刻回应 ACK之后对端在完成自己的数据发送后发出 FIN主动方再回一个最后的 ACK然后这条流就消失了。Wireshark 会在时间列后显示一条“TCP_FIN”或“TCP_CONNECTION_CLOSE”的提示。异常断开则会出现 RST 报文。RST 标志位表示“强制重置连接”常见触发场景包括连接尚未建立时对端已经关闭监听、端口根本没有服务、对端应用进程崩溃、半连接队列溢出等。抓包时看到 RST 报文直接判定这不是一次正常挥手。RST 的可怕之处在于它不经过 TIME_WAIT而是直接销毁连接控制块对端收到 RST 后也会立刻关闭连接。所以线上如果莫名出现大量 RST要重点排查端口是否存活、全连接队列是否溢出、防火墙或负载均衡配置是否有问题。另外再说一个抓包容易踩的坑你抓本地到本地loopback的包时会看到 TCP 三次握手的时序特别“假”——SYN 和 SYN-ACK 可能同一微秒内出现而且很多选项被系统裁剪掉了loopback 接口常常不做 MSS 协商这和真实场景不符。排查线上问题时要把抓包点放在真正的网络链路上比如服务器物理网卡或负载均衡处不要拿本机回环抓包结论去解释线上现象。4. 生产环境常见问题与避坑经验了解完整流程后真正考验功底的是用它来解决实际问题。这一节我整理了自己在维护线上服务时反复遇到过的几类 TCP 连接问题每个问题都给出定位思路和排查方向。4.1 握手失败、连接卡死的定位思路线上最常听到的一句反馈是“服务连不上了”这种情况先不要慌按下面几个步骤过一遍。第一步用ss -tan state syn-sent看客户端是否卡在 SYN_SENT。如果大量连接卡在 SYN_SENT说明发出的 SYN 没有得到响应。这时在服务端抓包看是否有 SYN 报文到达如果没有问题可能出在中间网络、安全组或防火墙拦截如果服务端收到了 SYN 但没有回复 SYN-ACK就要看服务端的半连接队列是否满了。半连接队列满通常意味着并发连接数瞬间暴涨或存在 SYN Flood 攻击。第二步用ss -lnt查看服务端监听队列的情况。重点关注Send-Q和Recv-Q。如果监听 socket 的 Recv-Q 长期不为 0 且接近积压上限说明全连接队列已满accept 的速度跟不上连接建立速度。这时优化方式有几种调大net.core.somaxconn和应用层 backlog 参数检查应用是否有阻塞调用拖慢了 accept 循环如果服务端短时间内出现大量 SYN可以考虑开启tcp_syncookies但注意 syncookies 会牺牲部分 TCP 选项协商不是长久之计。第三步排查两端 MTU 不一致导致的黑洞问题。客户端发 SYN服务端回 SYN-ACK这个报文如果被中间链路判定为超大包而丢弃就会出现“连接超时但端口是通的”的假象。此时在网卡上设置较低的 MTU 或开启 PMTU Discoverytcp_mtu_probing往往能解决问题。4.2 TIME_WAIT 与 CLOSE_WAIT 成堆怎么处理TIME_WAIT 多不多用ss -tan state time-wait | wc -l统计即可。如果是高并发短连接场景TIME_WAIT 多很正常但也可能耗尽端口。在客户端侧你可以启用net.ipv4.tcp_tw_reuse来复用处于 TIME_WAIT 的连接注意前提是开启 TCP 时间戳同时把net.ipv4.ip_local_port_range调大让可用源端口范围更宽。服务端侧则尽量让服务端不要主动关闭连接减少 TIME_WAIT 的产生如果是代理类服务比如 Nginx可以通过配置keepalive_timeout和上游 keepalive 长连接来降低短连接比例。CLOSE_WAIT 堆积则完全是应用层的问题。CLOSE_WAIT 意味着对端已经发了 FIN本端应用还没调用 close。最直接的排查方式是结合 JVM 线程栈或业务日志看哪些连接长期没有被释放通常能追到某个读超时没处理、或者数据读取循环没退出、或者连接池没有设置回收时间。这里分享一个经验不要靠加内核参数来解决 CLOSE_WAIT那是治标不治本真正要做的是把应用代码里忘了关 socket 的路径找出来。再提醒一个容易忽视的坑很多语言的高性能框架会在应用层自己管理连接生命周期比如 Go 的 net/http 默认启用了 keep-alive连接空闲到超时时间后才会关闭。这时候你看到很多 CLOSE_WAIT 不一定代表泄漏可能是框架的“惰性关闭”策略。判断是不是真的泄漏要看 CLOSE_WAIT 的数量是否持续增长而不是只看某一瞬间的值。4.3 高频面试问题速查与我的回答思路这一节整理面试中最高频的几个 TCP 连接相关问题我给出自己的回答要点你可以在此基础上拓展。为什么两次握手不行核心是防止失效的连接请求突然到达服务端而产生资源浪费同时要让双方确认各自的收发能力。两次握手无法确认客户端的接收能力也无法兜底处理网络中的旧报文。为什么四次挥手因为 TCP 是全双工的每个方向的关闭都需要一次 FIN 和一次 ACK。被动方收到 FIN 后可能还有数据要发送所以 ACK 和 FIN 必须分开。TIME_WAIT 为什么是 2MSL确保最后一个 ACK 丢失时可以重传同时让本次连接产生的旧报文在网络中消失避免污染后续连接。用“一个报文最长活在网络里的时间为 MSL一去一回最多 2MSL”这个逻辑来解释最清晰。SYN Flood 是什么攻击者伪造大量 SYN 报文但不完成第三次握手让服务端的半连接队列被快速耗尽导致正常用户无法建立连接。缓解手段包括开启 syncookies、限制 SYN 速率、增大半连接队列。TCP 与 UDP 的区别TCP 面向连接、可靠、有序、有流量控制和拥塞控制UDP 无连接、不可靠、无序、开销小。需要文件传输、远程登录这类场景用 TCP实时音视频、游戏状态同步这类容忍少量丢失但强调低时延的场景用 UDP。顺便补充一点HTTP/3 实际是基于 UDP 实现的 QUIC这恰恰说明“UDP 不可靠”不等于“UDP 不能承载可靠协议”可靠能力可以在用户态自己做。面试回答时不要只背结论最好能顺手画出状态迁移图再举一个你在项目中遇到的 TIME_WAIT 或 CLOSE_WAIT 排查例子这会让面试官觉得你是真的理解而不是背书。4.4 一封记录从三次握手到粘包、心跳与连接池最后还想把知识延伸到更实际的生产里。很多人搞懂了三次握手和挥手却依然不知道程序里 connection pool、heartbeat、sticky packet粘包和这一套流程有什么关系。这里把链路串一下。粘包问题和三次握手没有直接关系它是因为 TCP 是字节流协议应用层写入的数据不保证按“消息”边界交付。比如你用 C 或 Go 写了send对端recv到的不一定是你一次send的内容。处理办法是自定义应用层协议固定长度、分隔符、或者头部声明报文长度最常用的是“长度字段 载荷”。我在实际项目里多采用变长包头比如 4 字节uint32表示长度再加 1 字节类型和 1 字节校验全部走小端序两端一致就不会出问题。心跳机制也和握手无关但同样属于连接生命周期管理。TCP keepalive是一个内核层机制默认关闭或需要很长时间tcp_keepalive_time默认 7200 秒才会触发探测通常并不能及时感知对端崩溃。所以业务层往往自己发心跳包比如 30 秒一个 ping如果连续 3 次没有 pong就判定连接失效主动调close()走一遍四次挥手或者直接 RST。这里是主动关闭方就必然会进入 TIME_WAIT如果心跳频繁TIME_WAIT 会累积这个场景下我一般建议服务端尽量做被动关闭方或者客户端启用tcp_tw_reuse。连接池的作用则是减少三次握手和四次挥手的次数。一次握手 RTT 在跨地域场景可能是几十毫秒连接池复用一个已建立的连接省下的是握手时延同时也能降低服务端新建连接的系统开销。连接池的回收策略一般结合 idle timeout 和 max lifetime 来做本质上做的就是“晚一点挥手、分批挥手”这件工程上的事。把这些概念串起来后你再看 TCP 三次握手和四次挥手就不再是两张孤立的示意图了而是一整个连接生命周期设计里最基础的底盘。我个人在实际排查中的体会是TCP 协议栈的每一层设计都有它的“不得不”。三次握手不是为了好看四次挥手也不是故弄玄虚。遇到连接问题最先要做的不是去翻网上各种玄学调参方案而是静下来把抓包数据拉到面前看看当前到底停在哪一个 flags 和状态上。状态机不会骗人骗人的往往是没看状态就乱猜的人。