跳到主要内容
预计阅读 32 分钟

TCP 协议深入 — 可靠传输的艺术

TCP 是互联网最重要的传输协议之一。它就像一位严谨的快递员——不仅确保包裹准确送达,还要按正确的顺序摆好,丢了的重新补发,速度太快还会主动放慢脚步以免造成拥堵。

📋 开篇自测:你已经知道多少?

  1. TCP 三次握手的每一步分别在做什么?为什么是三次而不是两次?
  2. TCP 的滑动窗口机制如何实现流量控制?
  3. TCP 连接断开时的四次挥手为什么需要四步?TIME_WAIT 状态存在的意义是什么?

一、TCP 概览:面向连接的可靠传输

1.1 TCP 要解决的核心问题

IP 层提供的是”尽力而为”的服务——数据包可能丢失、乱序、重复,甚至被损坏。TCP 在这样不可靠的基础上,构建了一套可靠传输机制:

问题TCP 的解决方案
数据丢失超时重传、快速重传
数据乱序序列号排序
数据重复序列号去重
数据损坏校验和验证
接收方处理不过来流量控制(滑动窗口)
网络拥堵拥塞控制算法

1.2 TCP 头部结构

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          源端口 (16位)         |         目的端口 (16位)        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        序列号 (32位)                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        确认号 (32位)                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 数据  |保留 |C|E|U|A|P|R|S|F|                                 |
| 偏移  |     |W|C|R|C|S|S|Y|I|         窗口大小 (16位)          |
|      |     |R|E|G|K|H|T|N|N|                                 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          校验和 (16位)         |         紧急指针 (16位)        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    选项(可变长度)                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

关键字段说明:

  • 源端口 / 目的端口(各 16 位):标识通信的两端进程
  • 序列号(32 位):标识本段数据的第一个字节在整个数据流中的位置
  • 确认号(32 位):告诉对方”我已收到的数据到第几个字节,下一个期待收到的字节编号”
  • 标志位:SYN(建立连接)、ACK(确认)、FIN(关闭连接)、RST(重置)、PSH(推送)、URG(紧急)
  • 窗口大小(16 位):告诉对方自己的接收缓冲区还有多少空间

二、三次握手:建立连接

2.1 握手的完整过程

客户端(CLOSED)                                   服务端(LISTEN)
    |                                                |
    |  1. SYN=1, seq=x                               |
    |  客户端进入 SYN_SENT 状态                        |
    | ---------------------------------------------> |
    |                                                |
    |  2. SYN=1, ACK=1, seq=y, ack=x+1              |
    |                              服务端进入 SYN_RCVD |
    | <--------------------------------------------- |
    |                                                |
    |  3. ACK=1, seq=x+1, ack=y+1                   |
    |  客户端进入 ESTABLISHED                          |
    | ---------------------------------------------> |
    |                          服务端进入 ESTABLISHED  |
    |                                                |
    |  ========== 连接建立,可以传输数据 ==========     |

2.2 每一步在做什么

第一次握手:客户端发送 SYN 包,声明自己的初始序列号为 x。

  • 目的:客户端通知服务端”我想建立连接”,并告知自己的初始序列号。

第二次握手:服务端回复 SYN+ACK,声明自己的初始序列号为 y,并确认收到了客户端的 SYN(ack=x+1)。

  • 目的:服务端确认客户端的发送能力正常,并告知自己的初始序列号。

第三次握手:客户端发送 ACK,确认收到服务端的 SYN(ack=y+1)。

  • 目的:客户端确认服务端的发送能力正常。

2.3 为什么是三次?两次行不行?

两次握手的问题在于:服务端无法确认客户端的接收能力

两次握手的灾难场景:

客户端                                    服务端
  |  SYN (旧的、延迟到达的连接请求)        |
  | ---- (在网络中漂流了很久) ----------> |
  |                                       |
  |  SYN+ACK                              |
  | <------------------------------------ |
  |    服务端认为连接已建立,开始等待数据    |
  |    但客户端知道这是过期的请求,不会响应  |
  |                                       |
  |    服务端白白浪费资源等待...            |

三次握手可以防止这种情况:即使服务端收到旧的 SYN 并回复了 SYN+ACK,客户端不会发送第三次 ACK(或发送 RST),服务端就不会进入 ESTABLISHED 状态。

