TCP与UDP

TCP 与 UDP

想象你正在开发一个实时对战游戏。当玩家按下“开火”键时,数据必须毫秒级到达服务器。如果网络稍微抖动,你希望发生什么?

  1. 游戏卡住,等待 2 秒,直到这个“开火”指令确认送达,然后画面瞬间快进。
  2. 丢弃这个指令,画面保持流畅,玩家可能只是觉得刚才没打中。

显然后者体验更好。

但在另一个场景,当你从银行下载电子账单时,如果底层发生丢包导致文件损坏或解析出极度荒谬的金额,这是绝对不可接受的。

这就是网络传输设计的核心矛盾:绝对的可靠性与极致的低延迟往往不可兼得

互联网协议栈给出了两种截然不同的策略:

  1. TCP(传输控制协议,Transmission Control Protocol):追求数据的绝对完整、有序和可靠交付,不惜牺牲传输效率。具有面向连接、可靠传输、面向字节流、全双工等特性。
  2. UDP(用户数据报协议,User Datagram Protocol):追求最快的传输速度和最低的延迟,对丢包不敏感。具有无连接、不可靠传输、面向数据报、轻量级等特性。

头部设计

TCP

TCP 头部则承载了维持可靠传输所需的“重型控制信息”。它的基础大小为 20 字节,如果加上可选(Options)字段,最大可达 60 字节。

这些看似繁杂的字段,主要为了解决两个核心问题:丢包乱序

为此,TCP 引入了以下机制:

1.序列号与确认号

  • 序列号 (Seq uence, 32 位): 解决 乱序 问题。发送方为字节流中的每一个字节都打上连续的编号,确保接收方能按原序重组被拆分的数据包。
  • 确认号 (Ack nowledgment/ack, 32 位):解决 丢包 问题。指明期望收到的下一个字节序号,作为接收方的回执。

2.标志位

这几个核心标志位决定了当前报文的性质与 TCP 状态机的走向:

  • SYN (Synchronize): 发起新连接请求。
  • ACK (Acknowledgment): 确认收到报文。
  • FIN (Finish): 正常结束连接。
  • RST (Reset): 重置连接(通常发生在网络异常、连接崩溃或目标端口未监听时)。
  • PSH (Push): 推送数据(催促接收方的操作系统内核尽快把数据上交给应用层进程,别在接收缓冲区里干攒着)。

小写的 ack(确认号字段,32 位数字)和全大写的 ACK(标志位,1 个比特的布尔值)是两个完全不同的概念。只有当 ACK 标志位为 1 时,前面的 ack 确认号字段才有效。

3.性能与安全保障

  • 窗口大小 (Window Size, 16 位):接收方通过此字段告诉发送方自己“接收缓冲区的剩余可用空间”,防止发送方发得太快把接收方撑爆(滑动窗口机制的核心)。
  • 校验和 (Checksum, 16 位):强制校验 TCP 头部和数据部分。一旦发现物理传输中发生了哪怕一个比特的翻转,内核会直接丢弃该包,等待对方超时重传。
UDP

UDP 的头部只有固定的 8 字节。它不打算在传输层做任何控制,只是为了把数据“分发”给应用程序。

  • 源/目的端口 (16 位):用于标识通信双方的应用进程。网络层的 IP 地址用于定位目标主机,此处的端口号则用于确定主机上具体的接收程序。
  • 长度 (16 位):头部与数据部分的总长度。
  • 校验和 (16 位):用于检测数据在传输过程中是否出现错误。若接收方计算结果与校验和不一致,会直接丢弃该数据包,此字段为可选字段(在 IPv4 中可选,在 IPv6 中强制要求)。

连接的建立:三次握手

TCP 三次握手有 两个 核心目的:一是 同步双方初始序列号(ISN,Initial Sequence Number),确认双方发送与接收能力均正常;二是 防止网络中滞留的历史连接报文导致错误建立连接

  • Step 1 (SYN):客户端发送 SYN=1,生成随机序列号 seq=x
  • Step 2 (SYN+ACK):服务端收到后,回复 SYN=1 (我也要连你) + ACK=1 (确认收到你的)。服务端生成自己的序列号 seq=y,确认号 ack=x+1
  • Step 3 (ACK):客户端收到后,检查 ack 是否正确,回复 ACK=1,确认号 ack=y+1

为什么初始序列号 (ISN) 必须是随机的?

为了防止黑客伪造 TCP 包(TCP 序列号预测攻击),也为了防止网络中滞留的旧数据包干扰新连接。

sequenceDiagram
    participant C as 客户端 (Client)
    participant S as 服务器 (Server)

    Note over C,S: 状态: CLOSED
    C->>S: SYN=1, Seq=x
    Note over C: SYN_SENT
    Note over S: LISTEN -> SYN_RCVD
    S->>C: SYN=1, ACK=1, Seq=y, Ack=x+1
    Note over C: ESTABLISHED
    C->>S: ACK=1, Seq=x+1, Ack=y+1
    Note over S: ESTABLISHED
    Note right of C: 连接建立,可以传输数据

