3. QUIC 连接建立:1-RTT 握手、0-RTT 握手、Transport Parameters 协商、版本协商机制
聊到 QUIC 的连接建立,我得先跟你交个底——这是 QUIC 最让我觉得「真香」的部分。以前做 TCP+TLS 优化时,每次握手至少 2-RTT,碰上长肥网络简直想骂人。QUIC 把传输层和安全层揉在一起,1-RTT 就能建连,甚至 0-RTT 就能直接发数据。
但别高兴太早。0-RTT 有坑,版本协商也有坑。我踩过,今天一并说清楚。
3.1 1-RTT 握手:标准流程
1-RTT 握手是 QUIC 最基础的建连方式。说白了,就是客户端和服务端各发一次握手包,然后就能愉快地传数据了。
流程其实很清晰:
- 客户端发 Initial:包含 Client Hello、支持的加密套件、Transport Parameters。
- 服务端回 Initial + Handshake:包含 Server Hello、证书、服务端 Transport Parameters。
- 客户端发 Handshake:确认密钥,握手完成。
嗯,这里要注意——QUIC 的 Initial 包是明文的,但 Handshake 包就已经加密了。我在项目里见过有人抓包只看 Initial,以为 QUIC 全程明文,差点闹笑话。
关键点:1-RTT 握手完成后,客户端和服务端已经协商出 4 套密钥——Initial 密钥、Handshake 密钥、1-RTT 发送密钥、1-RTT 接收密钥。别搞混了。
3.2 0-RTT 握手:快,但有代价
0-RTT 是 QUIC 的杀手锏。如果客户端之前跟这个服务端建立过连接,缓存了会话信息,那下次建连时可以直接在 Initial 包里带上 0-RTT 数据。
流程是这样的:
- 客户端发 Initial + 0-RTT 数据(用之前缓存的密钥加密)。
- 服务端收到后,如果能解密,直接处理数据,同时回复 Handshake。
- 如果解密失败,服务端会忽略 0-RTT 数据,回退到 1-RTT 握手。
我曾经踩过的坑:0-RTT 数据存在重放攻击风险。比如客户端发了个「转账 100 元」的 0-RTT 请求,攻击者截获后重放一次,服务端可能处理两次。所以,幂等性校验是必须的。我当年上线 0-RTT 时没注意这个,结果线上出了重复订单,被运维追着骂了一周。
我个人习惯:0-RTT 只用来发查询类请求,或者带一个唯一请求 ID 让服务端去重。写操作?老老实实走 1-RTT。
3.3 Transport Parameters 协商
Transport Parameters 是 QUIC 连接建立时双方交换的「能力声明」。说白了,就是告诉对方「我能支持什么,你看着办」。
这些参数在 Initial 和 Handshake 包里传输,用 TLS 扩展字段承载。我整理了一份常用参数表:
| 参数名 | 含义 | 我的建议值 |
|---|---|---|
| initial_max_data | 初始最大数据量 | 10MB(别设太小,否则慢启动卡脖子) |
| initial_max_stream_data_bidi_local | 双向流本地最大数据 | 1MB |
| initial_max_streams_bidi | 最大双向流数量 | 100 |
| max_idle_timeout | 空闲超时时间 | 30s(移动网络建议 10s) |
| active_connection_id_limit | 最大连接 ID 数量 | 2(多路径场景可以设 4) |
| ack_delay_exponent | ACK 延迟指数 | 3(默认值,别乱改) |
避坑指南:Transport Parameters 是单向声明的,不是协商。你设了 initial_max_streams_bidi = 100,对方设了 50,那实际生效的是 min(100, 50) = 50。别指望对方会迁就你。
3.4 版本协商机制
QUIC 的版本协商,说实话,设计得有点「粗暴」。如果客户端发的版本服务端不支持,服务端直接回一个 Version Negotiation 包,列出自己支持的版本列表。客户端收到后,重新选一个版本再连。
流程就三步:
- 客户端发 Initial(版本号 = X)。
- 服务端不支持 X,回 Version Negotiation 包(无状态,不加密)。
- 客户端从列表里选一个支持的版本,重新发 Initial。
注意:Version Negotiation 包是明文的,而且无状态。攻击者可以伪造这个包,诱导客户端降级到有漏洞的旧版本。这就是著名的「版本降级攻击」。
我记得有一次排查线上问题,发现客户端一直在循环发 Initial,服务端也一直在回 Version Negotiation。抓包一看,中间有个中间件把版本号改成了 0x00000000,导致双方永远谈不拢。嗯,这种问题定位起来特别恶心。
我个人建议:客户端收到 Version Negotiation 后,必须校验服务端身份。比如用之前缓存的服务端证书指纹做比对,或者干脆只信任自己白名单里的版本。
3.5 核心流程总览
下面这张图把整个连接建立流程串起来了。你盯着看 30 秒,应该能记住 80%。
3.6 日志追踪实战要点
最后,聊点实际的。你在日志里怎么判断当前是哪种握手?
- 看到 Initial 包里有 early_data 扩展:说明客户端在尝试 0-RTT。
- 服务端回了 Retry 包:说明服务端怀疑客户端是假的,要求重新验证。嗯,这个 Retry 机制我后面会专门讲。
- 看到 Version Negotiation 包:版本不匹配,赶紧检查客户端和服务端的版本号。
- Transport Parameters 里 max_idle_timeout 设得太小:连接容易被断开,尤其是移动网络下。我见过有人设 3 秒,结果用户刷个页面都要断连重连。
我的习惯:每次上线 QUIC 服务前,我都会抓一次 1-RTT 和 0-RTT 的握手包,确认 Transport Parameters 都协商对了。特别是 initial_max_data,设小了会影响吞吐,设大了会浪费内存。10MB 是个比较稳妥的起点。