3. TCP协议详解:报文格式、三次握手与四次挥手、滑动窗口与流量控制

聊到网络编程,TCP 绝对是个绕不开的大山。我刚开始接触网络编程那会儿,总觉得 TCP 就是个黑盒子——数据发出去,对面能收到,完事。直到有一次线上服务出现大量连接超时,我才被迫把这个盒子拆开,好好研究了一番。今天咱们就把 TCP 的核心机制掰开揉碎了讲清楚。

3.1 TCP 报文格式:协议的头到底长什么样?

TCP 报文说白了就是一段二进制数据,前面固定 20 字节的头部,后面跟着载荷。这 20 字节里藏着所有控制信息。我习惯把 TCP 头部想象成一张快递单——收件地址、寄件人、包裹类型、校验码,全在上面。

TCP 头部核心字段一览:

字段 长度 作用
源端口 / 目的端口 各 2 字节 标识通信的应用程序
序列号 (SEQ) 4 字节 本报文第一个字节的编号
确认号 (ACK) 4 字节 期望收到的下一个序列号
标志位 9 位 SYN、ACK、FIN 等控制位
窗口大小 2 字节 接收方还能收多少数据
校验和 2 字节 校验头部+数据的完整性

这里有个细节我当年踩过坑——序列号不是从 0 或 1 开始的。TCP 建立连接时会随机生成一个初始序列号(ISN),然后每个字节都按这个编号递增。为什么要随机?为了防止旧连接的数据包干扰新连接。我曾经在调试一个长连接复用的问题时,就是因为 ISN 冲突导致数据错乱,排查了两天才找到根因。

3.2 三次握手:为什么非得握三次?

三次握手,说白了就是双方互相确认「我能发,你能收」的过程。我经常跟团队新人说,这就像两个人打电话:

  • 第一次:客户端说「喂,能听到吗?」(发送 SYN)
  • 第二次:服务端回「听到了,你能听到我吗?」(发送 SYN+ACK)
  • 第三次:客户端说「我也听到了,咱们开始聊吧」(发送 ACK)

你想想看,如果只握两次手会怎样?服务端发完 SYN+ACK 就认为连接建立了,但客户端可能根本没收到。这时候服务端傻等着,资源就浪费了。三次握手保证了双方都有发送和接收的能力。

避坑指南:我曾经遇到过客户端大量发送 SYN 但不完成第三次握手的情况——这就是典型的 SYN 洪水攻击。服务端会维护一个半连接队列,如果队列满了,正常用户的连接就进不来了。解决方案是开启 SYN Cookie,或者调大半连接队列长度。

握手过程中还有一个容易被忽略的点:TCP 选项。比如 MSS(最大报文段长度)就是在握手时协商的。我记得有一次调优,发现吞吐量上不去,查了半天发现是 MSS 被中间设备改小了,导致每个报文只能带很少的数据,效率极低。

3.3 四次挥手:优雅地分手

断开连接比建立连接复杂一些,因为双方可能还有数据没发完。四次挥手的过程是这样的:

  1. 主动方发送 FIN,表示「我没数据要发了」
  2. 被动方回复 ACK,表示「知道了,但我可能还有数据要发」
  3. 被动方发送 FIN,表示「我也没数据了」
  4. 主动方回复 ACK,然后等待 2MSL 后关闭

这里有个经典问题:为什么主动方要等 2MSL? 说白了就是为了防止最后一个 ACK 丢失。如果被动方没收到 ACK,会重发 FIN,主动方得能处理这个重发的 FIN。2MSL 足够一个报文在网络上来回一趟了。

注意:我在生产环境见过很多「CLOSE_WAIT」堆积的问题。原因是被动方(通常是服务端)没有调用 close(),导致连接一直处于 CLOSE_WAIT 状态。如果堆积太多,文件描述符会被耗尽,新连接就进不来了。排查方法很简单:netstat -n | grep CLOSE_WAIT 看看数量。

3.4 滑动窗口:流量控制的基石

滑动窗口这个概念,我刚开始学的时候觉得特别抽象。后来自己动手抓包分析,才真正理解。说白了,窗口大小就是接收方告诉发送方「你还能发多少数据,别撑着我」。

我画了一张图,帮你理解滑动窗口的工作机制:

滑动窗口工作机制 发送方 已确认 已发送未确认 可发送(窗口内) 不可发送(窗口外) 滑动窗口 接收方 已接收并确认 接收窗口(可接收) 未接收 rwnd(接收窗口) 发送窗口 = min(cwnd, rwnd) —— 拥塞窗口与接收窗口的较小值

滑动窗口的核心逻辑其实很简单:

  • 发送方维护一个发送窗口,窗口内的数据可以连续发送,不用等每个包的 ACK
  • 接收方通过 ACK 报文中的窗口字段告诉发送方自己还剩多少缓冲区
  • 窗口滑动:收到 ACK 后,窗口向右移动,新的数据进入可发送范围

我个人的经验是,窗口大小直接决定了吞吐量。如果窗口太小,发送方大部分时间都在等 ACK,带宽利用率极低。比如一个 RTT 是 100ms,窗口只有 1 个 MSS(1460 字节),那吞吐量撑死了也就 1460 / 0.1 = 14.6 KB/s,太慢了。

3.5 流量控制:别把接收方撑爆了

流量控制,说白了就是接收方告诉发送方「慢点发,我快扛不住了」。这个机制通过滑动窗口实现,但有几个细节值得注意。

零窗口通告:当接收方缓冲区满了,它会通告窗口大小为 0。这时候发送方会停止发送,但会启动一个坚持定时器,定期发送 1 字节的探测报文,看看接收方窗口是否恢复。我曾经遇到过一个问题:接收方窗口恢复后发的 ACK 丢了,发送方一直等,结果死锁了。TCP 用坚持定时器解决了这个问题。

还有一个经典问题:糊涂窗口综合征。接收方每次只释放一点点缓冲区,就通告一个很小的窗口。发送方收到后发一个很小的报文段,导致网络利用率极低。我记得有一次做视频流传输优化,发现大量报文只有几十字节的有效载荷,头部开销占比极高。解决方案是启用 Nagle 算法,或者接收方延迟通告窗口。

实战建议:在做高并发网络编程时,我习惯把接收缓冲区调大一些(比如 256KB 甚至 1MB),这样可以减少零窗口通告的频率。但也不是越大越好——缓冲区太大,内存占用高,而且延迟也会增加。具体调多大,得根据你的业务场景来测试。

3.6 小结

TCP 的这几个核心机制,说白了就是一套可靠传输的保障体系。报文格式定义了通信的「语法」,三次握手和四次挥手解决了连接的建立与拆除,滑动窗口和流量控制则保证了传输的效率和稳定性。

我个人觉得,理解 TCP 最好的方式就是抓包。用 Wireshark 抓一次三次握手,看看序列号怎么变化,窗口怎么通告,比看十遍书都管用。嗯,今天就聊到这儿,下次咱们聊聊 TCP 的拥塞控制——那又是一个很有意思的话题。


可可精品资料库