土法炼钢兴趣小组的算法知识备份

QUIC 协议拆解(上):为什么 TCP 改不动了

目录

打开一个普通的 HTTPS 网页,你的浏览器在发出第一个字节的 HTTP 请求之前,至少要完成:

  1. TCP 三次握手 — 1 RTT
  2. TLS 1.3 握手 — 1 RTT(如果是 TLS 1.2 还要 2 RTT)

也就是说,在跨太平洋链路上(RTT ≈ 150ms),你要白等 300ms 才能开始干正事。如果是 TLS 1.2,那就是 450ms

而 QUIC 首次连接只需要 1 RTT,重连甚至可以 0 RTT —— 第一个包就带上 HTTP 请求数据。

这不是小幅优化,这是代际差异。为什么 TCP 做不到?不是不想,是基因决定了它改不动


一、TCP 的三个设计级缺陷

TCP 诞生于 1981 年(RFC 793),在那个年代它是伟大的工程杰作。但 45 年后的今天,它的三个核心设计决策变成了无法绕开的枷锁。

1.1 队头阻塞(Head-of-Line Blocking)

TCP 提供的是一条严格有序的字节流。这意味着:

在 HTTP/2 这样的多路复用协议下,问题更严重。假设你在一条 TCP 连接上复用了 3 个请求(流 A、流 B、流 C),流 B 的一个包丢了:

这就是 TCP 层的队头阻塞,HTTP/2 的多路复用在传输层被打回原形。

TCP vs QUIC 队头阻塞对比

来看一个直观的对比。假设丢包率 2%,并发 10 个流:

场景 TCP (HTTP/2) QUIC (HTTP/3)
丢包影响范围 全部 10 个流 仅丢包所在的 1 个流
其余 9 个流延迟 增加 1 个 RTT(等重传) 无影响
在高丢包网络的表现 指数级恶化 线性优雅降级

1.2 连接绑定四元组

TCP 连接由四元组唯一标识:

(源 IP, 源端口, 目的 IP, 目的端口)

只要这四个值中任何一个变了,连接就断了。这在 2026 年意味着什么?

每次断开都是几百毫秒的重建成本,外加应用层状态的丢失。你在视频通话中走出电梯的那一秒卡顿,很可能就是 TCP 连接重建造成的。

1.3 协议僵化(Ossification)

这可能是最致命的问题。TCP 的头部是明文的,这意味着网络中间的设备(middlebox)——防火墙、NAT、负载均衡器——可以看到并解析 TCP 头部的每一个字段。

后果是什么?这些中间设备基于 TCP 头部的格式做了大量假设:

中间设备的逻辑(伪代码):

if tcp_header.data_offset > 5:
    # 有 TCP option
    for option in tcp_options:
        if option.kind == UNKNOWN:
            DROP_PACKET()    # 不认识?丢掉!
            # 或者更糟:
            STRIP_OPTION()   # 不认识?删掉这个 option!

这导致了一个恶性循环:

  1. IETF 设计了新的 TCP 扩展(比如 TCP Fast Open、MPTCP)
  2. 中间设备不认识新 option → 丢包或剥离
  3. 新扩展在公网上大面积失败
  4. 没人敢用 → 没人部署 → 永远不会有足够的部署量来逼中间设备升级

MPTCP 就是最好的反面教材

MPTCP (Multipath TCP, RFC 8684) 的部署困境:

设计目标:允许一条 TCP 连接使用多条路径(WiFi + 4G)
现实情况:
  - 2013 年标准化
  - 2026 年,公网上仍有约 6% 的路径会剥离 MPTCP option
  - 只有 Apple(iOS/macOS)大规模使用
  - 需要客户端 + 服务端 + 所有中间设备都支持
  - 13 年了,全球渗透率不足 5%

结论:在 TCP 框架内做创新,基本等于在沙滩上盖高楼

TCP 的问题不是某个具体的功能缺失,而是架构层面的不可演进性。你不能给一辆行驶中的汽车换发动机,尤其是当路上所有的交通灯都是按老发动机的速度设计的。


