UDP 与可靠传输

用生活化的比喻,让你理解 UDP 的特点、QUIC 协议、以及为什么游戏用 UDP 而不是 TCP

前置知识:第06章 TCP/IP 协议栈(理解 TCP 的拥塞控制和队头阻塞问题)


阅读指南(初学者必看)

为什么你需要学习 UDP 与可靠传输?

上一章你学了 TCP 的致命问题——队头阻塞和拥塞控制对游戏不利。那游戏用什么?UDP!但 UDP 不可靠,怎么保证消息送达?

  • 为什么游戏用 UDP?UDP 和 TCP 的核心区别是什么?
  • QUIC 是什么?为什么 HTTP/3 基于 QUIC 而不是 TCP?
  • 怎么在 UDP 上实现可靠传输?

学完本章,你能回答:

  • UDP 和 TCP 的核心区别是什么?为什么游戏用 UDP?
  • QUIC 协议解决了 TCP 的哪些问题?
  • 0-RTT 连接建立是怎么做到的?
  • 怎么在 UDP 上实现自定义可靠传输?

本文结构

第一部分:UDP 的特点(和 TCP 的对比)
第二部分:UDP 编程
第三部分:QUIC 协议(UDP 上的可靠传输 + 多路复用 + 加密)
第四部分:自定义可靠 UDP

7.1 UDP 的特点

生活比喻:UDP 就像寄明信片。

TCP:挂号信
- 需要登记(建立连接)
- 确认收到(确认应答)
- 丢失可查(可靠传输)
- 速度较慢

UDP:明信片
- 直接投递(无连接)
- 不确认收到(不可靠)
- 可能丢失(无保证)
- 速度快
UDP vs TCP:

| | UDP | TCP |
|---|---|---|
| 连接 | 无连接 | 面向连接 |
| 可靠性 | 不保证送达 | 保证送达 |
| 有序性 | 不保证有序 | 保证有序 |
| 流量控制 | 无 | 有(滑动窗口)|
| 拥塞控制 | 无 | 有 |
| 头部大小 | 8 字节 | 20~60 字节 |
| 传输效率 | 高 | 低 |
| 适用场景 | 实时游戏、视频通话 | 文件传输、网页浏览 |

为什么游戏用 UDP?
1. 不需要等重传 → 低延迟
2. 丢了一个位置更新无所谓 → 下一个更新很快就来
3. 没有拥塞控制 → 不会因为丢包而大幅降速
4. 没有队头阻塞 → 一个包丢了不影响其他包

UDP 头部结构

 0      7 8     15 16    23 24    31
+--------+--------+--------+--------+
|     Source      |   Destination   |
|      Port       |      Port       |
+--------+--------+--------+--------+
|     Length      |    Checksum     |
+--------+--------+--------+--------+
|             Data...               |
+-----------------------------------+

头部只有8字节,非常精简!

7.2 QUIC 协议

生活类比:QUIC 就像"快递升级版"。TCP 是普通快递(必须按顺序签收,一个丢了后面的都等着),QUIC 是智能快递(每个包裹独立签收,互不影响)。

QUIC = UDP 上的可靠传输 + 多路复用 + 加密

核心特性:
1. 基于 UDP → 不需要内核修改,用户态实现
2. 0-RTT 连接建立 → 首次 1-RTT,重连 0-RTT
3. 连接迁移 → IP 变化不断连(WiFi → 4G 无缝切换)
4. 多路复用无队头阻塞 → 每个流独立
5. 内置 TLS 1.3 → 所有数据加密

TCP 队头阻塞 vs QUIC:
TCP:  流A丢包 → 流A/B/C 全部等待
QUIC: 流A丢包 → 只有流A等待,流B/C 继续

0-RTT 连接建立

TCP + TLS 1.3:
  客户端 → SYN → 服务器          (1 RTT)
  客户端 ← SYN-ACK ← 服务器      (1 RTT)
  客户端 → ClientHello → 服务器   (1 RTT)
  客户端 ← ServerHello ← 服务器   (1 RTT)
  客户端 → 请求 → 服务器
  总计:3 RTT 后才能发数据

