TCP协议详解:三次握手、四次挥手、状态机与流量控制

TCP 这东西,说白了就是给不可靠的 IP 层套了个「可靠」的壳。我早年刚接触网络编程时,总觉得它太复杂,不就是发个数据吗?直到自己写了个简单的 socket 程序,发现数据丢了、乱序了、连接卡死了……嗯,从那以后我才老老实实把 TCP 的状态机背了下来。

这一节我们就把 TCP 的核心机制拆开揉碎。你跟着我走一遍,保证以后抓包排查问题时心里有底。

1. 三次握手:建立连接为什么非要三次?

先看一个最经典的场景:客户端想跟服务器说话。TCP 用三次握手来同步双方的序列号,并确认双方都有收发能力。

核心流程:

  1. SYN(客户端→服务器):客户端发送 SYN 包,随机生成一个初始序列号 seq=x,并进入 SYN_SENT 状态。
  2. SYN+ACK(服务器→客户端):服务器收到后,分配资源,回复 SYN+ACK,其中 ack=x+1,自己的序列号 seq=y,进入 SYN_RCVD 状态。
  3. ACK(客户端→服务器):客户端收到后,再发一个 ACK,ack=y+1。此时双方都进入 ESTABLISHED 状态,可以开始传数据了。

为什么不是两次?我举个例子你就明白了。假设只有两次握手:客户端发 SYN,服务器回 SYN+ACK 就认为连接建立。但如果这个 SYN 在网络中延迟了,客户端超时重传了一个新 SYN,服务器又回了新 SYN+ACK,此时旧 SYN 突然到达服务器……服务器会以为这是两个连接,白白浪费资源。三次握手通过客户端的最后一次 ACK 来「确认确认」,彻底避免了这种历史重复连接的问题。

避坑指南: 我曾经在调试一个高并发网关时,发现大量 SYN_RCVD 状态的连接堆积。排查下来是客户端发完 SYN 后不回复 ACK,这就是典型的 SYN Flood 攻击。解决方案是开启 Linux 的 tcp_syncookies,内核会在半连接队列满时用 cookie 机制绕过队列。

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

断开连接时,TCP 是全双工的,每一方向都要单独关闭。所以需要四次交互。

标准流程:

  1. FIN(主动关闭方):主动方发送 FIN,seq=u,进入 FIN_WAIT_1 状态。表示「我没数据要发了」。
  2. ACK(被动关闭方):被动方回复 ACK,ack=u+1,进入 CLOSE_WAIT 状态。主动方收到后进入 FIN_WAIT_2。
  3. FIN(被动关闭方):被动方处理完剩余数据后,发送 FIN,seq=v,进入 LAST_ACK 状态。
  4. ACK(主动关闭方):主动方回复 ACK,ack=v+1,进入 TIME_WAIT 状态。等待 2MSL 后彻底关闭。

你想想看,为什么不能把第二步和第三步合并?因为被动方收到 FIN 时,可能还有数据没发完。它必须先回复 ACK 表示「我知道你要关了」,等自己数据发完再发 FIN。这就是四次挥手比三次握手多一次的根本原因。

注意: TIME_WAIT 状态持续 2MSL(约 1-4 分钟),目的是让旧连接的所有报文在网络中消失,防止新连接收到旧数据。我在线上遇到过因为 TIME_WAIT 过多导致端口耗尽的问题,后来用 tcp_tw_reuse 和 tcp_tw_recycle 优化了回收策略(注意:内核 4.12 之后已移除 tcp_tw_recycle)。

3. TCP 状态转换图:一张图看懂所有状态

下面这张 SVG 图是我自己梳理的 TCP 状态机核心路径。你盯着看两分钟,比背十遍文字都管用。