二、QUIC 的设计哲学:把 TCP+TLS 合体

QUIC(RFC 9000, 2021)的核心思路很简单:既然改不动 TCP,那就在 UDP 上重新造一个

2.1 为什么选 UDP

UDP 是一个极简协议——只有源端口、目的端口、长度、校验和,共 8 字节头部。中间设备对 UDP 的假设极少:

UDP 报文结构(8 字节):
+------------------+------------------+
|   源端口 (16b)   |  目的端口 (16b)  |
+------------------+------------------+
|   长度 (16b)     |  校验和 (16b)    |
+------------------+------------------+
|              载荷 ...               |
+-------------------------------------+

中间设备不会去解析 UDP 载荷的内容,所以 QUIC 可以在 UDP 载荷里放任何它想要的东西——而且不用担心被篡改或丢弃

2.2 加密一切

QUIC 的第二个关键决策:几乎所有头部字段都是加密的

QUIC Long Header(Initial 包):
+------------------+------------------+
| Header Form (1b) | Fixed Bit (1b)   |  ← 明文(仅 2 bit)
| Pkt Type (2b)    | Reserved (2b)    |
| Pkt Num Len (2b) |                  |
+------------------+------------------+
| Version (32b)                       |  ← 明文
+-------------------------------------+
| DCID Len (8b) | DCID (0-160b)      |  ← 明文(连接路由需要)
| SCID Len (8b) | SCID (0-160b)      |  ← 明文
+-------------------------------------+
| Token Length   | Token (变长)        |  ← 明文(仅 Initial)
+-------------------------------------+
| Length (变长编码)                     |
+-------------------------------------+
| Packet Number (1-4B)                |  ← 加密
+-------------------------------------+
| Payload (Frames...)                 |  ← 加密 + 认证
+-------------------------------------+

QUIC Short Header(1-RTT 包,握手完成后):
+------------------+------------------+
| Header Form (1b) | Fixed Bit (1b)   |  ← 明文
| Spin Bit (1b)    | Reserved (2b)    |
| Key Phase (1b)   | Pkt Num Len (2b) |
+------------------+------------------+
| DCID (变长,由对端决定)               |  ← 明文
+-------------------------------------+
| Packet Number (1-4B)                |  ← 加密
+-------------------------------------+
| Payload (Frames...)                 |  ← 加密 + 认证
+-------------------------------------+

这意味着中间设备看不到包号、帧类型、流 ID、确认信息……什么都看不到。它们能看到的只有 UDP 的四元组和 QUIC 的连接 ID。

好处显而易见:中间设备无法对看不到的东西做假设,也就无法阻止协议演进

2.3 用户态实现

TCP 是操作系统内核的一部分。想修复一个 TCP 的 bug,你得:

  1. 提交 Linux 内核补丁
  2. 等待审核、合入、发布新内核版本
  3. 等各大发行版更新内核
  4. 等企业客户升级系统

整个周期可能是几个月到几年

QUIC 是在用户态实现的,跑在应用程序里。想升级 QUIC?更新一下你的应用就行。Google 在 Chrome 里就是这么干的——每 6 周一个新版本,QUIC 实现跟着迭代。

用户态传输的代价

把传输层搬到用户态换来了升级自由,但代价不小——你失去了内核几十年积累的硬件协作优化。

没有零拷贝 sendmsg。 TCP 的 sendfile()splice() 可以让数据在内核缓冲区之间直接搬运,完全不经过用户态。QUIC 做不到——应用层必须把数据拷贝到用户态的 QUIC 库,库做完加密和帧封装后,再通过 sendmsg() 把 UDP 包拷回内核。每个包至少多一次内存拷贝。

