QUIC 协议深度解析

QUIC 是 Google 在 2012 年设计、2021 年由 IETF 标准化为 RFC 9000 的传输层协议,也是 HTTP/3 的底层基础。它运行在 UDP 之上,在用户态重新实现了可靠传输、加密、多路复用——这听起来像是重复造轮子,但背后有一个很具体的工程动机。

TCP 改不动了

TCP 设计于 1974 年,运行在操作系统内核里。这意味着改一个 TCP 特性,需要等内核发布、等操作系统升级、等用户更新系统。互联网上的防火墙、NAT、负载均衡器对 TCP 的行为做了大量假设,任何新的 TCP 选项都可能被中间件静默丢弃或篡改。

Google 统计过,互联网上约 29% 的新 TCP 特性因中间件干扰而无法生效。这不是说 TCP 不够好,而是说 TCP 的演进通道已经被堵死了。

QUIC 选择 UDP 的核心原因就在这里:UDP 对中间件是透明的,中间件不会对 UDP 内容做假设,也不会干预。把协议逻辑搬到用户态,更新一个应用程序就能升级协议,不需要等内核。

代价是显而易见的——拥塞控制、丢包恢复、流量控制这些原本由内核 TCP 处理的事情,现在要在用户态全部重新实现。Google 测量的结果是 QUIC 服务端的 CPU 消耗约是 TCP 的 2 倍。这是一个刻意接受的权衡。

队头阻塞:HTTP/2 没有真正解决的问题

HTTP/2 引入了多路复用,多个请求共用一条 TCP 连接,解决了 HTTP/1.1 时代"一个请求占满连接"的问题。但它遇到了 TCP 的一个根本限制。

TCP 是字节流协议,它不知道上层有多个独立的数据流。当网络丢了一个包,TCP 必须停下来等待重传,整条连接上所有的数据流都被阻塞——哪怕其他流的数据早就到了,也只能在接收缓冲区里等着。这就是 TCP 队头阻塞

实测数据很能说明问题:在 1% 丢包率的网络下,HTTP/2 的性能甚至不如 HTTP/1.1(后者用多条独立 TCP 连接,一条被阻塞不影响其他)。HTTP/2 的多路复用在有丢包的网络里反而是负优化。

QUIC 在协议层面原生支持流(Stream),每个流有自己独立的序号空间。一个流的丢包只阻塞这个流,其他流继续传输不受影响。这才是真正意义上的多路复用。

TCP + HTTP/2(丢包时):
流 A: ████ ░░░░ ████  ← 等包 3 重传,全部阻塞
流 B: ████ ░░░░ ████  ← 数据已到,但被迫等待
流 C: ████ ░░░░ ████

QUIC(丢包时):
流 A: ████ ░░░░ ████  ← 等包 3 重传,只有流 A 阻塞
流 B: ████████████    ← 正常传输,不受影响
流 C: ████████████

握手:从 3.5 RTT 到 0 RTT

建立一个 HTTPS 连接的开销:TCP 三次握手(1.5 RTT)+ TLS 1.2 握手(2 RTT)= 3.5 RTT,才能发出第一个字节。TLS 1.3 把握手压到了 1 RTT,但 TCP 握手本身的 1.5 RTT 还在。

移动网络下一次 RTT 经常超过 100ms,3.5 RTT 就是 350ms——用户点开一个链接,光等建立连接就要等将近半秒。

QUIC 把传输层握手和 TLS 握手合并,首次连接只需 1 RTT

sequenceDiagram
    participant C as Client
    participant S as Server
    Note over C,S: 首次连接 1-RTT
    C->>S: Initial ClientHello
    S-->>C: Initial ServerHello
    S-->>C: Handshake Certificate
    Note over C,S: 一个 RTT 内同时完成
    S-->>C: Handshake Finished
    C->>S: Handshake Finished
    C->>S: 1-RTT Data 请求
    Note over C,S: 握手完成 立即发数据
    S-->>C: 1-RTT Data 响应

更进一步,恢复连接可以做到 0 RTT。第一次连接时,服务端会下发一个 Session Ticket(包含共享密钥)。下次连接时,客户端用这个 ticket 直接加密数据,不等任何握手响应:

sequenceDiagram
    participant C as Client
    participant S as Server
    Note over C,S: 恢复连接 0-RTT
    C->>S: Initial + 0-RTT Data 请求
    Note over C: 直接发 不等握手
    S-->>C: Initial + 1-RTT Data 响应

0-RTT 有一个不可回避的代价:无法防御重放攻击。攻击者可以截获 0-RTT 数据包并重复发送。因此 0-RTT 只应用于幂等请求(GET),有副作用的操作(POST、支付)必须等 1-RTT 握手完成。