QUIC:
  首次连接:
  客户端 → CHLO → 服务器         (1 RTT)
  客户端 ← SHLO ← 服务器         (1 RTT)
  客户端 → 请求 → 服务器
  总计:1 RTT 后就能发数据

  重连(0-RTT):
  客户端 → CHLO + 请求 → 服务器   (0 RTT!)
  服务器直接处理请求
  总计:0 RTT 后就能发数据

游戏场景:
- 首次进入游戏:1 RTT 建立 QUIC 连接
- 断线重连:0 RTT 恢复连接
- 网络切换(WiFi→4G):连接不断!

7.3 自定义可靠 UDP

为什么需要可靠 UDP

游戏场景需要:
- 低延迟(UDP优势)
- 可靠性(某些数据不能丢,如技能释放)
- 有序性(状态更新顺序重要)

解决方案:在UDP之上实现可靠传输机制
核心机制:
1. ACK 确认——接收方收到包后发送确认
2. 序号——给每个包编号,检测丢包和重排序
3. 重传——超时未确认的包重发(选择性重传比全部重传高效)
4. 流量控制——类似 TCP 的滑动窗口

序列号与确认

class ReliablePacket {
  constructor(seq, data, flags = {}) {
    this.seq = seq;
    this.data = data;
    this.ack = flags.ack || 0;
    this.ackBits = flags.ackBits || 0;
    this.timestamp = Date.now();
  }

  serialize() {
    let buffer = new ArrayBuffer(12 + this.data.length);
    let view = new DataView(buffer);
    view.setUint32(0, this.seq);
    view.setUint32(4, this.ack);
    view.setUint32(8, this.ackBits);
    let dataView = new Uint8Array(buffer, 12);
    for (let i = 0; i < this.data.length; i++) {
      dataView[i] = this.data[i];
    }
    return new Uint8Array(buffer);
  }
}

class ReliableTransport {
  constructor() {
    this.localSeq = 0;
    this.remoteSeq = 0;
    this.sentPackets = new Map();
    this.receivedPackets = new Map();
    this.resendTimeout = 100;
    this.maxResends = 5;
  }

  send(data) {
    let packet = new ReliablePacket(this.localSeq, data, {
      ack: this.remoteSeq,
      ackBits: this.calculateAckBits()
    });
    this.sentPackets.set(this.localSeq, {
      packet, sends: 1, lastSend: Date.now()
    });
    this.localSeq++;
    return packet.serialize();
  }

  calculateAckBits() {
    let bits = 0;
    for (let i = 0; i < 32; i++) {
      let seq = this.remoteSeq - i - 1;
      if (this.receivedPackets.has(seq)) {
        bits |= (1 << i);
      }
    }
    return bits;
  }

  checkResends() {
    let now = Date.now();
    let toResend = [];
    for (let [seq, info] of this.sentPackets) {
      if (now - info.lastSend > this.resendTimeout) {
        if (info.sends >= this.maxResends) {
          this.sentPackets.delete(seq);
          continue;
        }
        toResend.push({ seq, packet: info.packet });
        info.sends++;
        info.lastSend = now;
      }
    }
    return toResend;
  }
}

滑动窗口实现

class SlidingWindowProtocol {
  constructor(windowSize = 32) {
    this.windowSize = windowSize;
    this.sendWindow = new Map();
    this.recvWindow = new Map();
    this.sendBase = 0;
    this.recvBase = 0;
    this.nextSeq = 0;
  }

  canSend() {
    return this.nextSeq < this.sendBase + this.windowSize;
  }

  send(data) {
    if (!this.canSend()) return null;
    let seq = this.nextSeq++;
    this.sendWindow.set(seq, { data, timestamp: Date.now(), acked: false });
    return { seq, data };
  }

  receive(seq, data) {
    if (seq >= this.recvBase && seq < this.recvBase + this.windowSize) {
      this.recvWindow.set(seq, data);
      this.slideRecvWindow();
      return true;
    }
    return false;
  }

  slideRecvWindow() {
    let deliverable = [];
    while (this.recvWindow.has(this.recvBase)) {
      deliverable.push({ seq: this.recvBase, data: this.recvWindow.get(this.recvBase) });
      this.recvWindow.delete(this.recvBase);
      this.recvBase++;
    }
    return deliverable;
  }
}

7.4 游戏状态同步

class GameStateSync {
  constructor() {
    this.transport = new ReliableTransport();
    this.localState = {};
    this.remoteState = {};
    this.stateSeq = 0;
  }