为什么是三次握手?

为什么不是两次?

主要目的是 防止历史连接的初始化

假设客户端发出的第一个 SYN 包在网络中滞留了很久,客户端超时重发并建立了新连接。数据传完断开后,那个滞留的“旧 SYN”终于到了服务器。

  • 如果是两次握手:服务器收到旧 SYN 以为是新连接,直接分配资源,导致服务器空挂一个连接。
  • 如果是三次握手:服务器收到旧 SYN,回复 SYN+ACK。客户端收到后,发现 Ack 对应的是旧请求,直接发送 RST 报文终止,避免资源浪费。

断开连接:四次挥手

TCP 是全双工的(双向独立通道),因此关闭需要两个方向分别操作。

  • 管道 A:客户端 $\rightarrow$ 服务器
  • 管道 B:服务器 $\rightarrow$ 客户端

主动关闭:只是切断了 管道 A管道 B 还通着,因为 被动方还要发数据

sequenceDiagram
    participant C as 客户端 (主动关闭)
    participant S as 服务器 (被动关闭)

    Note over C,S: 场景:客户端请求下载大文档
    C->>S: 发送请求 "GET /movie.mp4"
    C->>S: FIN (我请求发完了,我不说话了)
    Note over C: 状态: FIN_WAIT_1
    S->>C: ACK (知道了,你闭嘴吧)
    Note over C: 状态: FIN_WAIT_2 (耳朵还竖着)
    
    Note right of S: 【关键阶段】<br/>服务器开始疯狂发送 1GB 的电影数据
    S->>C: 数据包 1 (电影开头...)
    C-->>S: ACK (收到了)
    S->>C: 数据包 2 (电影中段...)
    C-->>S: ACK (收到了)
    S->>C: ... 数据包 N ...
    C-->>S: ACK (收到了)

    Note right of S: 电影发完了
    S->>C: FIN (我也说完了)
    Note over C: 状态: TIME_WAIT
    C->>S: ACK (好的,再见)
    Note over S: CLOSED
  • Step 1 (FIN):主动方发 FIN。主动方:我发完了,申请关闭。
  • Step 2 (ACK):被动方回 ACK。此时连接处于 半关闭 状态。此时被动方可能还有剩余数据要发送。
  • Step 3 (FIN):被动方数据传完后,也发 FIN
  • Step 4 (ACK):主动方回 ACK,并进入 TIME_WAIT(等待 2MSL)。服务端收到后直接 CLOSED
sequenceDiagram
    participant A as 主动方 (Active)
    participant P as 被动方 (Passive)

    Note over A: ESTABLISHED
    A->>P: FIN=1, Seq=u
    Note over A: FIN_WAIT_1
    Note over P: CLOSE_WAIT
    P->>A: ACK=1, Ack=u+1
    Note over A: FIN_WAIT_2
    Note right of P: 此时 P 仍可发送剩余数据
    P->>A: FIN=1, Seq=w, Ack=u+1
    Note over P: LAST_ACK
    A->>P: ACK=1, Ack=w+1
    Note over A: TIME_WAIT (2MSL)
    Note over P: CLOSED
    Note over A: CLOSED

为什么 主动关闭方要进入 TIME_WAIT 状态等待 2MSL

MSL(Maximum Segment Lifetime):报文最大生存时间

如果 A 回复完最后一个 ACK 直接跑路(CLOSED):

  1. 可靠性崩塌:如果最后一个 ACK 丢了,B 会重发 FIN。如果 A 已经关机或释放了端口,B 就会收到 RST 错误,导致传输不完美的结束。
  2. 幽灵数据:IP 协议允许数据包在网络中“迷路”一段时间。如果没有 TIME_WAIT,A 立即在同一个端口启动了新连接。此时,那个迷路的老数据包突然到达,会被新连接误接收,导致数据错乱。

在高并发短连接场景(如 Web 服务器)下,大量的 TIME_WAIT 会占用服务器端口资源(最多 65535 个)。

优化方案:开启 tcp_tw_reuse(允许安全重用)。但不要开启 tcp_tw_recycle,因为这个参数在 NAT(如现今几乎所有的家庭/公司路由器)环境下会导致严重的丢包问题,Linux 内核已经在 4.12 版本(2017 年)直接把这个参数废弃并删除了

数据流与“粘包”问题

TCP 是 面向字节流 (Byte Stream) 的。这意味着 TCP 不把数据看作一个个“包”,而是一条连绵不断的水流。

你在客户端 send("Hello") 两次。服务端可能收到 recv("HelloHello"),也可能收到 recv("He")recv("lloHello")。这就是俗称的“粘包”。

