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

原文发布于 quant67.com,转载请保留出处。
阅读路径(QUIC ↔ TLS 1.3 ↔ HTTP/3)

文章 分工
TLS 1.3 工程实践 RFC 8446 部署:1-RTT/0-RTT 语义、PSK、middlebox
TLS 1.3 最小实现 握手与 HKDF 源码级拆解(与工程文二选一打底)
本文(上) 为何 TCP 改不动;RFC 9000 设计动机与传输语义
QUIC 下 Initial→1-RTT 实现;RFC 9001 TLS 映射
HTTP/3 RFC 9114 应用层:QPACK、流映射

建议顺序:TLS 1.3(工程或 crypt)→ 本文 → QUIC 下 → HTTP/3。

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

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

也就是说,在跨太平洋链路上(RTT ≈ 150ms),你要白等约 2 个 RTT(TCP + TLS 1.3)才能开始发应用数据。如果是 TLS 1.2,TLS 侧还要多 1 RTT。

而 QUIC 首次连接只需 1 RTTRFC 9001, Section 4 与传输参数合并),重连在 PSK 条件下可 0 RTT(同 TLS 1.3 early data 语义)------第一个包就带上 HTTP 请求数据。

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


一、TCP 的三个设计级缺陷

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

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

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

  • 发送端按顺序发送字节 1, 2, 3, 4, 5, ...
  • 接收端必须按顺序交付给应用层
  • 如果字节 2 的包丢了,即使字节 3, 4, 5 已经到了,应用层也拿不到

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

  • 流 A 的数据已经完整到达 → 不能交付,因为 TCP 要保证全局有序
  • 流 C 的数据已经完整到达 → 不能交付,同样的原因
  • 所有流都在等流 B 的重传

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

(示意图见原文:TCP vs QUIC 队头阻塞对比)

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

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

1.2 连接绑定四元组

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

scss 复制代码
(源 IP, 源端口, 目的 IP, 目的端口)

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

  • WiFi 切 4G:IP 地址变了,TCP 连接断开,重新三次握手 + TLS 握手
  • NAT 重绑定:运营商的 NAT 设备给你换了个端口,TCP 连接断开
  • 从公司网络走到会议室:换了 AP,如果 IP 变了,连接断开

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

1.3 协议僵化(Ossification)

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

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

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

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 就是最好的反面教材

markdown 复制代码
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 上重新造一个 。传输层语义见 RFC 9000 Section 2--4;与 TLS 1.3 的绑定见 RFC 9001

2.1 为什么选 UDP

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

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

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

2.2 加密一切

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

scss 复制代码
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 重要得多。

bash 复制代码
# 主流 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 是独立的有序字节流:

arduino 复制代码
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 位编码了两个信息:

ini 复制代码
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 → 客户端发起的单向流

这个设计非常精妙:

  • 奇偶区分谁发起:避免了双方同时创建同一个 Stream ID 的冲突
  • 双向 vs 单向:HTTP/3 的请求/响应用双向流,服务端推送用单向流
  • ID 递增:每一方创建的同类 Stream ID 单调递增(0, 4, 8, 12, ...)

3.3 每个 Stream 独立流控

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

QUIC 实现了两级流量控制

arduino 复制代码
流量控制层次:

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

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

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

用 Go 语言伪代码说明:

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 中一个典型的请求对应一个双向流:

arduino 复制代码
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 的基本原理

arduino 复制代码
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 轮换

arduino 复制代码
连接迁移过程中的 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 机制验证:

ini 复制代码
路径验证流程:

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 没变 → 服务端照样能路由 → 连接继续。

python 复制代码
# 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:

  • 路径 1 (WiFi): CID = ServerCID_1
  • 路径 2 (4G): CID = ServerCID_2
  • 同一个连接,不同的 Stream 可以调度到不同的路径上

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


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

QUIC 握手的设计灵感来源于 TLS 1.3------工程部署与 0-RTT 权衡见 TLS 1.3 工程实践;消息级拆解见 TLS 1.3 最小实现。QUIC 做了一件 TCP+TLS 永远做不到的事:把传输层握手和加密握手合并成一个RFC 9001, Section 4)。

5.1 首次连接:1-RTT

传统 TCP + TLS 1.3 需要两步:

arduino 复制代码
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 把这两步合并:

ini 复制代码
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 派生密钥加密数据发出去:

css 复制代码
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 场景的具体时间线

ini 复制代码
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):

ini 复制代码
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 数据包,可以在之后原样重放给服务端:

arduino 复制代码
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 数据还有两个限制:

markdown 复制代码
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 不友好:

markdown 复制代码
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 在内核态实现,享受了几十年的优化,包括:

  • 零拷贝sendfile()splice() 让数据不用经过用户态
  • TSO/GRO:网卡硬件帮忙做分段和合并
  • checksum offload:校验和由网卡计算

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。

不过这个情况正在改善:

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

6.4 调试困难

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

想调试 QUIC?你需要:

bash 复制代码
# 用 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
bash 复制代码
# 方法 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 的握手过程(见 QUIC 下)。应用层接 HTTP/3 见 HTTP/3


开放问题

  1. 用户态 QUIC 与内核/TCP offload 的长期成本差。 RFC 9000 把解析放到用户态以绕开 middlebox,但 CPU 与硬件加速路径仍落后于 TCP(本文第六节);ConnectX/E810 等 offload 能否缩小差距尚无统一生产结论。
  2. 多路径 QUIC(draft-ietf-quic-multipath)与 Connection ID 路由。 CID 已为迁移铺路,但多路径调度、拥塞控制耦合仍在标准化中------比 MPTCP 少依赖中间设备,但部署面更窄。

参考资料

规范

  • RFC 9000 --- QUIC 传输(连接、流、丢包见 Section 2--5)
  • RFC 9001 --- QUIC 上的 TLS 1.3(握手 Section 4,0-RTT Section 4.6)
  • RFC 9002 --- 丢包检测与拥塞控制
  • RFC 8446 --- TLS 1.3(QUIC 加密层语义来源)
  • RFC 9114 --- HTTP/3

源码 / 工具

  • ngtcp2、quiche、MsQuic 握手状态机(对照 RFC 9001 Section 4)
  • curl --http3SSLKEYLOGFILE + Wireshark

论文 / 书

  • Langley et al., The QUIC Transport Protocol(RFC 9000 背景)
  • IETF QUIC WG 邮件列表与 multipath draft(开放问题线索)

实验 / 博客(B 级)

  • Google / Cloudflare HTTP/3 部署笔记(CPU 与连接迁移观察,勿跨环境套用延迟数字)

延伸阅读

相关推荐
Linux-lucky2 小时前
28-Linux学习之旅之Maven与Nexus制品库
linux·运维·学习·nginx·tomcat·maven
敲代码还房贷5 小时前
VMware17 + Ubuntu22.04 共享 完整步骤
linux·python·ubuntu
King of fraud5 小时前
Linux 下 MySQL 基础操作:创建用户、数据库与权限管理实战
linux·数据库·mysql
实心儿儿6 小时前
Linux —— 传输层协议TCP
linux·服务器·tcp/ip
祖力556 小时前
Linux应用软件编程:目录IO与framebuffer
linux·运维·算法·framebuffer·目录io
词却6 小时前
Linux基础:常用基本命令与软件包管理
linux
2401_890603407 小时前
Linux 进程控制
linux·运维·服务器
啦啦啦啦啦zzzz8 小时前
时间轮定时器
linux·服务器·网络·c++·定时器
神威难绷泪8 小时前
Linux应用软件编程:目录IO framebuffer
linux