TCP 状态转换图(核心路径) TCP 状态转换图(核心路径) CLOSED SYN_SENT LISTEN SYN_RCVD ESTABLISHED FIN_WAIT_1 CLOSE_WAIT FIN_WAIT_2 LAST_ACK TIME_WAIT 主动打开 / SYN 被动打开 / listen() 收到 SYN 收到 SYN+ACK / 发 ACK 收到 ACK 主动关闭 / FIN 收到 FIN / 发 ACK 收到 ACK 收到 FIN+ACK / 发 ACK 发 FIN 收到 FIN / 发 ACK 收到 ACK 2MSL 超时 说明:实线为主流程,虚线为同时关闭场景。每个箭头旁标注了触发事件。

这张图我建议你保存下来。我个人习惯在排查连接异常时,先看 ss -tna 输出的状态,然后对照这张图反推当前卡在哪一步。

4. 滑动窗口与流量控制:TCP 怎么做到不丢包?

TCP 不能一股脑把数据全扔出去,否则接收方来不及处理就会丢包。滑动窗口就是用来做流量控制的。

简单说,接收方会告诉发送方:「我当前能收多少字节」,这个值就是窗口大小(Window Size)。发送方只能在这个窗口内发送数据,不能超发。

滑动窗口的核心概念:

  • 已发送并确认:最左边,已经收到 ACK 的数据。
  • 已发送未确认:在窗口内,但还没收到 ACK。
  • 允许发送但未发送:窗口剩余空间,发送方可以继续发。
  • 不可发送:超出窗口范围,必须等窗口滑动后才能发。

你想想看,如果接收方处理速度慢,它会逐渐缩小窗口。当窗口变为 0 时,发送方就停止发送,并启动「零窗口探测」定时器,定期发一个字节去试探窗口是否恢复。这就是流量控制的本质——接收方主导,发送方配合。

实战经验: 我曾经在调优一个文件传输服务时,发现吞吐量上不去。抓包一看,接收方每次通告的窗口都很小,但接收方的 CPU 和内存都空闲。后来发现是接收方的 socket 缓冲区设置太小,默认只有 64KB。调大 SO_RCVBUF 后,窗口立刻变大,吞吐量翻了 3 倍。

5. 窗口缩放因子:突破 65535 字节的限制

TCP 头部的窗口字段只有 16 位,最大只能表示 65535 字节。这在百兆、千兆网络下显然不够用。所以 RFC 1323 引入了窗口缩放因子(Window Scaling)。

三次握手时,双方会在 SYN 包中协商一个缩放因子。比如因子是 7,实际窗口大小就是 通告窗口 × 2^7,最大可达 1GB。这个机制默认开启,你不需要手动配置,但排查问题时要知道它的存在。

概念 说明 常见问题
三次握手 SYN → SYN+ACK → ACK,同步序列号,防止历史连接 SYN Flood 攻击、半连接队列溢出
四次挥手 FIN → ACK → FIN → ACK,全双工独立关闭 TIME_WAIT 过多、CLOSE_WAIT 泄漏
滑动窗口 接收方通告可用缓冲区大小,控制发送速率 窗口过小导致吞吐量低、零窗口死锁
窗口缩放 通过因子放大窗口,支持高速网络 中间设备可能改写缩放因子导致性能下降

注意: 有些老旧的路由器或防火墙会改写 TCP 选项,把窗口缩放因子置为 0。如果你发现两台服务器之间明明带宽很大,但吞吐量卡在 64KB/s 左右,大概率是中间设备把缩放因子干掉了。用 tcpdump 抓包看 SYN 中的 wscale 选项就能确认。

6. 总结一下

TCP 的可靠性不是凭空来的。三次握手建立连接,四次挥手优雅关闭,状态机管理整个生命周期,滑动窗口做流量控制。每一个机制背后都有实际要解决的问题。

我个人建议你找个空闲时间,用 tcpdump 抓一次本机访问某个网站的包,对照着本节内容看一遍三次握手和四次挥手的序列号变化。嗯,看完你就彻底懂了。