没有内核 TSO/GRO。 TCP Segmentation Offload 让网卡硬件负责把大块数据切成 MSS 大小的段,CPU 只需要处理一个大包。Generic Receive Offload 反过来,把多个小包合并成一个大包再交给协议栈。QUIC 包头是加密的,网卡根本看不懂——无法做硬件分段,也很难做接收合并。虽然 Linux 5.x 开始支持 UDP GSO(Generic Segmentation Offload),QUIC 可以利用它在软件层做类似的事,但效率远不如硬件 TSO。

调度公平性问题。 内核的 TCP 栈和进程调度器深度集成——TCP 的拥塞窗口、发送时机都由内核统一协调,多个连接能公平竞争 CPU 和网络带宽。QUIC 跑在用户态,它的发送时机取决于应用进程什么时候被调度到 CPU 上。如果你的 QUIC 进程和一个 CPU 密集型进程共享同一核心,QUIC 的发送可能被延迟几毫秒——这对实时性敏感的应用是致命的。

但升级自由是真的值钱。 定量对比:

维度 TCP(内核态) QUIC(用户态) 差距
发送路径内存拷贝 0 次(sendfile) 至少 1 次 +1 拷贝
分段/合并 硬件 TSO/GRO 软件 UDP GSO(部分) CPU +30-50%
加密 offload kTLS + 网卡硬件 纯用户态 CPU +20-40%
相同吞吐 CPU 开销 基准 高 2-3 倍 Google 实测
新特性部署周期 3-24 个月(内核+发行版) 6 周(应用更新) ~10-100x 更快
bug 修复周期 内核补丁+回合 下一次部署 天 vs 月

Google 的选择很务实:CPU 开销可以用硬件堆,但 TCP 的不可演进性是花钱买不到解决方案的。对于一个每天服务数十亿请求的公司,能在 6 周内修复一个传输层 bug 比节省 2 倍 CPU 重要得多。

# 主流 QUIC 实现(2026 年)
# Google:     quiche (Rust/C++)  - Chromium 内置
# Cloudflare: quiche (Rust)      - 独立库
# Meta:       mvfst (C++)        - 用于 Instagram/WhatsApp
# Microsoft:  msquic (C)         - Windows/Xbox
# Mozilla:    neqo (Rust)        - Firefox 内置
# Go 生态:    quic-go (Go)       - 最流行的 Go 实现

三、流多路复用:真正的独立流

HTTP/2 在应用层实现了多路复用,但底层 TCP 只有一条字节流。QUIC 把流(Stream)作为一等公民内置在传输层。

3.1 QUIC Stream 的概念

每个 QUIC 连接可以承载多个 Stream,每个 Stream 是独立的有序字节流:

QUIC 连接
├── Stream 0 (控制流)
├── Stream 4 (请求 A)        ← 独立的排序空间
├── Stream 8 (请求 B)        ← 独立的排序空间
├── Stream 12 (请求 C)       ← 独立的排序空间
└── ...最多 2^62 个 Stream

关键区别:Stream 之间没有排序关系。Stream 4 丢了一个包,不影响 Stream 8 和 12 的数据交付。这是从根本上消除了 HTTP/2 over TCP 的队头阻塞。

3.2 Stream ID 编码规则

Stream ID 的最低 2 位编码了两个信息:

Stream ID 的低 2 位:
+------+------+-----------------------+
| Bit1 | Bit0 | 含义                  |
+------+------+-----------------------+
|  0   |  0   | 客户端发起,双向流     |  → 0, 4, 8, 12, ...
|  0   |  1   | 服务端发起,双向流     |  → 1, 5, 9, 13, ...
|  1   |  0   | 客户端发起,单向流     |  → 2, 6, 10, 14, ...
|  1   |  1   | 服务端发起,单向流     |  → 3, 7, 11, 15, ...
+------+------+-----------------------+

示例:
  Stream ID = 4  → 二进制 100 → 低 2 位 00 → 客户端发起的双向流
  Stream ID = 7  → 二进制 111 → 低 2 位 11 → 服务端发起的单向流
  Stream ID = 10 → 二进制 1010 → 低 2 位 10 → 客户端发起的单向流