  updateState(newState) {
    this.localState = { ...this.localState, ...newState };
    this.stateSeq++;
    let delta = this.calculateDelta(this.localState, this.lastSentState || {});
    if (Object.keys(delta).length > 0) {
      let data = JSON.stringify({ seq: this.stateSeq, state: delta });
      this.transport.send(data);
      this.lastSentState = { ...this.localState };
    }
  }

  calculateDelta(current, previous) {
    let delta = {};
    for (let key in current) {
      if (current[key] !== previous[key]) {
        delta[key] = current[key];
      }
    }
    return delta;
  }

  receiveState(buffer) {
    let packet = this.transport.receive(buffer);
    if (packet.seq > (this.lastReceivedSeq || 0)) {
      let state = JSON.parse(packet.data);
      this.remoteState = { ...this.remoteState, ...state.state };
      this.lastReceivedSeq = packet.seq;
    }
  }
}

自问自答

Q:UDP 不可靠,为什么游戏还用 UDP? A:因为游戏对"延迟"的要求远高于"可靠性"。一个位置更新丢了,不需要重传——下一个更新 50ms 后就来了。但 TCP 丢包后会:1)等待重传(增加延迟);2)后续包被队头阻塞;3)拥塞控制降速。这些对实时游戏是致命的。关键数据(如技能释放)可以在应用层实现可靠传输。

Q:QUIC 为什么基于 UDP 而不是直接修改 TCP? A:1)TCP 在操作系统内核中实现,修改需要更新所有操作系统,推广周期可能数十年;2)中间网络设备(NAT、防火墙)认识 TCP 但不认识新协议,会被丢弃;3)UDP 是"白名单"——只要应用层实现了就能用,不需要操作系统支持。所以 QUIC 选择在用户态基于 UDP 实现。

Q:0-RTT 连接安全吗? A:0-RTT 有"重放攻击"的风险——攻击者可以重放之前捕获的 0-RTT 请求。QUIC 通过以下方式缓解:1)服务器为每次连接生成新的随机数;2)0-RTT 数据不能包含非幂等操作;3)客户端和服务器共同维护重放窗口。对于游戏来说,0-RTT 通常只用于恢复连接上下文,真正的游戏操作在 1-RTT 之后。

Q:连接迁移是怎么实现的? A:TCP 用"四元组"(源IP、源端口、目标IP、目标端口)标识连接,IP 变化 = 新连接。QUIC 用"Connection ID"标识连接,Connection ID 不随 IP 变化。WiFi→4G 切换时,IP 变了但 Connection ID 没变,服务器识别出这是同一个连接,继续通信。这对移动游戏特别重要!

Q:怎么在 UDP 上实现可靠传输? A:核心机制:1)ACK 确认——接收方收到包后发送确认;2)序号——给每个包编号,检测丢包和重排序;3)重传——超时未确认的包重发(选择性重传比全部重传高效);4)流量控制——类似 TCP 的滑动窗口。QUIC 就是这样做的,但比 TCP 更灵活——可以按流独立控制。


实践任务

  • 任务1:用 Wireshark 抓包,对比 TCP 和 UDP 的头部结构,理解 UDP 为什么更轻量
  • 任务2:用 curl --http3 测试一个支持 HTTP/3 的网站,观察 QUIC 的连接建立过程
  • 任务3:实现一个简易可靠 UDP——给 UDP 包加序号,实现 ACK + 选择性重传
  • 任务4:测试 WiFi→4G 切换时 TCP 和 QUIC 的表现差异(如果有条件)
  • 任务5:对比 TCP 和 UDP 在丢包环境下的延迟差异(用 tc netem 模拟丢包)

与其他章节的关联

本章内容 关联章节 关联点
UDP vs TCP 第06章 TCP/IP UDP 解决了 TCP 的队头阻塞和拥塞控制问题
QUIC 第08章 HTTP/2与HTTP/3 HTTP/3 基于 QUIC,解决了 TCP 层的问题
连接迁移 第09章 网络安全 QUIC 内置 TLS 1.3,连接迁移不影响加密
可靠传输 第10章 实战篇 游戏网络同步需要自定义可靠传输

上一章:06-TCPIP协议栈深度 | 下一章:08-HTTP2与HTTP3