🤔 想一想 TCP 三次握手期间,IP 层和 MAC 层在做什么?它们也只是”三次”吗?


三、四次挥手:断开连接

3.1 挥手的完整过程

客户端(ESTABLISHED)                          服务端(ESTABLISHED)
    |                                            |
    |  1. FIN=1, seq=u                           |
    |  客户端进入 FIN_WAIT_1                      |
    | ----------------------------------------> |
    |                                            |
    |  2. ACK=1, ack=u+1                         |
    |                        服务端进入 CLOSE_WAIT |
    | <---------------------------------------- |
    |  客户端进入 FIN_WAIT_2                      |
    |                                            |
    |  (服务端可能还有数据要发送...)              |
    |                                            |
    |  3. FIN=1, seq=w                           |
    |                        服务端进入 LAST_ACK  |
    | <---------------------------------------- |
    |                                            |
    |  4. ACK=1, ack=w+1                         |
    |  客户端进入 TIME_WAIT                       |
    | ----------------------------------------> |
    |                        服务端进入 CLOSED    |
    |                                            |
    |  (等待 2MSL 后...)                          |
    |  客户端进入 CLOSED                          |

3.2 为什么是四次而不是三次?

因为 TCP 是全双工协议,每个方向的关闭是独立的。当客户端发 FIN 时,只表示客户端不再发送数据,但服务端可能还有数据要发。所以服务端先回 ACK 表示”收到了你的关闭请求”,等自己数据发完后再发 FIN。

3.3 TIME_WAIT 的意义

客户端在发完最后一个 ACK 后,不是立即关闭,而是等待一段时间。RFC 793 定义 TIME_WAIT 应持续 2MSL(Maximum Segment Lifetime,最大报文寿命),MSL 建议值为 2 分钟。但 Linux 内核将 TIME_WAIT 硬编码为 60 秒TCP_TIMEWAIT_LEN = 60*HZ,定义于 include/net/tcp.h),不可通过内核参数调节。

原因有二:

  1. 确保最后的 ACK 到达:如果服务端没收到 ACK 会重发 FIN,客户端在 TIME_WAIT 期间可以重新回复 ACK。
  2. 让旧连接的残余数据包消失:防止延迟的旧数据包干扰新连接。
TIME_WAIT 问题:高并发服务器短时间内大量连接关闭,
会产生大量 TIME_WAIT 状态的连接,占用端口和内存。

解决方案:
- 内核参数 tcp_tw_reuse=1  允许复用 TIME_WAIT 的端口
- 使用长连接(Keep-Alive)减少连接创建/关闭次数
- 让客户端主动关闭(TIME_WAIT 在客户端而非服务端)

注意: 早期常见的 tcp_tw_recycle 参数已在 Linux 4.12 内核中被移除,
因为它在 NAT 环境下会导致连接异常丢弃,不应再使用。

四、序列号与确认机制

4.1 序列号的作用

TCP 将数据视为一个连续的字节流,每个字节都有编号。序列号标识的是一段数据的第一个字节在整个流中的位置。

发送方发送 3 个段:

段1: seq=1,    数据="Hello"(5字节)     --> 字节 1-5
段2: seq=6,    数据=" World"(6字节)    --> 字节 6-11
段3: seq=12,   数据="!"(1字节)         --> 字节 12

接收方确认:
收到段1后: ack=6    "我已收到前5字节,期待第6个"
收到段2后: ack=12   "我已收到前11字节,期待第12个"
收到段3后: ack=13   "我已收到前12字节,期待第13个"

4.2 累积确认

TCP 使用累积确认机制:确认号表示”这个编号之前的所有字节都已收到”。

发送: seg1(seq=1), seg2(seq=100), seg3(seq=200)

场景1: 全部按序到达
  收到seg1 -> ack=100
  收到seg2 -> ack=200
  收到seg3 -> ack=300

场景2: seg2 丢失
  收到seg1 -> ack=100
  收到seg3 -> ack=100  (仍然是100,因为seg2还没收到)
  重传seg2到达 -> ack=300  (一次性确认到300)

五、滑动窗口与流量控制

5.1 停等协议的低效

如果每发一个包就停下来等确认,效率极低:

停等协议(Stop-and-Wait):
发送方        网络         接收方
  |--seg1--> [延迟] -->|
  |                    |--ack-->|
  |<---[延迟]---ack----|
  |--seg2--> [延迟] -->|
  ...
  大部分时间在等待!