这个设计非常精妙:

3.3 每个 Stream 独立流控

TCP 的流量控制(rwnd)是连接级的——一个慢消费者会拖慢整个连接上的所有数据。

QUIC 实现了两级流量控制

流量控制层次:

1. Stream 级别:每个 Stream 有自己的接收窗口
   MAX_STREAM_DATA frame → 告诉对端"Stream X 可以再发 N 字节"

2. 连接级别:所有 Stream 共享的总窗口
   MAX_DATA frame → 告诉对端"整个连接可以再发 N 字节"

效果:
  Stream 4 的消费者很慢?→ 只限制 Stream 4 的发送速率
  Stream 8 的消费者很快?→ 不受影响,继续全速接收

用 Go 语言伪代码说明:

// TCP: 全局流控,所有流共享一个窗口
type TCPConnection struct {
    recvWindow int  // 整个连接只有一个窗口
    // 一个流阻塞 → 窗口填满 → 所有流停止
}

// QUIC: 每个 Stream 独立流控
type QUICConnection struct {
    connWindow int              // 连接级窗口
    streams    map[uint64]*Stream
}

type Stream struct {
    id         uint64
    recvWindow int  // 每个 Stream 有自己的窗口
    recvBuffer []byte
    // 这个 Stream 阻塞不影响其他 Stream
}

3.4 双向流 vs 单向流

特性 双向流 (Bidirectional) 单向流 (Unidirectional)
数据方向 双方都能发送 只有发起方能发送
HTTP/3 用途 请求-响应对 控制流、QPACK 编解码流、推送
创建方式 任何一方 任何一方
Stream ID 低位 0001 1011
关闭方式 任何一方发 FIN 发送方发 FIN

HTTP/3 中一个典型的请求对应一个双向流:

Client                          Server
  |                               |
  |--- Stream 0 (双向) ---------->|  请求 GET /index.html
  |<-- Stream 0 (双向) -----------|  响应 200 OK + HTML
  |                               |
  |--- Stream 4 (双向) ---------->|  请求 GET /style.css
  |<-- Stream 4 (双向) -----------|  响应 200 OK + CSS
  |                               |
  |<-- Stream 3 (单向) -----------|  QPACK 编码器流
  |--- Stream 2 (单向) ---------->|  QPACK 编码器流

四、连接迁移:Connection ID 的妙用

TCP 用四元组标识连接,IP 一变连接就断。QUIC 用 Connection ID 来标识连接,从根本上解耦了连接身份和网络路径。

4.1 Connection ID 的基本原理

TCP 连接标识:
  (192.168.1.100, 54321, 93.184.216.34, 443)
  WiFi 切 4G → IP 变成 10.0.0.5 → 连接断开 ✕

QUIC 连接标识:
  Connection ID: 0x8a3f7c2e...(随机生成,与 IP 无关)
  WiFi 切 4G → IP 变了,但 Connection ID 没变 → 连接继续 ✓

Connection ID 在 QUIC 包头中以明文传输(这是少数几个不加密的字段之一),因为服务端需要用它来路由到正确的连接状态。

但是,如果 Connection ID 一直不变,中间的观察者可以用它来追踪用户的网络迁移行为。所以 QUIC 引入了 Connection ID 轮换

连接迁移过程中的 CID 轮换:

1. 握手阶段
   Client → Server: DCID=ServerCID_0, SCID=ClientCID_0
   Server → Client: DCID=ClientCID_0, SCID=ServerCID_0

2. 服务端预分配新 CID
   Server → Client: NEW_CONNECTION_ID{seq=1, CID=ServerCID_1}
   Server → Client: NEW_CONNECTION_ID{seq=2, CID=ServerCID_2}

3. 网络迁移时
   Client 换了 IP(WiFi → 4G)
   Client → Server: DCID=ServerCID_1(用新的 CID!)
   旧路径的 ServerCID_0 → 废弃

