HTTP/2 与 RPC 精讲教程 / 07 - HTTP/3 与 QUIC
第 07 章:HTTP/3 与 QUIC
告别 TCP——HTTP 协议的下一次革命
7.1 HTTP/3 概述
HTTP/3 是 HTTP 协议的第三个主要版本,它不再使用 TCP 作为传输层,而是基于 QUIC(Quick UDP Internet Connections)协议运行在 UDP 之上。这是 HTTP 协议历史上最根本的架构变革。
7.1.1 HTTP/2 的遗留问题
HTTP/2 解决了什么:
✓ 应用层队头阻塞(多路复用)
✓ 头部冗余(HPACK)
✓ 无优先级(流优先级)
HTTP/2 没解决什么:
✗ TCP 层队头阻塞
- 一个 TCP 段丢失 → 整个连接阻塞
- 所有流被迫等待重传
✗ 连接建立延迟
- TCP 握手:1 RTT
- TLS 握手:1-2 RTT
- 总计:2-3 RTT 才能开始传输数据
✗ 连接迁移困难
- TCP 连接绑定 (IP, Port) 四元组
- 网络切换(如 WiFi → 4G)导致连接断开
7.1.2 HTTP/3 协议栈对比
HTTP/2 协议栈:
┌─────────────────────┐
│ HTTP/2 │
├─────────────────────┤
│ TLS 1.2+ │
├─────────────────────┤
│ TCP │
├─────────────────────┤
│ IP │
└─────────────────────┘
HTTP/3 协议栈:
┌─────────────────────┐
│ HTTP/3 │
├─────────────────────┤
│ QUIC (内置 TLS) │
├─────────────────────┤
│ UDP │
├─────────────────────┤
│ IP │
└─────────────────────┘
7.2 QUIC 协议
7.2.1 QUIC 是什么
QUIC(Quick UDP Internet Connections)最初由 Google 设计,后由 IETF 标准化。它是一个通用的传输层协议,旨在提供比 TCP 更好的性能,同时保持可靠性。
| 特性 | TCP | QUIC |
|---|---|---|
| 传输层 | TCP | UDP |
| 队头阻塞 | 有(连接级) | 无(流级独立) |
| 握手延迟 | 1-3 RTT | 0-1 RTT |
| 加密 | 可选 TLS | 强制内置 TLS 1.3 |
| 连接迁移 | 不支持 | 支持(Connection ID) |
| 拥塞控制 | 内核实现 | 用户空间实现(可定制) |
| 头部压缩 | N/A | QPACK(HTTP/3 专用) |
7.2.2 QUIC 连接建立
TCP + TLS 1.2(3 RTT):
客户端 服务器
|--- SYN ----------->| RTT 1: TCP 握手
|<-- SYN+ACK --------|
|--- ACK ----------->|
|--- ClientHello --->| RTT 2: TLS 握手
|<-- ServerHello -----|
|--- Finished ------->|
|<-- 数据 ------------| RTT 3: 开始传输
TCP + TLS 1.3(2 RTT):
客户端 服务器
|--- SYN ----------->| RTT 1: TCP 握手
|<-- SYN+ACK --------|
|--- ClientHello --->| RTT 2: TLS 握手 + 数据
|<-- ServerHello + 数据
QUIC 首次连接(1 RTT):
客户端 服务器
|--- Initial (ClientHello) -->| RTT 1: QUIC 握手 + 数据
|<-- Handshake (ServerHello) -|
|--- 数据 ------------------->|
QUIC 0-RTT 恢复(0 RTT):
客户端 服务器
|--- Initial + 0-RTT 数据 -->| 无等待!直接发送
|<-- 数据 -------------------|
7.3 流级独立性
7.3.1 QUIC 流模型
TCP 的队头阻塞问题:
发送方:[流1-帧1][流2-帧1][流1-帧2][流2-帧2]
传输层:[TCP段1][TCP段2][TCP段3][TCP段4]
如果 TCP 段2 丢失:
流1-帧2 和 流2-帧1 都被阻塞
即使流2-帧2 已到达,也无法交付
QUIC 的流级独立:
发送方:[流1-帧1][流2-帧1][流1-帧2][流2-帧2]
传输层:[QUIC包1][QUIC包2][QUIC包3][QUIC包4]
如果 QUIC 包2 丢失(包含流2-帧1):
流1 的数据继续交付,不受影响
流2 等待重传
7.3.2 流类型
| 流类型 | 发起方 | 用途 |
|---|---|---|
| 客户端发起的双向流 | 客户端 | HTTP 请求/响应 |
| 服务器发起的双向流 | 服务器 | 通常不用于 HTTP/3 |
| 客户端发起的单向流 | 客户端 | 控制流 |
| 服务器发起的单向流 | 服务器 | 推送流、控制流 |
# QUIC 流 ID 编码规则
def stream_info(stream_id: int) -> dict:
"""解析 QUIC 流 ID"""
initiator = "客户端" if stream_id & 0x01 == 0 else "服务器"
stream_type = "双向" if stream_id & 0x02 == 0 else "单向"
return {
"id": stream_id,
"initiator": initiator,
"type": stream_type,
}
# 示例
for sid in [0, 1, 2, 3, 4, 5, 6, 7]:
info = stream_info(sid)
print(f"流 {sid}: {info['initiator']}发起, {info['type']}")
7.4 连接迁移
7.4.1 问题场景
传统 TCP 连接的问题:
用户在咖啡店用 WiFi 访问网站:
源 IP: 192.168.1.100
源端口: 54321
目标 IP: 93.184.216.34
目标端口: 443
用户离开咖啡店,切换到 4G:
源 IP: 10.0.0.50 (变化了!)
源端口: 12345 (变化了!)
TCP 连接断开,需要重新建立!
所有进行中的请求失败!
7.4.2 QUIC 的连接迁移
QUIC 使用 Connection ID 标识连接:
初始连接:
Connection ID: 0x1234567890abcdef
源 IP: 192.168.1.100
源端口: 54321
网络切换后:
Connection ID: 0x1234567890abcdef (不变!)
源 IP: 10.0.0.50
源端口: 12345
服务器通过 Connection ID 识别连接,无需重建!
// 模拟连接迁移
package main
import (
"fmt"
"net"
)
type QUICConnection struct {
connectionID []byte
remoteAddr *net.UDPAddr
streams map[uint32]*QUICStream
}
type QUICStream struct {
id uint32
data []byte
}
func NewQUICConnection(connID []byte, addr *net.UDPAddr) *QUICConnection {
return &QUICConnection{
connectionID: connID,
remoteAddr: addr,
streams: make(map[uint32]*QUICStream),
}
}
func (c *QUICConnection) Migrate(newAddr *net.UDPAddr) {
fmt.Printf("连接迁移: %s -> %s\n", c.remoteAddr, newAddr)
fmt.Printf("Connection ID 不变: %x\n", c.connectionID)
c.remoteAddr = newAddr
}
func main() {
connID := []byte{0x12, 0x34, 0x56, 0x78}
// 初始连接
addr1, _ := net.ResolveUDPAddr("udp4", "192.168.1.100:54321")
conn := NewQUICConnection(connID, addr1)
fmt.Printf("初始连接: %s\n", conn.remoteAddr)
// 连接迁移
addr2, _ := net.ResolveUDPAddr("udp4", "10.0.0.50:12345")
conn.Migrate(addr2)
}
7.5 0-RTT 数据传输
7.5.1 0-RTT 原理
首次连接后,客户端保存服务器的配置:
- 服务器公钥
- 会话票据(Session Ticket)
- 早期数据密钥
后续连接时:
客户端可以在握手的同时发送数据(0-RTT)
安全性注意:
0-RTT 数据没有前向保密性
可能遭受重放攻击
7.5.2 0-RTT 安全限制
# 0-RTT 数据的幂等性检查
class ZeroRTTHandler:
def __init__(self):
self.seen_requests = set() # 防重放
self.max_0rtt_size = 16384 # 16KB 限制
def handle_0rtt_data(self, request_id: str, data: bytes, method: str) -> bool:
"""处理 0-RTT 数据"""
# 检查重放
if request_id in self.seen_requests:
return False # 拒绝重放
# 只接受幂等方法
if method not in ("GET", "HEAD", "OPTIONS"):
return False # 非幂等方法拒绝 0-RTT
# 检查大小
if len(data) > self.max_0rtt_size:
return False
self.seen_requests.add(request_id)
return True
# 0-RTT 适用场景
# ✓ GET 请求
# ✓ 静态资源加载
# ✓ 缓存验证(If-None-Match)
# ✗ POST/PUT/DELETE
# ✗ 幂等性不确定的操作
7.6 QPACK:HTTP/3 的头部压缩
7.6.1 QPACK vs HPACK
| 特性 | HPACK (HTTP/2) | QPACK (HTTP/3) |
|---|---|---|
| 编码流 | 共享编码上下文 | 独立编码流 |
| 队头阻塞 | 有(HEADERS 帧阻塞) | 无(通过指令流分离) |
| 动态表访问 | 同步 | 异步(通过编码流通知) |
| 复杂度 | 低 | 中 |
HPACK 的问题:
- 头部块在 HEADERS 帧中传输
- 如果 HEADERS 帧丢失,后续帧被阻塞
QPACK 的解决方案:
- 使用两个单向流:编码流(Encoder)和解码流(Decoder)
- 编码流传递动态表更新
- HEADERS 帧引用已确认的表条目
- 解码器可延迟处理,不阻塞数据流
7.7 QUIC 拥塞控制
7.7.1 可插拔的拥塞控制
// QUIC 拥塞控制接口(简化)
package main
type CongestionControl interface {
// 数据包发送确认
OnPacketSent(sentTime int64, packetNumber uint64, bytes int, isRetransmittable bool)
// 收到 ACK
OnPacketAcked(packetNumber uint64, ackDelay int64)
// 数据包丢失
OnPacketLost(packetNumber uint64, lostTime int64)
// 获取拥塞窗口
GetCongestionWindow() int
// 获取慢启动阈值
GetSlowStartThreshold() int
// 是否在慢启动
InSlowStart() bool
// 是否在恢复期
InRecovery() bool
}
// NewReno 拥塞控制实现(简化)
type NewReno struct {
congestionWindow int
slowStartThreshold int
bytesInFlight int
recoveryStartTime int64
maxCongestionWindow int
minCongestionWindow int
}
func NewNewReno(initialWindow int) *NewReno {
return &NewReno{
congestionWindow: initialWindow,
slowStartThreshold: 1 << 30, // 初始为极大值
maxCongestionWindow: 1 << 24, // 16MB
minCongestionWindow: 2 * 1460, // 2 * MSS
}
}
func (nr *NewReno) OnPacketAcked(packetNumber uint64, ackDelay int64) {
if nr.InSlowStart() {
// 慢启动:指数增长
nr.congestionWindow += 1460 // MSS
} else {
// 拥塞避免:线性增长
nr.congestionWindow += 1460 * 1460 / nr.congestionWindow
}
if nr.congestionWindow > nr.maxCongestionWindow {
nr.congestionWindow = nr.maxCongestionWindow
}
}
func (nr *NewReno) OnPacketLost(packetNumber uint64, lostTime int64) {
if nr.InRecovery() {
return // 已在恢复期
}
nr.recoveryStartTime = lostTime
nr.slowStartThreshold = nr.congestionWindow / 2
nr.congestionWindow = nr.slowStartThreshold
}
func (nr *NewReno) GetCongestionWindow() int { return nr.congestionWindow }
func (nr *NewReno) GetSlowStartThreshold() int { return nr.slowStartThreshold }
func (nr *NewReno) InSlowStart() bool { return nr.congestionWindow < nr.slowStartThreshold }
func (nr *NewReno) InRecovery() bool { return false }
7.8 生态系统支持
7.8.1 浏览器支持
| 浏览器 | 版本 | 备注 |
|---|---|---|
| Chrome | 87+ (2020) | 默认启用 |
| Firefox | 88+ (2021) | 默认启用 |
| Edge | 87+ | 基于 Chromium |
| Safari | 14+ (2021) | macOS/iOS |
| IE | 不支持 | 已停止更新 |
7.8.2 服务器支持
| 服务器 | 版本 | 支持状态 |
|---|---|---|
| Nginx | 1.25.0+ | 实验性支持 |
| Caddy | 2.6+ | 默认启用 |
| LiteSpeed | 5.4+ | 完整支持 |
| Cloudflare | - | 全面支持 |
7.8.3 Go 生态
// 使用 quic-go 实现 HTTP/3 服务器
package main
import (
"fmt"
"log"
"net/http"
"github.com/quic-go/quic-go/http3"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, HTTP/3!\n")
fmt.Fprintf(w, "Protocol: %s\n", r.Proto)
})
server := &http3.Server{
Addr: ":8443",
Handler: mux,
}
log.Println("HTTP/3 服务器启动于 :8443")
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}
# 使用 curl 测试 HTTP/3(需要支持 quic 的版本)
curl --http3 https://localhost:8443/
# 使用 quiche 工具
docker run --rm ymuski/curl-http3 curl --http3 -k https://localhost:8443/
7.9 HTTP/2 vs HTTP/3 对比
| 特性 | HTTP/2 | HTTP/3 |
|---|---|---|
| 传输层 | TCP | UDP (QUIC) |
| 队头阻塞 | TCP 层有 | 完全消除 |
| 连接建立 | 1-3 RTT | 0-1 RTT |
| 连接迁移 | 不支持 | 支持 |
| 加密 | TLS 可选 | 强制 TLS 1.3 |
| 头部压缩 | HPACK | QPACK |
| 协议僵化 | 严重(中间设备) | 较轻(UDP 较新) |
| 生态成熟度 | 高 | 快速增长中 |
7.10 注意事项
⚠️ UDP 中间设备问题:
- 部分企业防火墙限制 UDP 流量
- NAT 设备可能丢弃 QUIC 包
- 建议同时提供 HTTP/2 降级方案
⚠️ CPU 开销:
- QUIC 在用户空间实现,CPU 开销高于内核 TCP
- 加密/解密在用户空间完成
- 高流量场景需评估 CPU 资源
⚠️ 0-RTT 重放攻击:
- 0-RTT 数据可能被重放
- 仅对幂等请求使用 0-RTT
- 服务器需实现防重放机制
💡 迁移建议:
- 新项目直接采用 HTTP/3
- 存量项目先支持 HTTP/2,再逐步升级
- 使用 Alt-Svc 头部宣告 HTTP/3 支持
7.11 扩展阅读
- 📖 RFC 9114 - HTTP/3
- 📖 RFC 9000 - QUIC: A UDP-Based Multiplexed and Secure Transport
- 📖 RFC 9204 - QPACK: Header Compression for HTTP/3
- 📖 The Road to HTTP/3 (Cloudflare Blog)
- 📖 quic-go - Go 实现