5.2 滑动窗口机制

TCP 使用滑动窗口允许发送方在收到确认前连续发送多个段:

发送窗口大小 = 4 个段

发送方的视角:
+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|  1  |  2  |  3  |  4  |  5  |  6  |  7  |  8  |  9  |
+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|已确认|已确认| 已发送  | 已发送 | 已发送 | 已发送  |  待发送 | 待发送  |
       ^                                  ^
       |                                  |
    窗口左边界                          窗口右边界
       |<--------- 发送窗口(4) ---------->|

收到 ack=3 后,窗口右移:
+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|  1  |  2  |  3  |  4  |  5  |  6  |  7  |  8  |  9  |
+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|已确认|已确认|已确认| 已发送 | 已发送 | 已发送  | 已发送  | 待发送  |
              ^                                  ^
              |<--------- 发送窗口(4) ---------->|

5.3 流量控制

接收方通过 TCP 头部的窗口大小字段,告诉发送方自己的接收缓冲区还剩多少空间。发送方据此调整发送速率:

接收方缓冲区变化:

时刻1: 缓冲区空,通告窗口 = 4000 字节
  发送方: 可以发送最多 4000 字节

时刻2: 收到 2000 字节,应用还未读取,通告窗口 = 2000
  发送方: 最多再发 2000 字节

时刻3: 又收到 2000 字节,缓冲区满,通告窗口 = 0
  发送方: 停止发送!(零窗口)

时刻4: 应用读取了 3000 字节,通告窗口 = 3000
  发送方: 恢复发送

当窗口变为 0 时,发送方启动持续计时器,定期发送窗口探测报文(Window Probe),以检测窗口是否重新打开。


六、拥塞控制:对网络负责

6.1 拥塞控制 vs 流量控制

  • 流量控制:防止发送方淹没接收方(端到端问题)
  • 拥塞控制:防止发送方淹没网络(全局问题)

6.2 四大算法

TCP 的拥塞控制包含四个核心算法:慢启动拥塞避免快速重传快速恢复

拥塞窗口(cwnd)变化示意图:

cwnd
 ^
 |                            * <- 检测到丢包(超时)
 |                           *
 |                          *   拥塞避免(线性增长)
 |                        *
 |                      * <- ssthresh(慢启动阈值)
 |                    *
 |                  *
 |               *     慢启动(指数增长)
 |            *
 |         *
 |      *
 |   *
 | *
 +*----------------------------------------------> 时间
 |
 | 丢包后: ssthresh = cwnd/2, cwnd 重置
 |
 |       *
 |     *   重新慢启动...
 |   *
 | *

慢启动:经典定义中初始 cwnd=1 MSS(最大段大小),但现代系统已大幅提升——RFC 6928(2013)将推荐初始窗口增加到 10 MSS(约 14.6KB),Linux 2.6.39+ 默认即采用此值。每收到一个 ACK,cwnd 翻倍。增长看似”慢”,实际是指数增长。

拥塞避免:当 cwnd 达到慢启动阈值(ssthresh)后,改为每个 RTT 只增加 1 MSS,线性增长。

快速重传:收到 3 个重复 ACK 时立即重传,不等超时定时器。

快速恢复:快速重传后,ssthresh = cwnd/2,cwnd = ssthresh + 3,然后进入拥塞避免阶段。

6.3 现代拥塞控制算法

经典的 TCP Reno 算法在高带宽长延迟网络中效率不佳。现代算法包括:

算法特点适用场景
CUBICLinux 默认,基于三次函数调整窗口通用场景
BBRGoogle 开发,基于带宽和 RTT 估算高延迟、有丢包的网络
DCTCP数据中心优化,利用 ECN 标记数据中心内部

🤔 想一想 为什么 TCP 在移动网络(4G/5G)环境下性能不如预期?丢包和信号波动会如何影响拥塞控制?


七、超时重传与定时器

7.1 超时重传时间(RTO)的计算

RTO 不是固定值,而是根据网络状况动态调整的:

RTT 采样和 RTO 计算(Jacobson 算法):

SRTT = (1-α) * SRTT + α * RTT_sample     (平滑RTT, α=1/8)
RTTVAR = (1-β) * RTTVAR + β * |SRTT - RTT_sample|  (RTT偏差, β=1/4)
RTO = SRTT + 4 * RTTVAR

