UDP 与 QUIC
#网络 · #UDP · #QUIC · #HTTP3 · #传输层
UDP (User Datagram Protocol) 是最简传输协议,仅提供端口复用和校验。QUIC (Quick UDP Internet Connections) 在 UDP 之上构建了媲美 TCP 的可靠传输,并成为 HTTP/3 的底层协议。
UDP 基础
协议头(仅 8 字节)
UDP 头部极其精简——只有 4 个字段 8 字节,没有序列号、确认号、窗口大小等 TCP 的复杂机制:
mermaid
flowchart LR
subgraph UDP["UDP Header (8 字节)"]
direction LR
SP["源端口 (16b)"]
DP["目的端口 (16b)"]
Len["长度 (16b)"]
CS["校验和 (16b)"]
end
Payload["数据"]
SP --> DP --> Len --> CS --> Payload8 字节头部 vs TCP 的 20-60 字节。UDP 的哲学:把可靠性和流量控制交给应用层(如 QUIC),传输层只管"尽力而为"。
核心特性
| 特性 | UDP | TCP |
|---|---|---|
| 连接 | 无连接 | 面向连接 |
| 可靠性 | 不可靠(丢包不重传) | 可靠(确认+重传) |
| 顺序 | 不保证 | 严格有序 |
| 头部开销 | 8 字节 | 20-60 字节 |
| 流量控制 | 无 | 滑动窗口 |
| 拥塞控制 | 无 | 有(CUBIC/BBR) |
| 多路复用 | 仅端口 | 无流级复用 |
| 适用场景 | 实时音视频/DNS/DHCP | HTTP/文件传输/邮件 |
Go UDP 示例
go
// UDP 服务端
func udpServer() {
addr, _ := net.ResolveUDPAddr("udp", ":8080")
conn, _ := net.ListenUDP("udp", addr)
defer conn.Close()
buf := make([]byte, 1024)
for {
n, clientAddr, _ := conn.ReadFromUDP(buf)
fmt.Printf("收到 %s: %s\n", clientAddr, buf[:n])
conn.WriteToUDP([]byte("ACK"), clientAddr)
}
}
// UDP 客户端
func udpClient() {
addr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:8080")
conn, _ := net.DialUDP("udp", nil, addr)
defer conn.Close()
conn.Write([]byte("Hello UDP"))
buf := make([]byte, 1024)
n, _ := conn.Read(buf)
fmt.Println(string(buf[:n]))
}UDP 不可靠但为什么还用?
1. 实时性 > 可靠性
视频通话:偶尔丢一帧 vs 等重传卡顿 200ms → 宁丢勿等
DNS:丢包了再发一次就好,无需三次握手2. 无需连接建立
TCP: 三次握手 + TLS 握手 = 2-3 RTT
UDP: 0 RTT 直接发3. 可自定义传输策略
应用层自定义:
- 选择性重传(只重传关键帧)
- FEC 前向纠错(多发冗余数据包)
- 自定义拥塞控制QUIC 协议
为什么需要 QUIC
QUIC 不是简单的"UDP 版 TCP"——它是一个用户态实现的完整传输协议,目标是在不修改内核的前提下,解决 TCP 积重难返的四大问题:
mermaid
flowchart LR
subgraph TCP_Problems["TCP 四大顽疾"]
P1["🔴 TCP 队头阻塞<br/>一个包丢,所有流卡"]
P2["🔴 连接建立慢<br/>TCP + TLS = 3 RTT"]
P3["🔴 连接迁移难<br/>换 IP 就必须重建连接"]
P4["🔴 内核升级难<br/>新算法需等 kernel 更新"]
end
subgraph QUIC_Solutions["QUIC 的解决方案"]
S1["🟢 Stream 级独立传输<br/>一个流丢包不影响其他流"]
S2["🟢 0-RTT 恢复连接<br/>曾经连接过即可秒发数据"]
S3["🟢 Connection ID<br/>换 IP/WiFi 连接不断"]
S4["🟢 用户态实现<br/>应用更新即可升级协议"]
end
TCP_Problems --> QUIC_Solutions握手延迟对比 — 为什么 QUIC 更快
TCP + TLS 1.2(传统 HTTPS):
┌─────────────────────────────────────────────────────┐
│ Client Server │
│ │──── TCP SYN ──────────────→ │ RTT 1 │
│ │←─── TCP SYN+ACK ─────────── │ │
│ │──── TCP ACK ───────────────→ │ │
│ │──── ClientHello ───────────→ │ RTT 2 │
│ │←─── ServerHello+Cert ─────── │ │
│ │──── ClientKeyExchange ──────→ │ RTT 3 │
│ │──── HTTP GET ──────────────→ │ (数据) │
│ ═══════════════════════════════════════════════════ │
│ 首次连接: 3 RTT 后才能发数据 │
└─────────────────────────────────────────────────────┘
QUIC(首次连接, 1-RTT):
┌─────────────────────────────────────────────────────┐
│ Client Server │
│ │──── ClientHello(含密钥) ───→ │ RTT 1 │
│ │←─── ServerHello+Finished ─── │ (同时发数据) │
│ │──── HTTP GET ──────────────→ │ │
│ ═══════════════════════════════════════════════════ │
│ 首次连接: 1 RTT 后即可发数据 │
└─────────────────────────────────────────────────────┘
QUIC(恢复连接, 0-RTT):
┌─────────────────────────────────────────────────────┐
│ Client Server │
│ │──── 0-RTT: 数据直接发送 ────→ │ │
│ │ (用上次缓存的密钥加密) │ 0 RTT! │
│ │←─── ServerHello + 响应 ───── │ │
│ ═══════════════════════════════════════════════════ │
│ 恢复连接: 0 RTT 就收到响应! │
└─────────────────────────────────────────────────────┘0-RTT 的代价:虽然快,但 0-RTT 数据可能被重放攻击——攻击者录制 0-RTT 包后重放即可重复执行操作。因此 0-RTT 只能用于幂等操作(GET 请求),非幂等操作(POST/PUT)必须等 1-RTT 完成。
QUIC 多路复用 — 真正的"无队头阻塞"
HTTP/2 虽然支持多路复用(一个 TCP 连接承载多个 Stream),但 TCP 层面仍然是顺序传输——一个 TCP 包丢失,所有 Stream 都阻塞:
mermaid
sequenceDiagram
participant TCP as "TCP 连接"
participant S1 as "HTTP/2 Stream 1"
participant S2 as "HTTP/2 Stream 2"
participant S3 as "HTTP/2 Stream 3"
Note over TCP,S3: TCP 队头阻塞示例
S1->>TCP: Pkt 1 ✅
S2->>TCP: Pkt 2 ❌ 丢失!
S3->>TCP: Pkt 3 ✅
S1->>TCP: Pkt 4 ✅
Note over TCP: TCP 必须等待 Pkt 2 重传
TCP-->>S2: Pkt 2 重传 OK
TCP-->>S3: 这时 Pkt 3 才被交付
TCP-->>S1: Pkt 4 才被交付
Note over TCP,S3: 🔴 Stream 2 的丢包阻塞了 Stream 1 和 3QUIC 在 UDP 之上实现了多条独立的"Stream"——每个 Stream 有自己的重传状态,相互完全隔离:
mermaid
sequenceDiagram
participant Q as "QUIC 连接"
participant S1 as "Stream 1 (CSS)"
participant S2 as "Stream 2 (JS)"
participant S3 as "Stream 3 (图片)"
Note over Q,S3: QUIC 流级独立传输
S1->>Q: S1 Pkt 1 ✅
S2->>Q: S2 Pkt 1 ❌ 丢失!
S3->>Q: S3 Pkt 1 ✅
S1->>Q: S1 Pkt 2 ✅
Q-->>S1: S1 Pkt 1,2 立即交付 ✅
Q-->>S3: S3 Pkt 1 立即交付 ✅
Q-->>S2: S2 Pkt 1 重传中...
Q-->>S2: S2 Pkt 1 重传完成 ✅
Note over Q,S3: 🟢 只有 Stream 2 等待,其他流不受影响量化对比:
| 场景 | TCP + HTTP/2 | QUIC + HTTP/3 |
|---|---|---|
| 一个流丢包 1% | 所有流都受影响 | 只有丢包的流受影响 |
| 丢包率 2% 时吞吐 | 下降 30-50% | 几乎不变 |
| 弱网环境(4G/地铁) | 体验差(页面加载慢) | 明显提升 |
拥塞控制 — 从 CUBIC 到 BBR
QUIC 在用户态实现拥塞控制,这意味着可以热更新拥塞算法而不需要重启。目前主流的算法已经从"基于丢包"转向"基于带宽探测":
| 算法 | 原理 | 丢包敏感 | 高带宽场景 | 缓冲膨胀 |
|---|---|---|---|---|
| CUBIC | 三次函数调速(Linux 内核默认) | ❌ 见到丢包就降速 | 🟡 | ❌ 会填满瓶颈缓冲区 |
| BBR | 持续探测带宽 + RTT | ✅ 丢包不一定降速 | ✅✅ | ✅ 几乎不填满缓冲区 |
| BBRv2 | BBR + 丢包辅助 + ECN | ✅ | ✅✅ | ✅✅ |
mermaid
flowchart LR
subgraph CUBIC["CUBIC — 基于丢包"]
C1["检测到丢包"] --> C2["拥塞窗口砍半"]
C2 --> C3["三次函数逐渐恢复"]
C3 --> C4["再次填满缓冲区 → 丢包 → 回到 C1"]
end
subgraph BBR["BBR — 基于带宽探测"]
B1["持续测量: 最大带宽 + 最小 RTT"]
B1 --> B2["计算出 BDP (带宽延迟积)"]
B2 --> B3["发送速率 = BDP<br/>(不填满缓冲区)"]
B3 --> B4["周期性探测更高带宽"]
B4 --> B1
endCUBIC 的致命问题:它依赖缓冲区溢出导致的丢包作为"降速信号"。在高带宽延迟网络(长肥管道)中,这意味着缓冲区已经满了还在发、用户已经感受到了延迟加剧——这被称为"缓冲膨胀"(Bufferbloat)。BBR 绕过这个问题,通过测量 RTT 和带宽来直接计算最佳发送速率。
Go 中使用 QUIC:
go
import "github.com/quic-go/quic-go"
// QUIC 服务端
func quicServer() {
tlsConf := &tls.Config{...}
listener, _ := quic.ListenAddr(":4433", tlsConf, nil)
for {
conn, _ := listener.Accept(context.Background())
stream, _ := conn.AcceptStream(context.Background())
// 读/写 stream...
}
}TCP 用四元组(源IP:Port + 目标IP:Port)标识一个连接——这意味着换网络(WiFi→4G)时 IP 变了,连接必须重建。QUIC 用 Connection ID 标识连接,与 IP 无关:
mermaid
sequenceDiagram
participant Phone as "手机"
participant Server as "服务器"
participant WiFi as "WiFi IP: 192.168.1.5"
participant Cell as "4G IP: 10.10.10.2"
Note over Phone,Server: QUIC Connection ID = 0xABCD1234
Phone->>WiFi: 连接建立 (CID=0xABCD1234)
WiFi->>Server: 数据传输中...
Note over Phone: 用户离开 WiFi 范围
Phone->>Cell: 同样的 CID=0xABCD1234
Cell->>Server: 服务器识别 CID → 连接继续!
Note over Phone,Server: 🟢 无感切换,连接不中断实际意义:视频通话中从 WiFi 切到 4G,TCP 会中断 3-5 秒重建连接,QUIC 只需几十毫秒。这对用户体验是质的提升。
| 特性 | TCP | QUIC |
|---|---|---|
| 传输层 | 内核实现 | 用户态(UDP 上) |
| 连接标识 | IP:Port 四元组 | Connection ID |
| 连接迁移 | ❌(换 IP 断开) | ✅(Connection ID 不变) |
| 多路复用 | TCP 流(全部阻塞) | Stream(互不影响) |
| 加密 | TLS 可选 | TLS 1.3 内建 |
| 握手 | TCP + TLS 分开 | 合并为一次 |
| 升级 | 需要内核更新 | 只需应用层更新 |
队头阻塞对比
TCP + HTTP/2 队头阻塞:
Stream 1: [P1][P2][P3丢失][P4][P5]
Stream 2: [P1][P2][P3][--等待--][P4]
↑ TCP 层阻塞所有流
QUIC 流级无阻塞:
Stream 1: [P1][P2][P3丢失][P4][P5] ← 只有 Stream 1 等待重传
Stream 2: [P1][P2][P3][P4][P5] ← 继续正常HTTP/3 = HTTP over QUIC
HTTP/1.1 → TCP
HTTP/2 → TCP + TLS(多路复用,但有 TCP 队头阻塞)
HTTP/3 → QUIC + UDP(真正无队头阻塞)协议栈对比
HTTP/2 HTTP/3
┌──────────┐ ┌──────────┐
│ HTTP │ │ HTTP │
├──────────┤ ├──────────┤
│ TLS │ │ QUIC │ ← 合并了传输层+TLS
├──────────┤ ├──────────┤
│ TCP │ │ UDP │
├──────────┤ ├──────────┤
│ IP │ │ IP │
└──────────┘ └──────────┘QPACK vs HPACK
| 维度 | HPACK (HTTP/2) | QPACK (HTTP/3) |
|---|---|---|
| 头部压缩 | 静态表+动态表 | 静态表+动态表 |
| 顺序依赖 | 表更新必须有序 | ✅ 不阻塞 |
| 队头阻塞 | 动态表更新阻塞 | ✅ 无阻塞 |
KCP:可靠 UDP 传输
对于不需要 HTTP/3 但需要可靠 UDP 的场景(游戏、VPN),可以使用 KCP:
KCP = UDP + 选择性重传 + 快速重传 + 流控
vs TCP:
- 比 TCP 快 30-40%(牺牲 10-20% 带宽)
- 可选择跳过丢包(不等待重传)
- 自定义超时重传 RTOgo
// KCP 使用示例(简化)
import "github.com/xtaci/kcp-go"
func kcpServer() {
listener, _ := kcp.ListenWithOptions(":8080", nil, 10, 3)
for {
conn, _ := listener.AcceptKCP()
go handleKCP(conn)
}
}应用场景总结
| 场景 | 推荐协议 | 原因 |
|---|---|---|
| Web 浏览 | HTTP/3 (QUIC) | 更快握手、无队头阻塞 |
| 视频通话 | WebRTC (UDP) | 低延迟 > 可靠性 |
| 直播 | RTMP → SRT/QUIC | 低延迟传输 |
| 在线游戏 | UDP / KCP | 低延迟,允许少量丢包 |
| DNS 解析 | UDP / DoQ | 单次请求,无需连接 |
| 文件传输 | TCP | 要求可靠完整 |
| VPN | WireGuard (UDP) | 避免 TCP over TCP |
| IoT/传感器 | UDP / CoAP | 低功耗,轻量级 |
工程实践:QUIC 0-RTT、连接迁移与 NAT 穿透
1. QUIC 0-RTT 的重放风险
QUIC 0-RTT 允许客户端在恢复连接时直接发送数据,跳过握手——但这也引入了重放攻击风险:
mermaid
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: 首次连接: 完整 1-RTT 握手 + 获得 session ticket
C->>S: ClientHello + 0-RTT Data
Note over S: 客户端直接开始发送数据<br/>但攻击者可以重放这个包!
S-->>C: ServerHello + Finished
S-->>C: 1-RTT Data (确认 or 拒绝)重放攻击场景:
text
攻击者截获 Client 的 0-RTT 包:
1. Client → Server: "转账 100 元给 Bob" (0-RTT 加密)
2. 攻击者重放这个包 10 次
3. Server 可能执行 10 次转账!
防御:
- 服务端只在 0-RTT 中接受 幂等操作(GET 请求)
- 或者用 client token / 唯一 nonce 防重放
- HTTP/3 规范: 0-RTT 只能用于重放安全的请求各场景建议:
| 场景 | 0-RTT 是否安全 | 建议 |
|---|---|---|
| GET / 查询 | ✅ 安全(幂等) | 可以开启 |
| POST / 写入 | ⚠️ 有风险 | 需要额外的重放防护 |
| 支付/交易 | ❌ 危险 | 禁用 0-RTT |
2. 连接迁移
QUIC 的一大优势是连接不绑定 IP:Port,切换网络不需要重建连接:
text
场景: 手机从 WiFi 切到 4G
TCP:
IP 变了 → RST → 重新 TCP 握手 + TLS 握手 → 1-3 RTT 中断
QUIC:
IP 变了 → 发送 PATH_CHALLENGE 帧 → 验证新路径 → 继续传输
→ 最多 1 RTT 中断,且已有数据不需要重新获取mermaid
sequenceDiagram
participant C as Client (WiFi → 4G)
participant S as Server
Note over C,S: 连接基于 Connection ID
C->>S: PATH_CHALLENGE (新 IP:4G)
S->>C: PATH_RESPONSE (验证成功)
Note over C,S: 连接无缝迁移,无丢包3. UDP NAT 映射超时
UDP 是无连接协议,NAT 设备不会像 TCP 那样跟踪连接状态。NAT 映射超时后连接就断了:
| NAT 类型 | UDP 映射超时 | 影响 |
|---|---|---|
| 家用路由器 | 30-120s | 需要频繁发送 keepalive |
| 运营商 NAT (CGNAT) | 60-300s | 超时更不稳定 |
| 企业防火墙 | 通常更长或禁用 UDP | QUIC 可能被直接封 |
go
// QUIC 的 keepalive (PING 帧)
// 默认 15-30s,防止 NAT 映射超时
quic.Config{
KeepAlivePeriod: 15 * time.Second, // 比 NAT 超时短
}4. HTTP/3 回退机制
text
HTTP/3 连接失败时的回退策略:
1. 客户端尝试 HTTP/3 (QUIC), 超时 300ms
2. 如果失败 → 回退到 HTTP/2 (TCP)
3. 客户端记住: 该服务器 HTTP/3 不可用
4. 定期重试 HTTP/3 (Alt-Svc 刷新)
常见失败原因:
- UDP 被防火墙拦截 (最常见!)
- QUIC 版本不兼容
- 中间网络设备丢弃 QUIC 包5. UDP 被防火墙拦截排查
bash
# 1. 测试 UDP 连通性
nc -uv <server> 443
# 2. 用 tcpdump 抓 QUIC 包
tcpdump -i eth0 -n udp port 443
# 3. 看 nginx QUIC 日志
# error.log: quic connection rejected
# 4. 如果有大量 TCP fallback → 说明 UDP 不通QUIC 0-RTT 与连接迁移
0-RTT 握手原理
QUIC 的 0-RTT 允许客户端在首次连接后的重连中,第一个数据包就携带业务数据。前提是双方已通过 1-RTT 握手建立了会话票据(Session Ticket)。
mermaid
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: 首次连接(1-RTT)
C->>S: ClientHello(Inchoate)
S->>C: ServerHello + Encrypted Extensions + Finished
Note over S: 生成 Session Ticket<br/>加密后发给 Client
C->>S: Finished + HTTP Request
S->>C: HTTP Response + Session Ticket
Note over C,S: 后续连接(0-RTT)
C->>S: ClientHello + Session Ticket + <b>HTTP Request(0-RTT 数据!)</b>
Note over S: 验证 Session Ticket<br/>如果有效,直接处理 0-RTT 数据
S->>C: ServerHello + HTTP Response(0-RTT 数据已处理)
C->>S: Finished + 下一批 HTTP Requesttext
0-RTT 数据的安全风险:
- 重放攻击: 攻击者捕获 0-RTT 数据包 → 重放给 Server → 重复执行
例: POST /transfer 被重放 → 重复转账!
防御:
- 幂等设计: 0-RTT 只处理 GET 或带幂等 token 的请求
- Nginx: proxy_request_buffering on + ssl_early_data on
- 服务端: 检查 0-RTT 标志 → 拒绝非幂等请求
- HTTP/3 的 0-RTT 仅允许安全方法(GET, HEAD, OPTIONS)连接迁移(Connection Migration)
QUIC 不依赖 TCP 的四元组(源IP+源端口+目的IP+目的端口)标识连接,而是使用 Connection ID。这意味着切换网络(WiFi→4G)时无需重新握手。
mermaid
sequenceDiagram
participant C as Client (WiFi)
participant S as Server
participant C2 as Client (4G)
Note over C,S: WiFi 连接中
C->>S: QUIC 包<br/>src=10.0.0.5:12345, dst=1.2.3.4:443<br/>Connection ID: ABC123
Note over C: WiFi 断连 → 切换到 4G
Note over C2: 新 IP: 172.16.0.8:54321
C2->>S: QUIC 包<br/>src=172.16.0.8:54321, dst=1.2.3.4:443<br/>Connection ID: ABC123 ← 相同!
Note over S: 通过 Connection ID 识别<br/>→ 这是同一个连接!<br/>→ 更新 peer address
S->>C2: QUIC 包(path validation 可选)
Note over C2,S: 连接继续,无需重新握手 ✅go
// Go quic-go 连接迁移核心
// Server 端自动处理连接迁移(按 Connection ID 路由)
// Client 端:切换网络后只需继续用原 Connection 发送数据
// quic-go 内部自动处理路径验证(Path Validation)
conn, _ := quic.DialAddr(ctx, "example.com:443", tlsConf, quicConf)
// ... WiFi 断连,切换到 4G ...
// 无需重新 Dial!原有的 conn 继续使用
// quic-go 检测到路径变化 → 发送 PATH_CHALLENGE → 收到 PATH_RESPONSE → 无缝迁移text
连接迁移对比:
TCP (含 TCP Fast Open):
四元组 (srcIP, srcPort, dstIP, dstPort) 标识连接
IP 变化 → 四元组变化 → 必须重新 三次握手
→ 至少 1 RTT 中断
QUIC:
Connection ID (64-bit 随机数) 标识连接
IP 变化 → 四元组变化 → Connection ID 不变
→ 0 RTT 中断,只做路径验证
MPTCP (MultiPath TCP):
在 TCP 层多路径,但需要两端都支持
QUIC 的连接迁移只需一端支持QUIC 连接迁移的局限
| 场景 | 是否支持 | 说明 |
|---|---|---|
| WiFi ↔ 4G 切换 | ✅ | 最常见的迁移场景 |
| NAT 重绑定(端口变化) | ✅ | Connection ID 不变 |
| 客户端 IP 不变但端口变 | ✅ | 同上 |
| 客户端断网 > 空闲超时 | ❌ | 默认 30s 无活动就关闭连接 |
| Server 端 IP 变化 | ❌ | 需要 L4 负载均衡支持 Connection ID 路由 |
bash
# nginx QUIC 配置连接迁移
server {
listen 443 quic reuseport;
# 关键: 用 $quic_connection_id 做负载均衡一致性哈希
# 确保同一个 Connection ID 的包始终路由到同一后端
}
登录后即可发表评论 👇