3. QUIC 连接建立与握手:1-RTT、0-RTT 与 TLS 1.3 的深度集成

聊到 QUIC 的握手,很多人第一反应就是“快”。确实,它比 TCP + TLS 1.2 那一套快了不少。但快在哪里?为什么能快?这背后其实是 TLS 1.3 的协议设计与 QUIC 传输层深度融合的结果。

我个人习惯把 QUIC 的握手分成两个场景来看:初次连接和再次连接。初次连接走的是 1-RTT 握手,再次连接则可以尝试 0-RTT。咱们一个一个拆开讲。

3.1 1-RTT 握手流程:初次连接的必经之路

先看最标准的场景。客户端第一次和服务器建立 QUIC 连接,这时候双方没有任何共享状态。握手需要 1 个 RTT(往返时延)就能完成数据交互。

流程其实很清晰:

  1. 客户端发送 Initial 包:包含 TLS 1.3 的 ClientHello,以及 QUIC 的传输参数(比如最大流数量、初始流量控制窗口等)。
  2. 服务器回复 Initial + Handshake 包:服务器收到 ClientHello 后,回复 ServerHello、EncryptedExtensions、Certificate、CertificateVerify、Finished 等一系列消息。这些消息被分装在 Initial 和 Handshake 两个加密级别中。
  3. 客户端发送 Handshake 包:客户端验证服务器证书,发送自己的 Finished 消息。此时,1-RTT 加密密钥已经就绪。
  4. 双方可以发送 1-RTT 数据:握手完成,应用数据开始传输。

关键点:QUIC 的握手不是简单的“发一次、回一次”。它把 TLS 1.3 的握手消息拆分到了不同的包空间(Initial、Handshake、1-RTT)中。这样做的好处是,即使 Initial 包丢了,Handshake 包依然可以独立处理,不会阻塞整个握手。

我在项目中遇到过一个问题:客户端发送的 Initial 包因为 MTU 分片问题被中间设备丢弃了,服务器一直收不到 ClientHello。嗯,这里要注意,QUIC 的 Initial 包必须足够小,通常建议控制在 1200 字节以内,否则很容易被路径上的老旧设备拦截。

3.2 0-RTT 握手流程:秒开的秘密

如果你之前连接过某个服务器,并且缓存了它的会话票据(Session Ticket),那么下次再连的时候,就可以尝试 0-RTT 握手。

说白了,0-RTT 就是“我信任你,所以不等你确认,我先发数据”。流程如下:

  1. 客户端发送 Initial + 0-RTT 包:客户端直接复用之前协商好的密钥,加密并发送应用数据。同时,它也会发送一个新的 ClientHello,用于更新密钥。
  2. 服务器处理 0-RTT 数据:如果服务器接受 0-RTT,它会直接解密并处理这些数据。如果拒绝(比如密钥过期了),服务器会忽略 0-RTT 数据,客户端需要回退到 1-RTT 重传。
  3. 握手完成:服务器回复 ServerHello 等消息,最终双方切换到 1-RTT 密钥。

避坑指南:我曾经因为 0-RTT 数据中包含幂等性校验不严格的请求(比如重复下单),导致线上出现了重复支付的问题。0-RTT 数据是可能被重放的,所以你的应用层必须做好幂等性处理。别问我怎么知道的……

3.3 TLS 1.3 在 QUIC 中的集成:不是简单的“套壳”

很多人以为 QUIC 就是“UDP + TLS 1.3”,其实没那么简单。TLS 1.3 在 QUIC 中的集成是深度定制的,而不是简单的“把 TLS 记录层塞进 UDP 包”。

我总结了几点核心差异:

对比项 传统 TLS 1.3 over TCP TLS 1.3 in QUIC
记录层 独立的 TLS 记录层,有 ContentType、长度等字段 没有独立的记录层,TLS 消息直接嵌入 QUIC 帧中
密钥更新 通过 KeyUpdate 消息触发 通过 QUIC 的密钥阶段(Key Phase)位来切换
握手消息保护 使用 TLS 的握手流量密钥 使用 QUIC 的包保护(Packet Protection)机制
0-RTT 支持 需要 TCP 连接建立后才能发送 可以在第一个 UDP 包中直接发送

为什么会这样设计?因为 QUIC 需要自己处理丢包和乱序。如果沿用 TLS 记录层,那么 TLS 消息的边界和 QUIC 包的边界就会打架。你想想看,一个 TLS 记录可能被拆成多个 QUIC 包,或者多个 TLS 记录合并到一个 QUIC 包里,这会让实现变得极其复杂。

所以 QUIC 的做法是:每个 QUIC 包只包含一个加密级别的内容。Initial 包只包含 Initial 密钥保护的数据,Handshake 包只包含 Handshake 密钥保护的数据。这样,即使某个包丢了,也不会影响其他加密级别的数据处理。

3.4 传输参数协商:握手阶段的“隐形契约”

握手不仅仅是交换密钥,还要协商一堆传输参数。这些参数决定了连接的行为方式。我习惯把它们分成三类:

  • 流控制参数:比如 initial_max_data、initial_max_stream_data_bidi_local 等,控制着发送方可以发送多少数据。
  • 连接参数:比如 idle_timeout、max_udp_payload_size,控制着连接的超时时间和包大小。
  • ACK 频率参数:比如 ack_delay_exponent,控制着 ACK 的延迟策略。

这些参数是在 ClientHello 和 ServerHello 的扩展字段中传输的。双方各自声明自己的参数,然后取交集或按规则选择。

小技巧:抓包时,如果你看到握手完成后数据传输很慢,不妨先检查一下传输参数。我曾经遇到过一个问题:客户端声明的 initial_max_data 只有 10KB,导致服务器每次只能发一点点数据,吞吐量上不去。后来发现是客户端的一个 bug,把参数写死了。

下面这张图展示了整个握手流程中,各个消息和参数的交互关系:

QUIC 握手流程与传输参数协商 客户端 服务器 ① Initial: ClientHello + 传输参数 ② Initial: ServerHello + 传输参数 ③ Handshake: 证书 + 证书验证 + Finished ④ Handshake: Finished ⑤ 1-RTT: 应用数据开始传输 传输参数协商细节 • 客户端在 ClientHello 中声明自己的传输参数(如 initial_max_data) • 服务器在 ServerHello 中回复自己的传输参数 • 双方取交集或按规则选择最终参数

你看,整个握手过程其实就是在做两件事:交换密钥和协商参数。两者缺一不可。密钥交换保证了通信的安全,参数协商则保证了通信的效率和可靠性。

最后说一句,0-RTT 虽然快,但别滥用。我建议只在以下场景使用:

  • 用户重新打开 App 或网页时
  • 连接迁移后(比如从 WiFi 切到 4G)
  • 预加载场景(比如视频流的前几秒)

对于涉及金钱交易、状态变更的请求,老老实实走 1-RTT 吧。安全第一,快是其次。