示例:
RTT采样: 100ms, 120ms, 90ms, 150ms
SRTT 逐步逼近实际 RTT 的加权平均
RTTVAR 反映 RTT 的波动程度
RTO 通常是 SRTT 的 1-3 倍

7.2 TCP 的重要定时器

定时器用途
重传定时器发送数据后启动,超时则重传
持续定时器零窗口时启动,定期探测窗口
保活定时器长时间无数据时探测连接是否存活(Keep-Alive)
2MSL 定时器TIME_WAIT 状态下等待 2 倍最大段寿命

八、TCP 连接的状态机

8.1 完整的状态转换图

                              +---------+
                              |  CLOSED |
                              +----+----+
                    主动打开 /      |      \ 被动打开
                   发送 SYN       |       创建 TCB
                      |          |          |
                      v          |          v
                 +---------+    |    +---------+
                 |SYN_SENT |    |    | LISTEN  |
                 +----+----+    |    +----+----+
       收到SYN+ACK/   |        |         | 收到SYN/
       发送ACK        |        |         | 发送SYN+ACK
                      v        |         v
                 +---------+   |   +---------+
                 |  ESTAB- |<--+-->|SYN_RCVD |
                 |  LISHED |       +----+----+
                 +----+----+            |
                      |           收到ACK |
          主动关闭/    |                  |
          发送FIN     |                  v
                      |           +---------+
                      v           |  ESTAB- |
                 +---------+      |  LISHED |
                 |FIN_WAIT1|      +---------+
                 +----+----+
        收到ACK/       |
                      v
                 +---------+     收到FIN/发送ACK
                 |FIN_WAIT2| ---------------+
                 +----+----+                |
        收到FIN/       |                    v
        发送ACK       |              +-----------+
                      v              | CLOSE_WAIT|
                 +---------+         +-----+-----+
                 |TIME_WAIT|               | 发送FIN
                 +----+----+               v
                      |              +-----------+
         2MSL超时     |              |  LAST_ACK |
                      v              +-----+-----+
                 +---------+               | 收到ACK
                 |  CLOSED |               v
                 +---------+         +---------+
                                     |  CLOSED |
                                     +---------+

使用 netstatss 命令可以查看当前系统中各 TCP 连接的状态:

$ ss -ant
State      Recv-Q Send-Q Local Address:Port  Peer Address:Port
LISTEN     0      128    0.0.0.0:80           0.0.0.0:*
ESTAB      0      0      192.168.1.10:52341  93.184.216.34:80
TIME_WAIT  0      0      192.168.1.10:52340  93.184.216.34:80

九、章节小结

  1. 三次握手建立连接,确保双方的发送和接收能力都正常,并协商初始序列号。
  2. 四次挥手断开连接,因全双工通信需要独立关闭两个方向。TIME_WAIT 等待 2MSL 确保旧数据包消亡。
  3. 序列号和确认号是可靠传输的基石,配合累积确认实现数据的有序、不丢、不重。
  4. 滑动窗口实现流量控制,防止发送方速率超过接收方处理能力。
  5. 拥塞控制(慢启动、拥塞避免、快速重传、快速恢复)防止网络过载,是 TCP 的全局责任感的体现。
  6. 超时重传通过动态计算 RTO 来适应不同的网络条件。

📝 结尾自测:检验你的收获

  1. 请详细描述 TCP 三次握手的过程,包括每一步的序列号和标志位变化。为什么不能是两次?
  2. TCP 四次挥手中,为什么需要 TIME_WAIT 状态?如果没有这个状态会出什么问题?
  3. 滑动窗口和拥塞窗口有什么区别?TCP 实际的发送窗口大小由什么决定?
  4. 解释快速重传的触发条件和工作机制:3 个重复 ACK 为什么意味着丢包?
  5. 在高并发 Web 服务器上经常看到大量 TIME_WAIT 连接,这是为什么?有哪些解决方案?

下一章预告:TCP 可靠但开销不小,并非所有场景都需要它。下一章我们将认识 TCP 的”轻量级兄弟” UDP——看看它为什么只有 8 字节的头部却能撑起 DNS 查询、视频通话等关键应用,同时深入 DNS、DHCP 等核心应用层协议的工作原理。

购买课程解锁全部内容

网络通信第一课:10 章掌握计算机网络

¥29.90