QUIC 网络协议
QUIC,Quick UDP Internet Connection, 从名字可以看出,是一种基于 UDP 协议实现的应用层网络协议。他的出现主要是为了解决 TCP 协议的一些痛点:
- TCP 建立连接的延迟
- TCP存在队头阻塞问题
- 升级 TCP 的工作很困难
- 基于 TCP 的网络迁移后需要重新建立
QUIC 提高了目前使用 TCP 的面向连接的网络应用的性能。它通过使用用户数据报协议(UDP)在两个端点之间创建若干个多路连接来实现这一目标,其目的是为了在网络层淘汰 TCP,以满足许多应用的需求,因此该协议偶尔也会获得 “TCP/2” 的昵称。QUIC 的协议栈如下:
可以看出,QUIC = HTTP/2 + TLS + UDP
实现原理
数据格式
一个 QUIC 数据包的格式如下:
主要由 Header 与 Data 组成,在 Data 中有许多 Frame ,其中 Frame Type 可以有很多种,比如:Stream、ACK、Padding、Window_Update、Blocked 等,传输数据时一般使用的是 Stream 类型,因此我们重点介绍 Stream 帧:
一个 Packet 报文中可以存多个这样的 Stream Frame,对于 Stream 类型的帧,我们可以理解为其就是一条 HTTP 请求。
- Stream ID 作用:多个并发传输的 HTTP 消息,通过不同的 Stream ID 加以区别,类似于 HTTP2 的 Stream ID;
- Offset 作用:类似于 TCP 协议中的 Seq 序号,保证数据的顺序性和可靠性;
- Length 作用:指明了 Frame 数据的长度。
连接建立
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手(1RTT),再 TLS 握手(2RTT),所以需要 3RTT 的延迟才能传输数据,就算 Session 会话服用,也需要至少 2 个 RTT。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。
从图中可以看出,TLS 握手需要 1 个 RTT,也就是 1 次 RTT 就把通信密钥协商好了,这是怎么做到的?
(1)客户端:生成随机数 a,选择公开的大数 G 和 P,计算 A=a*G%P,将 A 和 G 发送给服务器,也就是 Client Hello 消息
(2)服务器:生成随机数 b,计算 B=b*G%P,将 B 发送给客户端,也就是 Server Hello 消息
(3)客户端:使用 ECDH 算法生成通信密钥 KEY = aB = ab*G%P
(4)服务器:使用 ECDH 算法生成通信密钥 KEY = bA = ba*G%P
所以,这里的关键就是 ECDH 算法,a 和 b 是客户端和服务器的私钥,是不公开的,而其他参数是公开的。ECDH 算法有个非常重要的特征:即使知道 A、G、P,通过 A = a*G%P 公式也是无法推到出 a 的,保证了私钥的安全性。
当完成初次连接后,后序要恢复连接可以做到 0-RTT,客户端通过缓存 ServerConfig (B=b*G%P),下次连接时直接使用缓存数据计算通信密钥:
连接迁移
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。因此当出现这种情况:移动设备的网络从 4G 切换到 WIFI ,那么就需要断开连接重新握手建立 TCP 连接。而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
与 TCP 不同的是,QUIC 的连接是基于 64 位的 Connection ID,网络切换并不会影响 Connection ID 的变化,连接在逻辑上仍然是通的。
假设客户端先使用 IP1 发送了 1 和 2 数据包,之后切换网络,IP 变更为 IP2,发送了 3 和 4 数据包,服务器根据数据包头部的 Connection ID 字段可以判断这 4 个包是来自于同一个客户端。QUIC 能实现连接迁移的根本原因是底层使用 UDP 协议就是面向无连接的。
可靠传输
我们知道,TCP 依靠序列号、滑动窗口等机制来保证传输的可靠性,那么 QUIC 为了保证可靠传输也需要实现类似的机制。可靠传输有2个重要特点:
- 完整性:发送端发出的数据包,接收端都能收到。
- 有序性:接收端能按序组装数据包,解码得到有效的数据。
问题一:发送端怎么知道发出的包是否被接收端收到呢?答:通过包号和确认应答号。
包号就是 Header 中的 Packet Number,确认应答包则是将 Data 中的 Frame Type 改为 ACK
Packet Number 是严格递增的,也就是说就是 Packet N 丢失了,重传的 Packet Number 也不是N,而是一个比N大的值。
问题二:既然包号是单调递增的,那接收端怎么保证数据的有序性呢?答:通过数据偏移量 offset。
接收端通过 offset 字段就可以对异步到达的数据包进行排序了。
问题三:为什么要设计 Packet Number 和 offset,直接和 TCP 一样只用包号排序不好吗?答:解决 TCP 的重传歧义问题。
在 TCP 中,RTO(超时时间)是基于 RTT (往返时间) 来计算的,如果 RTT 计算的不精准,那么 RTO 计算的也不会精准。当 TCP 发生超时重传后,客户端发起重传,然后接收到了服务端确认 ACK 。由于客户端原始报文和重传报文序列号都是一样的,那么服务端针对这两个报文回复的都是相同的 ACK。
这样的话,客户端就无法判断出是「原始报文的响应」还是「重传报文的响应」,这样在计算 RTT 时应该选择从发送原始报文开始计算,还是重传原始报文开始计算呢?
- 如果算成原始报文的响应,但实际上是重传报文的响应(上图左),会导致采样 RTT 变大;
- 如果算成重传报文的响应,但实际上是原始报文的响应(上图右),又很容易导致采样 RTT 过小;
因此,QUIC 使用严格递增的 Packet Number ,就能解决这个歧义性问题。
流量与拥塞控制
流量控制
QUIC 实现流量控制的方式:
- 通过 Window_Update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
- 通过 Blocked 帧告诉对端由于流量控制被阻塞了,无法发送数据。
对于 TCP 来说,一个连接就一个滑动窗口,因此容易造成整个连接的阻塞。而 QUIC 的滑动窗口分为 Connection 和 Stream 两个级别:
- Stream 级别的流量控制:Stream 可以认为就是一条 HTTP 请求,每个 Stream 都有独立的滑动窗口,所以每个 Stream 都可以做流量控制,防止单个 Stream 消耗连接(Connection)的全部接收缓冲。
- Connection 流量控制:限制连接中所有 Stream 相加起来的总字节数,防止发送方超过连接的缓冲容量。
拥塞控制
QUIC 协议当前默认使用了 TCP 的 Cubic 拥塞控制算法(慢开始、拥塞避免、快重传、快恢复策略),同时也支持 CubicBytes、Reno、RenoBytes、BBR、PCC 等拥塞控制算法,相当于将 TCP 的拥塞控制算法照搬过来了。
QUIC 是处于应用层的,应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,所以 TCP 拥塞控制算法迭代速度是很慢的。而 QUIC 可以随浏览器更新,QUIC 的拥塞控制算法就可以有较快的迭代速度。
TCP 更改拥塞控制算法是对系统中所有应用都生效,无法根据不同应用设定不同的拥塞控制策略。但是因为 QUIC 处于应用层,所以就可以针对不同的应用设置不同的拥塞控制算法,这样灵活性就很高了。
队头阻塞的解决策略
TCP 的一大缺点是存在队头阻塞问题,如在 HTTP/2 (目前最常用的HTTP协议)中,由于所有请求流都共享一个TCP 的滑动窗口,而会造成队头阻塞。队头阻塞主要分为:
- 发送窗口的队头阻塞:TCP 发送出去的数据,都是需要按序确认的,只有在数据都被按顺序确认完后,发送窗口才会往前滑动。当某个数据报文丢失或者其对应的 ACK 报文在网络中丢失,会导致发送方无法移动发送窗口,这时就无法再发送新的数据,只能超时重传这个数据报文,直到收到这个重传报文的 ACK,发送窗口才会移动,继续后面的发送行为。
- 接收窗口的队头阻塞:接收方收到的数据范围必须在接收窗口范围内,如果收到超过接收窗口范围的数据,就会丢弃该数据。当接收窗口收到有序数据时,接收窗口才能往前滑动,然后那些已经接收并且被确认的「有序」数据就可以被应用层读取。
QUIC 借鉴了 HTTP/2 里的 Stream 的概念,在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (Stream),但与 HTTP/2 不同的是,QUIC 给每一个 Stream 都分配了一个独立的滑动窗口,这样使得一个连接上的多个 Stream 之间没有依赖关系,都是相互独立的,各自控制的滑动窗口。
例如:
A 请求流上的丢包不会影响 B 请求流上的数据发送。但是,对于每个请求流而言,也是存在队头阻塞问题的,也就是说,虽然 QUIC 解决了 TCP 层的队头阻塞,但仍然存在单条流上的队头阻塞。这就是 QUIC 声明的无队头阻塞的多路复用。
参考
[1] QUIC 协议详解
[2] 如何用UDP实现可靠传输