4. 效果
   观察者在 WiFi 上看到 ServerCID_0
   观察者在 4G 上看到 ServerCID_1
   无法将两者关联 → 隐私保护 ✓

4.2 路径验证(Path Validation)

网络切换后,服务端不能盲目信任新路径——攻击者可能伪造源 IP 来劫持连接。QUIC 用 PATH_CHALLENGE / PATH_RESPONSE 机制验证:

路径验证流程:

Client (新 IP: 10.0.0.5)           Server
  |                                   |
  |--- DCID=ServerCID_1 ------------>|  从新 IP 发包
  |                                   |  服务端发现源 IP 变了
  |                                   |
  |<-- PATH_CHALLENGE{data=随机8B} --|  "证明你能在新路径上收发"
  |--- PATH_RESPONSE{data=同样8B} -->|  "收到,原样返回"
  |                                   |
  |       路径验证成功!               |
  |   迁移完成,继续使用新路径         |

整个过程只需要一个 RTT,而 TCP 的连接重建需要 TCP 握手 + TLS 握手 = 至少 2 RTT。

4.3 NAT 重绑定

除了主动的网络切换,还有一种常见情况:NAT 重绑定

运营商的 NAT 设备有超时机制——如果一段时间没有数据传输,NAT 映射会被回收。下次发包时,NAT 会分配一个新的端口。

在 TCP 中:端口变了 → 四元组变了 → 连接断开。 在 QUIC 中:端口变了 → Connection ID 没变 → 服务端照样能路由 → 连接继续。

# NAT 重绑定场景对比

# TCP
client_sends_from = ("1.2.3.4", 54321)  # NAT 分配的端口
# ... 30 秒空闲 ...
# NAT 回收端口 54321
client_sends_from = ("1.2.3.4", 61789)  # NAT 分配了新端口
# server: "四元组不匹配,这不是我的连接" → RST

# QUIC
client_sends_from = ("1.2.3.4", 54321)
# ... 30 秒空闲 ...
client_sends_from = ("1.2.3.4", 61789)
# server: "Connection ID 匹配,是同一个连接" → 继续
# (可能触发 Path Validation 以确认安全)

4.4 多路径支持的未来

Connection ID 的设计还为未来的多路径 QUIC 铺平了道路。理论上,一个 QUIC 连接可以同时使用多条路径(WiFi + 4G),两条路径使用不同的 Connection ID:

这比 MPTCP 优雅得多,因为中间设备看不到也不需要理解多路径逻辑——它们只看到普通的 UDP 包。多路径 QUIC 目前还在 IETF 标准化进程中(draft-ietf-quic-multipath),但协议骨架已经准备好了。


五、0-RTT 握手:快到什么程度

QUIC 握手的设计灵感来源于 TLS 1.3(如果你对 TLS 1.3 的握手细节感兴趣,可以看 不到 500 行 C 实现 TLS 1.3 握手)。但 QUIC 做了一件 TCP+TLS 永远做不到的事:把传输层握手和加密握手合并成一个

5.1 首次连接:1-RTT

传统 TCP + TLS 1.3 需要两步:

TCP + TLS 1.3(首次连接):

Step 1: TCP 三次握手          → 1 RTT
  Client → SYN
  Server → SYN-ACK
  Client → ACK

Step 2: TLS 1.3 握手          → 1 RTT
  Client → ClientHello (+ KeyShare)
  Server → ServerHello (+ KeyShare) + {加密数据}
  Client → {Finished}

总计:2 RTT 后才能发送应用数据

QUIC 把这两步合并:

QUIC(首次连接):

Step 1: QUIC Initial + Handshake  → 1 RTT
  Client → Initial[ClientHello + 传输参数 + KeyShare]
  Server → Initial[ServerHello]
         + Handshake[{加密扩展} + {证书} + {Finished}]
  Client → Handshake[{Finished}]

总计:1 RTT 后就能发送应用数据