解决办法:应用层协议必须自己定义边界。通用做法是 Header (固定长度存 Body 长度) + Body

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def read_message(socket):
# 1. 先读取固定长度的头部 (假设头部 4 字节存长度)
header_data = read_exactly(socket, 4)
if not header_data:
return None

# 2. 解析出消息体长度
body_length = decode_int(header_data)
# 3. 根据长度读取完整的消息体
body_data = read_exactly(socket, body_length)

return body_data

def read_exactly(socket, n):
# 循环读取,直到读够 n 个字节
data = b''
while len(data) < n:
packet = socket.recv(n - len(data))
if not packet:
return None
data += packet
return data

TCP 是字节流实际只是 应用层的视角。在 传输层实现网络层,TCP 数据确实是以 离散的单元 传输的。

1
2
3
应用层视角:   [连续的字节流 "HelloWorld"]  ← 你看到的"流"
传输层实现: [Segment1:"Hell"] [Segment2:"oWor"] [Segment3:"ld"] ← 实际的"包"
网络层(IP): [Packet1][Packet2][Packet3] ← IP数据报(可能还有分片)

当这些“包”到达接收端的操作系统时,接收端的 TCP 协议栈会根据包上的序号,把它们重新倒进一个大水缸(接收缓冲区)里,并处理乱序和丢包重传。当应用程序去读的时候,它看到的依然是一池子连贯的水(流)。它根本不知道底下其实是一桶一桶运过来的。

流量控制 vs. 拥塞控制

这个两个完全不同的问题:接收方吃不消 vs 网络堵死了

流量控制:保护接收方

机制:滑动窗口 (Sliding Window)

如果发送方是高性能服务器,接收方是一台老旧手机。如果服务器全速发送,手机的处理速度跟不上,缓冲区爆满,数据就会溢出丢失。

所以接收方会在 ACK 中带上自己的剩余缓存大小 (Window),发送方据此限速。如果缓存满了,发送方会停止发送(零窗口探测)。直到接收方发送窗口更新通知。

拥塞控制:保护网络环境

机制:拥塞窗口 (cwnd, Congestion Window)。

发送方维护一个状态变量 cwnd,表示在未收到 ACK 之前,我敢往网络里扔多少数据。通过不断的试探来猜测网络的承载极限。

四大核心算法
  • 慢启动:刚创建连接时,不知道网络深浅,如果上来就全速发送,可能会瞬间把网络堵死。所以连接刚创建时,cwnd = 1。然后采取指数级增长加倍 cwnd(1 -> 2 -> 4 -> 8…)。所以是“慢启动”但“快加速”,目的是在短时间内找到网络的带宽上限。
  • 拥塞避免:当 cwnd 增长到阈值(ssthresh)时,说明快接近极限了,不能再翻倍了,要小心翼翼地线性增长(加法增大),每经过一个 RTT,cwnd 只增加 1,以此来缓慢探测网络的最高吞吐量。
  • 超时重传(严重拥堵):当出现丢包(超时或收到重复 ACK)时,TCP 认为网络拥堵了,必须踩刹车。当出现 超时重传(RTO Timeout) 时,说明连 ACK 都回不来了,TCP 会判定网络极度拥堵。cwnd 直接重置为 1,重新进入“慢启动”。
  • 快重传与快恢复:但有时候丢包不是因为拥堵,而是因为偶尔的链路错误。如果只丢了一个包,接收方会连续回复 3 个相同的 ACK。
    • 快重传:发送方一旦收到 3 个重复的 ACK,就知道“有个特定的包丢了”,立刻重传该包,而 不等待超时定时器
    • 快恢复:既然还能收到重复 ACK,说明网络没断,不需要重置为 1。于是将 cwnd 减半(乘法减小),然后进入“拥塞避免”阶段。

TCP 的发送速度 = min(接收方窗口, 拥塞窗口)。 它永远在 “试探 -> 增长 -> 丢包 -> 减速 -> 再试探” 的循环中寻找平衡点。这就是为什么下载大文档时,速度曲线总是呈锯齿状波动的原因。

TCP 的局限与未来

TCP 设计于 70 年代,它强制要求 绝对有序。这就导致了致命的 队头阻塞 问题:如果 TCP 窗口内的一个包丢失,即使后续的包都到了,内核也不能把数据交给应用层,必须等那个丢失的包重传回来。这在移动端(进电梯、切基站)会导致巨大的延迟飙升。

为了解决这个问题,Google 推出了 QUIC(HTTP/3) 协议。有趣的是,QUIC 底层使用的是 UDP。它在应用层重新实现了可靠传输和拥塞控制,但抛弃了 TCP 的强序依赖,真正实现了多路复用,成为了下一代互联网传输的基石。


TCP与UDP
https://gavinmo1.github.io/2026/02/12/TCP与UDP/
作者
Gavin
发布于
2026年2月12日
许可协议