《基于QUIC传输的自适应流媒体技术研究》
《高性能移动直播场景下QUIC协议研究与应用》
技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解
一泡尿的时间,快速读懂QUIC协议
---------------------------------------------------------------------------------
从上个世纪 90 年代互联网开始兴起一直到现在,大部分的互联网流量传输只使用了几个网络协议。使用 IPv4 进行路由,使用 TCP 进行连接层面的流量控制,使用 SSL/TLS 协议实现传输安全,使用 DNS 进行域名解析,使用 HTTP 进行应用数据的传输。三十年来改变不是很大。面对如今庞大的数据传输需求,难以支撑。
QUIC(Quick UDP Internet Connections),是谷歌提出的新的传输协议,与之相比的是TCP。众所周知,TCP建立连接前需要三次握手,如果为了安全性加入TLS安全协议,握手次数会更多,可见其建立连接的成本高。相比TCP,QUIC可以减少延迟,除了初次建链是 1-RTT(三次握手),其余的建链过程通常是 0-RTT(一次握手)。
TCP是在操作系统内核和中间件固件中实现的,所以改变TCP协议几乎不可能。QUIC是建立在UDP之上实现可靠传输,相比TCP,它的流控功能在用户空间而不在内核空间。QUIC非常类似于在 UDP 上实现的 TCP + TLS + HTTP/2,但与之相比,QUIC存在以下特征。
1)利用缓存,显著减少连接建立时间;
2)改善拥塞控制,拥塞控制从内核空间到用户空间;
3)没有 head of line 阻塞的多路复用;
4)前向纠错,减少重传;
5)连接平滑迁移,网络状态的变更不会影响连接断线。
如下图:QUIC建立于UDP之上,上层使用HTTP/2 API实现与服务器的交互。QUIC 协议已经包含了多路复用和连接管理,HTTP API 只需要完成 HTTP 协议的解析即可。下图中,蓝色部分为网络层协议,橙色部分为传输层协议,绿色部分为应用层协议,蓝色部分的网络层协议和橙色部分的传输层协议工作于操作系统内核空间、绿色部分的应用层协议工作于用户空间。TCP 实现了比较多的功能,对 IP 的封装比较厚,而 UDP 相对于 TCP 而言,其对 IP 的封装较薄。
QUIC协议的主要目的,是为了整合 TCP 协议的可靠性和 UDP 协议的速度和效率。同时QUIC是应用层协议,相对于等待传输层协议的改良和优化,使用应用层协议的优点在于只要服务端和客户端统一使用某协议则该协议即可被应用,数据包不需要广域网的网络承载基建更新即可正确被传输,相对于传输层协议而言,应用层协议更加能够根据开发者的实际需求进行自定义并进行快速实践。QUIC 协议处在五层协议栈中的应用层,而 QUIC 协议本身也是分层的,QUIC 协议构筑在 UDP 的 Socket 之上主要分为有两层,上层由一条或多条 QUIC 流(Stream)构成,下层则为对接上层 QUIC 流与传输层 UDP Socket 的 QUIC 连接(Connection),如下图所示。上层的应用可以通过创建不同的 QUIC 流向对端发送数据,QUIC 连接可以将上层的数据帧(Data Frame)进行封装,以包(Packet)的形式将数据交付给 UDP Socket 进行传输,并统一管理多条流的数据收发。下面将分别研究 QUIC 连接和 QUIC 流的具体设计原理。
------------------------------------------------------------
1、延迟低:QUIC由于建立在 UDP 的基础上,同时又实现了 0RTT 的安全握手,所以在大部分情况下,只需要 0 个 RTT 就能实现数据发送,在实现前向加密的基础上,并且 0RTT 的成功率相比 TLS 的 Sesison Ticket要高很多。
2、拥塞控制:默认使用了 TCP 协议的 Cubic 拥塞控制算法,同时也支持 CubicBytes, Reno, RenoBytes, BBR, PCC 等拥塞控制算法。但QUIC有以下改进之处。
(1)、可插拔:应用程序层面就能实现不同的拥塞控制算法;单个应用程序的不同连接也能支持配置不同的拥塞控制;应用程序不需要停机和升级就能实现拥塞控制的变更。
(2)、单调递增的 Packet Number:TCP使用序列号保证可靠性;QUIC使用Packet Number替代序列号,每个 Packet Number 都严格递增,重传的数据包的number直接递增,不是原来一样。
(3)、不允许 Reneging:QUIC 在协议层面禁止 Reneging,一个 Packet 只要被 Ack,就认为它一定被正确接收,减少干扰。
(4)、更多的 Ack 块:Quic Ack Frame 可以同时提供 256 个 Ack Block,在丢包率比较高的网络下,更多的 Sack Block 可以提升网络的恢复速度,减少重传量。
(5)、Ack Delay 时间:即接收端收到数据包到发出ACK期间的延迟。QUIC计算RTT如下
3、基于 stream 和 connecton 级别的流量控制:提供了stream和connection两种级别的流量控制。QUIC支持多路复用,多路复用即在一条 Connetion 上会同时存在多条 Stream。既需要对单个 Stream 进行控制,又需要针对所有 Stream 进行总体控制。QUIC的连接级流控,用以限制 QUIC 接收端愿意分配给连接的总缓冲区,避免服务器为某个客户端分配任意大的缓存。连接级流控与流级流控的过程基本相同,但转发数据和接收数据的偏移限制是所有流中的总和。
QUIC的流量控制策略:
(1)、通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
(2)、通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据
QUIC的窗口滑动只取决于接收到的最大偏移字节数。
4、没有队头阻塞的多路复用:QUIC 一个连接上的多个 stream 之间没有依赖。这样假如 stream2 丢了一个 udp packet,也只会影响 stream2 的处理。不会影响 stream2 之前及之后的 stream 的处理。每个流的帧在到达接收端时能立即分派到该流对应的缓存区,使得没有丢失的流可以继续被组装。QUIC中最基本的传输单元是 Packet,传输、加密和认证都是基于packet的。与TCP的对比如下两图。
5、加密认证的报文:QUIC除了个别报文所有报文头部都是经过认证的,报文 Body 都是经过加密的。这样只要对 QUIC 报文任何修改,接收端都能够及时发现,有效地降低了安全风险。
6、连接迁移:任何一条 QUIC 连接不再以 IP 及端口四元组标识,而是以一个 64 位的随机数作为 ID 来标识,这样就算 IP 或者端口发生变化时,只要 ID 不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。这个 ID 是客户端随机产生的,并且长度有 64 位,冲突概率非常低。
7、数据包流量整形:可以通过调整 Pacing 机制减少数据包丢失。Pacing减少了分组流的波动,从而减少基于拥塞的损耗(即由于溢出而导致的路由器中的分组丢弃)。如图 2.7,当前 QUIC 是基于带宽估计来达到动态调整 Pacing 的目的。
------------------------------------------------------------
如上图,QUIC 的结构框架主要由认证加密、流量控制、丢失重传和拥塞控制组成。数据传输的实现方式为一个连接内复用多个流传输数据,每个请求对应一个流。QUIC建链会使用专用流以实现较小的连接延迟,其他数据通过普通流传输。由客户端主动发起的流 ID 为奇数,由服务器主动发起的流 ID 为偶数。认证加密主要在握手的过程中完成;流量控制在接收到 STREAM 帧后作出响应,流关闭后触发连接级的流量控制;丢失重传在接收到 ACK 帧后作出响应,ACK包含目前观测到的最大包序号和未接收到的包的列表;
QUIC 关键机制主要分为四部分,建链机制,发包机制,流量控制以及拥塞控制。如下所示。
(1),客户端向服务端发送不完整的CHLO(客户端Hello)报文;其中包含的Tag/Value对包括了VERS(必须,协议版本信息)、SNI(可选,服务端名称标识)等;
(2),服务端响应REJ报文(Rejection Message);其中包含的Tag/Value对包括了:SCFG(可选,即ServerConfig,这是服务端的序列化的配置)、STK(可选,源地址令牌,一串由客户端保存的不透明字节,客户端在之后的Hello报文中必须通过携带它来证明身份)、SNO(可选,服务端随机数,由客户端存储的随机数,并在今后所有的完整CHLO报文中携带它)、CRT(可选,证书链)等。到此握手过程显然已经1-RTT,必然以服务端拒绝结束,因为第一步发送的CHLO携带的是不完整的信息,该REJ报文和一般的拒绝报文不同,它并不是单纯地表示拒绝连接,而是携带了很多跟后续通信相关的信息。所以,尽管协议中REJ报文的Tag/Value对都是可选的,但如果服务端希望接受客户端的请求,则服务端必须回应足够的信息以使得客户端能够发起接下来的通信。
(3),客户端收到了服务端配置(即REJ报文中的SCFG)并验证了证书后,就可以发送完整的CHLO报文了,这个报文中除了包含不完整的CHLO报文中的内容之外,还会包含的Tag/Value对有SCID(必须,正在被客户端使用的ServerConfig的ID)、AEAD(必须,将要用到的AEAD算法)、KEXS(必须,将要用到的密钥交换算法)、NONC(必须,客户端随机数)、PUBS(必须,公共值,被用在密钥交换算法之中)、SNO(可选,服务端随机数,如果服务端在REJ报文中用SNOtag提供了一个需要客户端回显的随机数,则需要填写)等。为了实现0-RTT的握手,客户端在发送完整的CHLO时必须被允许直接附带之后要发送的具体数据而非等待服务端进行响应。如果服务端拒绝客户端的握手请求,则可以响应REJ报文,如果接受客户端的握手请求,则响应SHLO报文。在SHLO报文中,除了REJ报文中定义的Tag/Value对之外还会包含PUBS(必须,公共值,被用在密钥交换算法之中,由客户端指定)。
服务端和客户端通过临时公共值和初始密钥算出前向安全密钥。但由于客户端需要等待服务端的SHLO报文才知道服务端提供的临时公共值,所以SHLO是用初始密钥加密的,客户端在提取出SHLO中的临时公共值后再计算前向安全密钥,后续则使用前向安全密钥进行加密。
在 QUIC 协议中,通过使用一个 Connection ID 来唯一区分一个连接,使得应用可以在一个 QUIC 的连接里开启多条 QUIC 流来传输数据,这使得 QUIC 协议原生实现了对连接的多路复用,每条 QUIC 流中单独传输数据,不会因为一条流中的数据包没有收到而阻塞其他流的数据的收发。每一个 QUIC 的连接都有一个 QUIC 会话与之绑定,QUIC 会话的功能是将一个连接的资源复用给多条流。在 QUIC 协议的官方实现中,QuicConnection 类型的作用是进行封包解包,以及进行连接层上总的流量控制,而 QuicSession 类型的作用则是管理流,实现对连接的多路复用。
QUIC连接层封装了下层的 UDP Socket,通过一个64-bit长度的无符号整型值Connection ID来区分不同的连接。在QUIC协议中,公共包头的第一个字段是1个字节长度的公共标识位(Public Flags),紧接着的字段就是一个长度为0或64bits的Connection ID。如下图所示。
整个公共包头都是明文传输,QUIC协议的连接层的作用是进行封包和解包,以及进行整个连接的总流量控制。在客户端与服务端进行握手前,Connection ID由客户端随机地选择,若服务端决定与客户端继续通信,则可以选择继续使用这个 ID,也可以选择新的 ID 响应给客户端,如果客户端接收到服务器响应的新 Connection ID,那么客户端在本次连接期间必须采用这个 ID。
QUIC协议的Connection ID长度之所以可以为0-bit,就是因为QUIC协议也允许开发者选择使用该四元组来确定一个QUIC连接。但大多情况下,开发者都应该采用一个不依赖于IP地址、端口号等信息的Connection ID,将连接标识独立实现的优点在于:网络切换会改变客户端方面的IP地址和端口号,但是这些改变并不会影响到带有Connection ID 的QUIC连接,因为带有Connection ID的QUIC连接的确定并不依赖于IP地址和端口号,客户端和服务端只需要记住这个Connection ID即可继续网络切换前的通信。另外,多个 QUIC流可以共享一个Connection ID,在数据发送过程中,连接的公共包头和流数据帧的头部分开处理,使得一个QUIC连接可以多路复用。
QUIC协议的Packet Number的功能则与之不同,该序号并不用于指示对应的数据包所携带数据在原始数据中的位置,也就是说,QUIC协议的接收方并不使用Packet Number来拼凑出原始数据,这就意味着重发包和原始包的Packet Number并不一定要相同,是单调递增的,这样的规则设计保证了每两个被发送的数据包的 Packet Number 都互不相同且后发送的包必然有较大的Packet Number,这使得发送方易于区分原始包和重发包的响应,不会出现重传的二义性。由于Packet Number不断增加,接收方在连接层没有办法判断哪些Packet Number是还需要等待的,所以,QUIC协议还设计了一种特殊的帧,即STOP_WAITING帧,使得接收方可以知道应该停止接收哪些包。这个特殊的帧需要由数据发送方按照一定的周期定时向数据接收方发送,以此来告知接收方不需要继续等待的 Packet Number 的最小值。
QUIC开始发送数据前会启动发包定时器,在收到数据包或者发送定时器超时时触发发包行为。QUIC发送非重传包时,有两种组包方式,分别是常规组包和快速组包。当发送数据量小于单个QUIC包的有效载荷时使用常规组包;发送数据量大于单个
QUIC包。有效载荷时,使用快速组包。当发送控制帧或者当前流数据无法填充一个数据包的某些情况(比如存在不能单独发送的
ACK 帧)时,QUIC 能进行适当的聚合,利用一个包发送多个帧。
QUIC 流量控制是同一个QUIC连接中通信的数据收端通知数据发端当前在每个流上愿意接收的数据的多少,以实现对传输速率的控制机制。QUIC流量控制中数据收端通过发送流中的绝对字节偏移实现流量控制。QUIC 数据接收端在收到数据后,将数据放入对应的流缓存中并判断当前能否更新可用窗口。如果可用窗口更新,当可用窗口小于最大接收窗口大小的一半,则数据接收端会更新自己接收窗口大小,并发送Windows update帧给QUIC发送端。QUIC发送端收到Windows update帧后更新发送窗口。
QUIC协议中包会携带一个或多个帧,而用于传输具体数据的帧是STREAM帧,一个QUIC连接上可以有多条QUIC流,需要被传输的数据被切分成QUIC流的帧来在某条流上被传输,流的传输是双向的,即使是在同一个连接中,任意一条流与另一条流的传输也是完全独立的,不会互相对对方产生干扰。图 2-7 展示了QUIC流的数据帧头部的格式:
QUIC流的每一帧数据都会带有一个可变长的无符号整形的Steam ID,Stream ID字段紧跟类型字段存放,每一条QUIC流都有一个连接内唯一的Stream ID,服务端建立的QUIC流Stream ID 一定为偶数,反之,如果流是客户端建立的,那么Stream ID 一定为奇数,另外,0 不可作为 Stream ID,1 则保留给加密握手使用,因此Stream ID 为 1 的流必然是第一个客户端创建的流。Steam ID 是单调递增的,奇数ID或偶数ID同类型大小同时代表顺序,如11先于13,12先于14。
SteamID后面的字段是数据偏移量(Offset)。由于QUIC包头中PacketNumber,具有单调递增的特性,接收方需要某种方式将被拆分的数据完整地还原出来,在QUIC协议中,接收方正是通过这个数据偏移量来确定接收到的数据在完整数据中所处的位置。
QUIC拥塞控制借鉴了TCP拥塞控制的思想结合自身的特点重新实现,包括慢启动,拥塞避免,快速重传与快速恢复三部分。连接建立初始或由于超时重传导致的丢包事件时,执行慢启动;当拥塞窗口大于慢启动门限时,进入拥塞避免;丢失检
测出丢包事件时,进入快速恢复;快速恢复结束后进入拥塞避免。QUIC基于UDP上构建了一套控制机制,使得具有可插入的拥塞控制。当前支持的拥塞控制算法有Reno,Cubic 和 BBR。
------------------------------------------------------------
QUIC目前实施的难点:
1、IETF上的QUIC 依然还是草稿,并且还存在Google QUIC与IETF QUIC两类不稳定的协定
2、路由可能封杀UDP 443端口( 这正是QUIC 部署的端口)
3、UDP包过多,由于QS限定,会被服务商误认为是攻击,UDP包被丢弃
4、无论是路由器还是防火墙目前对QUIC都还没有做好准备
------------------------------------------------------------
实践:
目前支持 QUIC 协议的 web 服务只有 0.9 版本以后的 Caddy
开源的实现有:
1、Chromium :google官方,编译比较麻烦,单独的编译工具
2、proto-quic :从 chromium 剥离的,不再维护
3、goquic :从 chromium 剥离的,封装了 libquic 的 go 语言封装,仅支持到 quic-36,不再维护。
4、quic-go:完全用 go 写的 QUIC 协议栈,开发很活跃,已在 Caddy 中使用,MIT 许可。