3. QUIC 连接建立与握手
聊到 QUIC,握手绝对是绕不开的核心。说实话,我第一次看 QUIC 的握手流程时,脑子里就一个想法——「这不就是把 TCP 和 TLS 揉在一起了吗?」后来深入进去才发现,事情远没那么简单。
QUIC 的握手,本质上是在解决一个老问题:如何让加密传输的连接建立得足够快。TCP 要三次握手,TLS 1.3 要一次往返,加起来至少 2-RTT。QUIC 直接把这两步合并了,1-RTT 就能建连,甚至 0-RTT 就能发数据。
核心要点:QUIC 握手 = 传输层握手 + 安全层握手合并。一次加密协商,同时完成连接建立和密钥交换。
3.1 1-RTT 握手流程详解
先看最标准的场景:客户端第一次连接服务器。这时候要走完整的 1-RTT 握手。
流程其实很清晰,我习惯把它拆成两个阶段来看:
- 客户端初始包(Initial):客户端发第一个包,里面包含 TLS 1.3 的 ClientHello,以及 QUIC 的传输参数。这个包是明文传输的,但内容已经做了部分保护。
- 服务器响应(Handshake):服务器收到后,回复 ServerHello + 证书 + 传输参数。同时服务器已经可以计算出手握手的密钥了。
- 客户端完成(Handshake Finished):客户端验证证书,计算密钥,发送 Finished 消息。这时候双方都有了应用数据的密钥。
- 应用数据开始:从第 2 个 RTT 开始,双方就可以加密传输真正的业务数据了。
嗯,这里要注意一个细节:QUIC 的握手包是分加密级别的。Initial 级别的包用初始密钥加密,Handshake 级别的包用握手密钥加密,Application 级别的包才用真正的业务密钥。我在项目中遇到过有人把这三个级别搞混,结果调试了整整两天才发现是密钥层级用错了。
我的经验:调试 QUIC 握手时,先抓包看加密级别。如果 Initial 包都解密不了,大概率是版本协商出了问题。别一上来就怀疑证书。
3.2 0-RTT 握手流程详解
0-RTT 是 QUIC 最亮眼的功能之一。说白了,就是客户端在第一次握手时拿到了服务器的「凭证」,下次再连的时候,直接拿这个凭证加密数据发过去。
流程是这样的:
- 客户端保存了之前握手时服务器下发的 0-RTT 令牌(通常是 NewSessionTicket 消息里的东西)。
- 新连接建立时,客户端直接发 0-RTT 数据包,同时带上 ClientHello。
- 服务器收到后,如果能验证令牌有效,直接解密 0-RTT 数据并处理。
- 然后服务器回复 Handshake 包,完成后续的密钥升级。
你想想看,这意味着什么?用户第二次打开你的 App,数据请求在第一个网络包就发出去了。延迟直接降了一个 RTT。
注意:0-RTT 数据存在重放攻击风险。因为服务器还没确认客户端的身份,攻击者可以截获 0-RTT 包并重放。我建议只在幂等操作(如查询、预加载)中使用 0-RTT,不要在支付、下单等敏感操作里用。
我曾经在一个直播项目中用过 0-RTT 做弹幕重连。用户断网重连后,弹幕流在 50ms 内就恢复了,体验提升非常明显。但后来发现,如果服务器集群没做好 0-RTT 令牌的同步,用户切节点时会失败。嗯,这个坑踩得值。
3.3 TLS 1.3 在 QUIC 中的集成
QUIC 没有自己搞一套加密协议,而是直接用了 TLS 1.3。但集成方式跟 HTTPS 完全不同。
在 HTTPS 里,TLS 跑在 TCP 之上,是独立的协议层。在 QUIC 里,TLS 1.3 被嵌入到 QUIC 的帧结构中。具体来说:
- TLS 的握手消息(ClientHello、ServerHello 等)被封装在 QUIC 的 CRYPTO 帧里。
- QUIC 的传输参数通过 TLS 的扩展字段传递。
- 密钥的生成和更新完全由 TLS 1.3 的密钥调度逻辑控制。
我画了一张图,帮你理解这个集成关系:
这种集成方式有个好处:TLS 的握手状态和 QUIC 的连接状态是同步的。TLS 计算出新密钥,QUIC 立刻就能用。不像 HTTPS 里,TLS 握手完还要等 TCP 确认。
3.4 握手过程中的参数协商
握手不只是交换密钥,还要协商一堆 QUIC 的传输参数。这些参数通过 TLS 的扩展字段传递,我列个表给你看:
| 参数名 | 含义 | 我的建议值 |
|---|---|---|
initial_max_data |
初始最大数据量 | 10MB(避免小文件场景频繁更新) |
initial_max_stream_data_bidi_local |
双向流本地最大数据 | 1MB |
initial_max_stream_data_bidi_remote |
双向流远端最大数据 | 1MB |
initial_max_stream_data_uni |
单向流最大数据 | 512KB |
initial_max_streams_bidi |
最大双向流数量 | 100 |
initial_max_streams_uni |
最大单向流数量 | 100 |
max_idle_timeout |
空闲超时时间 | 30秒(移动端建议更长) |
max_udp_payload_size |
最大 UDP 包大小 | 1200字节(避免分片) |
active_connection_id_limit |
最大连接 ID 数量 | 2(用于连接迁移) |
这些参数在握手时由双方各自声明,最终取最小值作为实际值。比如客户端说最大流数量是 100,服务器说 50,那最终就是 50。
避坑指南:我曾经把 max_idle_timeout 设成了 10 秒,结果用户在弱网环境下频繁断连。后来改成 30 秒,配合心跳包,问题就解决了。移动端建议设到 60 秒以上。
还有一个容易被忽略的点:传输参数是在 TLS 的扩展中传递的,这意味着它们也是加密的。中间人看不到你协商了哪些参数。嗯,这比 TCP 的 SYN 选项安全多了。
最后说一句,握手过程中的参数协商是 QUIC 灵活性的体现。你可以根据业务场景调整这些参数,比如视频流场景加大流控窗口,IoT 场景缩短超时时间。但记住一点:改参数前先理解参数,别像我一样踩过坑才回头补课。