Skip to content

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 --> Payload

8 字节头部 vs TCP 的 20-60 字节。UDP 的哲学:把可靠性和流量控制交给应用层(如 QUIC),传输层只管"尽力而为"。

核心特性 ​

特性UDPTCP
连接无连接面向连接
可靠性不可靠(丢包不重传)可靠(确认+重传)
顺序不保证严格有序
头部开销8 字节20-60 字节
流量控制无滑动窗口
拥塞控制无有(CUBIC/BBR)
多路复用仅端口无流级复用
适用场景实时音视频/DNS/DHCPHTTP/文件传输/邮件

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 和 3

QUIC 在 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/2QUIC + HTTP/3
一个流丢包 1%所有流都受影响只有丢包的流受影响
丢包率 2% 时吞吐下降 30-50%几乎不变
弱网环境(4G/地铁)体验差(页面加载慢)明显提升

拥塞控制 — 从 CUBIC 到 BBR ​

QUIC 在用户态实现拥塞控制,这意味着可以热更新拥塞算法而不需要重启。目前主流的算法已经从"基于丢包"转向"基于带宽探测":

算法原理丢包敏感高带宽场景缓冲膨胀
CUBIC三次函数调速(Linux 内核默认)❌ 见到丢包就降速🟡❌ 会填满瓶颈缓冲区
BBR持续探测带宽 + RTT✅ 丢包不一定降速✅✅✅ 几乎不填满缓冲区
BBRv2BBR + 丢包辅助 + 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
    end

CUBIC 的致命问题:它依赖缓冲区溢出导致的丢包作为"降速信号"。在高带宽延迟网络(长肥管道)中,这意味着缓冲区已经满了还在发、用户已经感受到了延迟加剧——这被称为"缓冲膨胀"(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 只需几十毫秒。这对用户体验是质的提升。

特性TCPQUIC
传输层内核实现用户态(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% 带宽)
- 可选择跳过丢包(不等待重传)
- 自定义超时重传 RTO
go
// 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要求可靠完整
VPNWireGuard (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超时更不稳定
企业防火墙通常更长或禁用 UDPQUIC 可能被直接封
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 Request
text
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 的包始终路由到同一后端
}

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记