为什么 QUIC 可以省掉 TCP 的那 1 RTT?因为 TCP 握手的三次交互只是为了协商序列号和窗口大小——这些信息 QUIC 用加密的传输参数(Transport Parameters)一次性搞定了。

握手延迟对比

5.2 重连:0-RTT

更惊艳的是 0-RTT。如果客户端之前连接过这个服务器,它手里会有一个 PSK(Pre-Shared Key)Session Ticket(来自上次连接的 NewSessionTicket 消息)。

有了 PSK,客户端不用等服务端回复,直接用 PSK 派生密钥加密数据发出去:

QUIC 0-RTT(重连):

Client 持有上次连接的 PSK

  Client → Initial[ClientHello + PSK + EarlyDataIndication]
         + 0-RTT[{HTTP GET /index.html}]    ← 第一个包就带数据!
  Server → Initial[ServerHello]
         + Handshake[{加密扩展} + {Finished}]
         + 1-RTT[{HTTP 200 OK + HTML}]       ← 立即返回响应
  Client → Handshake[{Finished}]

总计:0 RTT —— 第一个飞行中的包就携带了应用数据

5.3 RTT 对比一览

方案 首次连接 重连 备注
TCP + TLS 1.2 3 RTT 2 RTT (Session ID) 已过时,不建议使用
TCP + TLS 1.3 2 RTT 1 RTT (PSK) + 0-RTT 可选 当前主流
QUIC 1 RTT 0 RTT 传输层+加密层合并

握手延迟实测对比

理论 RTT 数字很好看,但实际体感如何?以下是不同网络条件下,TCP+TLS 1.3(首次 2-RTT)vs QUIC(首次 1-RTT / 重连 0-RTT)的握手延迟对比:

网络场景 RTT TCP+TLS 1.3 首次 QUIC 首次 QUIC 0-RTT 重连
同机房 LAN ~1 ms ~2 ms ~1 ms ~0 ms(随首包发出)
同城 Metro ~20 ms ~40 ms ~20 ms ~0 ms
跨国(中美) ~150 ms ~300 ms ~150 ms ~0 ms
移动网络(4G) ~50-80 ms ~100-160 ms ~50-80 ms ~0 ms
高丢包(2%) ~150 ms ~450-600 ms* ~200-300 ms* ~50-150 ms*

* 丢包场景下的数字波动很大。TCP+TLS 的问题是握手包也会丢——任何一步丢包都要等 RTO(通常 1 秒)重传。QUIC 的 Initial 包有冗余机制(客户端会主动重传 Initial),恢复更快。

同城 20ms RTT 场景的具体时间线

TCP + TLS 1.3 首次连接(总耗时 ~40ms):
  t=0ms    Client → SYN
  t=20ms   Server → SYN-ACK
  t=20ms   Client → ACK                     ← TCP 握手完成(20ms)
  t=20ms   Client → ClientHello
  t=40ms   Server → ServerHello + Finished
  t=40ms   Client → Finished                ← TLS 握手完成(40ms)
  t=40ms   Client → HTTP Request             ← 开始传数据

QUIC 首次连接(总耗时 ~20ms):
  t=0ms    Client → Initial[ClientHello]
  t=20ms   Server → Initial[ServerHello] + Handshake[Finished]
  t=20ms   Client → Handshake[Finished] + 1-RTT[HTTP Request]  ← 开始传数据

QUIC 0-RTT 重连(总耗时 ~0ms):
  t=0ms    Client → Initial[ClientHello] + 0-RTT[HTTP Request]  ← 第一个包就带数据
  t=20ms   Server → 开始处理请求(同时完成握手)

对移动端尤其重要:用户打开 app、点开一个页面,TCP+TLS 要白等 100-300ms(取决于网络),QUIC 0-RTT 可以让这个等待完全消失。这不是理论优势——Google 的 YouTube 实测数据显示,QUIC 将视频播放启动时间在移动端降低了 15-18%

在跨大洋场景下(RTT ≈ 150ms):