连接迁移:手机切网不断线

TCP 连接由四元组(源 IP、源端口、目标 IP、目标端口)唯一标识。手机从 WiFi 切到 4G,IP 地址变了,四元组变了,TCP 连接就断了,必须重新握手。

QUIC 用 Connection ID 标识连接,这是一个随机生成的不透明字节串,与网络地址完全无关。切换网络时,客户端只需用新地址发数据包,服务端通过 Connection ID 识别是同一个连接,握手状态、加密密钥、流的状态全部保留,无需重新握手

迁移时还有一个隐私细节:QUIC 会在路径切换时更换 Connection ID。这样网络路径上的观察者(ISP、路由器)无法把迁移前后的流量关联起来,防止跨网络的用户追踪。

迁移后 QUIC 会做路径验证(PATH_CHALLENGE / PATH_RESPONSE),确认新路径可达,然后把拥塞窗口重置到保守值,避免在未知网络质量的新路径上一开始就发太多数据。

加密:不是可选项

QUIC 强制要求 TLS 1.3,没有明文模式。这个决定有两层含义。

第一层是安全:所有数据强制加密,没有降级风险。

第二层是工程:加密是为了防止中间件干扰。中间件无法解析加密的 QUIC 包内部结构,就无法对它做错误的假设或修改。QUIC 的包头也做了部分加密(Header Protection),防止中间件根据包头字段做流量干预。这是 QUIC 能在真实互联网上稳定运行的重要保障。

QUIC 与 HTTP/3

HTTP/3 是运行在 QUIC 上的应用层协议,基本上是把 HTTP/2 的语义搬到 QUIC 上,同时修掉了一个遗留问题。

HTTP/2 用 HPACK 压缩请求头,HPACK 依赖一个有序的动态表——发送方和接收方必须按顺序处理头部更新,否则两端的动态表会不一致。这在多路复用下引入了新的队头阻塞:一个流的头部更新没收到,其他流的头部也没法解压。

HTTP/3 用 QPACK 替代 HPACK。QPACK 把动态表的更新和实际请求分离,通过两条独立的单向流传输,请求流和表更新流互不依赖,彻底消除了头部压缩层面的队头阻塞。

对应用开发者来说,HTTP/3 的 API 与 HTTP/2 完全兼容,启用 HTTP/3 通常只需在服务端配置中加一行:

# Nginx
listen 443 quic reuseport;
add_header Alt-Svc 'h3=":443"; ma=86400';  # 告知浏览器支持 HTTP/3

浏览器收到 Alt-Svc 头后,下次访问会尝试 QUIC。如果 UDP 443 被防火墙拦截,会自动回退到 TCP。

什么时候 QUIC 有用,什么时候没用

QUIC 的收益高度依赖网络环境:

  • 高丢包网络(移动网络、卫星网络):流级别隔离的价值最大,HTTP/2 在这里的劣势最明显
  • 高延迟网络:0-RTT 和 1-RTT 握手节省的绝对时间更多
  • 频繁切换网络:连接迁移避免了重新握手的开销
  • 短连接、高并发:0-RTT 对每次连接都有收益

Google 在 YouTube 上的实测:弱网下视频缓冲时间减少 15~18%,重新缓冲率减少 18%。Facebook 的数据:图片加载减少 8%,视频启动减少 20%。

但在数据中心内网(低延迟、极低丢包),QUIC 相比 TCP 几乎没有优势。更麻烦的是,UDP 的内核优化(GSO/GRO)成熟度不如 TCP,加上用户态加密的 CPU 开销,QUIC 在内网高吞吐场景下可能反而更慢。内网服务间通信通常不值得用 QUIC。

还有一个部署障碍:部分企业防火墙默认封锁 UDP 443,QUIC 会静默失败。浏览器用 Happy Eyeballs 策略并行尝试 QUIC 和 TCP,但服务端如果没有正确配置 Alt-Svc 回退,用户体验会变差。

总结

QUIC 解决的三个核心问题,背后都有清晰的工程逻辑:

  • 队头阻塞:TCP 是字节流,不理解上层的流边界;QUIC 在协议层内置流,丢包只影响对应流
  • 握手延迟:TCP 和 TLS 是两次独立握手;QUIC 合并为一次,复连可以 0 RTT
  • 连接迁移:TCP 用四元组标识连接,地址变化连接就断;QUIC 用 Connection ID,地址无关

QUIC 把协议逻辑从内核移到用户态,本质上是在用 CPU 换灵活性——这个权衡在 Google、Meta 这种规模下是合算的,在数据中心内网则未必。理解这一点,才能知道什么场景下该用 QUIC,什么场景下它只是增加了复杂度。