原文发布于 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 请求之前,至少要完成:
- TCP 三次握手 --- 1 RTT
- 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 RTT (RFC 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!
这导致了一个恶性循环:
- IETF 设计了新的 TCP 扩展(比如 TCP Fast Open、MPTCP)
- 中间设备不认识新 option → 丢包或剥离
- 新扩展在公网上大面积失败
- 没人敢用 → 没人部署 → 永远不会有足够的部署量来逼中间设备升级
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,你得:
- 提交 Linux 内核补丁
- 等待审核、合入、发布新内核版本
- 等各大发行版更新内核
- 等企业客户升级系统
整个周期可能是几个月到几年。
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 低位 | 00 或 01 |
10 或 11 |
| 关闭方式 | 任何一方发 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。
开放问题
- 用户态 QUIC 与内核/TCP offload 的长期成本差。 RFC 9000 把解析放到用户态以绕开 middlebox,但 CPU 与硬件加速路径仍落后于 TCP(本文第六节);ConnectX/E810 等 offload 能否缩小差距尚无统一生产结论。
- 多路径 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 --http3、SSLKEYLOGFILE+ Wireshark
论文 / 书
- Langley et al., The QUIC Transport Protocol(RFC 9000 背景)
- IETF QUIC WG 邮件列表与 multipath draft(开放问题线索)
实验 / 博客(B 级)
- Google / Cloudflare HTTP/3 部署笔记(CPU 与连接迁移观察,勿跨环境套用延迟数字)
延伸阅读
- TLS 1.3 工程实践 --- 1-RTT/0-RTT 部署与重放风险控制
- TLS 1.3 最小实现 --- 握手与 HKDF 源码级拆解
- QUIC 下 --- ngtcp2 最小客户端
- HTTP/3 --- QPACK 与完整请求链路