TCP + TLS 1.2 首次连接:  3 × 150ms = 450ms 的握手延迟
TCP + TLS 1.3 首次连接:  2 × 150ms = 300ms
QUIC 首次连接:           1 × 150ms = 150ms
QUIC 0-RTT 重连:         0 × 150ms = 0ms   ← 请求随第一个包发出

5.4 0-RTT 的安全风险:重放攻击

0-RTT 不是免费的午餐。它有一个根本性的安全风险:重放攻击(Replay Attack)

在正常的 1-RTT 握手中,双方都贡献了随机数来生成密钥,保证每次会话的密钥不同。但 0-RTT 数据是用旧的 PSK 派生的密钥加密的——服务端还没有机会贡献新鲜的随机数

这意味着攻击者如果截获了 0-RTT 数据包,可以在之后原样重放给服务端:

0-RTT 重放攻击:

1. Client 发送 0-RTT 数据:
   [ClientHello + PSK + 0-RTT{POST /transfer?amount=1000}]

2. 攻击者截获这个包

3. 攻击者原样重放给 Server:
   [ClientHello + PSK + 0-RTT{POST /transfer?amount=1000}]

4. Server 无法区分这是正常请求还是重放
   → 转账执行了两次!

因此,0-RTT 只适合幂等请求

请求类型 是否适合 0-RTT 原因
GET /index.html ✅ 适合 幂等,重放无副作用
GET /api/user/123 ✅ 适合 幂等查询
POST /api/transfer ❌ 不适合 非幂等,重放会重复执行
DELETE /api/item/456 ⚠️ 取决于实现 如果是真正幂等的则可以

服务端实现中通常会维护一个0-RTT 重放检测窗口:记住最近见过的 ClientHello,拒绝重复的。但这不能百分百防御分布式重放(攻击者同时向多个服务端副本重放)。

5.5 0-RTT 的其他限制

除了重放风险,0-RTT 数据还有两个限制:

0-RTT 数据的限制:

1. 没有前向保密(Forward Secrecy)
   - 0-RTT 数据用 PSK 派生的密钥加密
   - 如果 PSK 泄漏,0-RTT 数据可被解密
   - 1-RTT 数据有前向保密(因为用了新的 ECDHE 密钥交换)

2. 服务端可以拒绝
   - 服务端有权拒绝 0-RTT 数据(返回 Retry 或忽略)
   - 客户端必须准备好在 1-RTT 握手完成后重新发送
   - 所以 0-RTT 是一种"乐观优化",不是保证

六、QUIC 的代价

QUIC 不是银弹。在享受它的好处之前,你需要清楚它的代价。

6.1 UDP 在企业网络经常被限速或丢弃

很多企业防火墙和运营商设备对 UDP 不友好:

QUIC 在网络中遇到的障碍:

1. 企业防火墙:默认只开放 TCP 443,UDP 443 可能被封
2. 运营商限速:部分 ISP 对 UDP 限速(认为是游戏/视频流量)
3. 某些国家的 DPI 设备会阻断 UDP 443

实际数据(来自 Google 的测量,2024):
  - 全球约 3-5% 的用户无法使用 QUIC
  - Chrome 的策略:先尝试 QUIC,失败后回退到 TCP+TLS
  - 回退延迟:约 300ms(需要检测 QUIC 不可用)

6.2 用户态协议栈的 CPU 开销

TCP 在内核态实现,享受了几十年的优化,包括:

QUIC 在用户态,这些优化全都用不上(至少在 2026 年的大多数情况下):

优化 TCP QUIC
发送路径拷贝次数 0 (sendfile) 至少 1 次
分段 offload (TSO) ✅ 硬件 ❌ 软件
接收合并 (GRO) ✅ 硬件 部分支持 (GSO)
校验和 offload ✅ 硬件 ❌ 软件(UDP 校验和除外)
加密 offload (kTLS) ✅ 内核 TLS ❌ 用户态
CPU 占用(相同吞吐) 基准 高 2-3 倍

Google 的数据显示,相同吞吐量下 QUIC 的 CPU 开销大约是 TCP 的 2 倍。这对大规模服务来说不是小数目。

6.3 缺乏硬件 offload

网卡厂商(Intel、Mellanox/NVIDIA、Broadcom)针对 TCP 做了大量硬件加速。QUIC 由于头部加密,网卡根本看不懂包的内容,无法做硬件 offload。

不过这个情况正在改善:

# QUIC 硬件 offload 进展
# 2024: NVIDIA ConnectX-7 开始支持 QUIC crypto offload
# 2025: Intel E810 添加了 QUIC UDP segmentation
# 趋势: 硬件正在追赶,但还需要 2-3 年才能普及

6.4 调试困难

TCP 的包头是明文的,Wireshark 打开就能看。QUIC 的包头是加密的,你打开 Wireshark 看到的是一堆密文。

想调试 QUIC?你需要:

# 用 curl 测试 QUIC 连接(需要 curl 7.66+ 编译了 HTTP/3 支持)
curl --http3 -v https://quic.rocks:4433/
# 用 SSLKEYLOGFILE 配合 Wireshark 抓包
SSLKEYLOGFILE=/tmp/quic-keys.log curl --http3 https://example.com
# 方法 1: 使用 SSLKEYLOGFILE(类似调试 TLS)
# 让客户端/服务端把密钥写入文件
export SSLKEYLOGFILE=/path/to/keylog.txt

# 在 Wireshark 中配置:
# Edit → Preferences → Protocols → TLS → (Pre)-Master-Secret log filename
# 指向上面的 keylog 文件

# 方法 2: 使用 QUIC 事件日志(qlog)
# qlog 是 QUIC 专用的结构化日志格式
# 大多数 QUIC 实现都支持输出 qlog
# 可以用 qvis (https://qvis.quictools.info/) 可视化

# 方法 3: 使用 Spin Bit
# QUIC Short Header 中有一个 Spin Bit
# 它会在每个 RTT 翻转一次
# 网络运营商可以通过观察 Spin Bit 估算 RTT
# 但这是唯一能从外部观测的性能指标

6.5 生态成熟度

截至 2026 年,QUIC/HTTP/3 的生态已经相当成熟,但仍有差距:

维度 TCP + TLS QUIC
浏览器支持 100% ~96%(主流浏览器全支持)
CDN 支持 100% Cloudflare / Akamai / Fastly / AWS CloudFront
负载均衡 成熟(L4/L7 都有) L7 支持好,L4 需要解析 CID
内核集成 原生 Linux 6.x 有 AF_XDP 加速方案
运维工具 丰富 发展中(qlog/qvis 逐步成为标准)
开发者熟悉度 中等(学习曲线存在)

小结

TCP 的三个设计级缺陷——队头阻塞、四元组绑定、协议僵化——不是 bug,而是 40 年前合理决策的历史包袱。它们深入到 TCP 的骨髓中,无法通过扩展修复。

QUIC 的回答是:在 UDP 上重新来过,把加密、多路复用、连接迁移统统融入传输层。

维度 TCP QUIC
队头阻塞 ✕ 全局有序 ✓ 流级独立
连接迁移 ✕ 四元组绑定 ✓ Connection ID
握手延迟 2 RTT(+TLS 1.3) 1 RTT / 0 RTT
协议演进 ✕ middlebox 僵化 ✓ 加密+用户态
CPU 效率 ✓ 内核+硬件 ✕ 用户态开销
调试便利性 ✓ 明文头部 ✕ 需要密钥日志

QUIC 不是 TCP 的补丁,它是 TCP 的替代品。代价是更高的 CPU 开销和尚在完善的生态。但趋势很明确:HTTP/3 已经是事实标准,QUIC 正在成为互联网传输层的新基座。

下篇我们动手:用 C + ngtcp2 实现一个最小的 QUIC 客户端,逐帧拆解 Initial → Handshake → 1-RTT 的握手过程。


延伸阅读


By .