QUIC :Quick UDP Internet Connections;是一种新的默认加密的互联网通信协议,它提供了许多改进,旨在加速HTTP通信,同时使其变得更加安全,其最终目的是在web上代替TCP和TLS协议。QUIC 协议也是整合了 TCP 协议的可靠性和 UDP 协议的速度和效率。
传统的传输层协议是TCP和UDP,但是TCP和UDP存在很多问题,因而促进了QUIC的诞生。
(1) TCP的不足
(2) UDP的不足
(3) 具体缺点的分析
a) 握手导致的连接成本
不管是 HTTP1.0/1.1 还是 HTTPS,HTTP2,都使用了 TCP 进行传输。HTTPS 和 HTTP2 还需要使用 TLS 协议来进行安全传输,因此TCP的缺点HTTP及TLS都有。
两个握手延迟:
对于很多短连接场景,这样的握手延迟影响很大,且无法消除。
b) TCP的可靠成本
在数据传递时,确认机制、重传机制、拥塞控制机制等都会消耗大量的时间,而且要在每台设备上维护所有的传输连接,事实上,每个连接都会占用系统的CPU、内存等硬件资源。
c) 中间设备的僵化
TCP 协议使用得太久,也非常可靠。所以我们很多中间设备,包括防火墙、NAT 网关,整流器等出现了一些约定俗成的动作。
比如有些防火墙只允许通过 80 和 443,不放通其他端口。NAT 网关在转换网络地址时重写传输层的头部,有可能导致双方无法使用新的传输格式。整流器和中间代理有时候出于安全的需要,会删除一些它们不认识的选项字段。
TCP 协议本来是支持端口、选项及特性的增加和修改。但是由于 TCP 协议和知名端口及选项使用的历史太悠久,中间设备已经依赖于这些潜规则,所以对这些内容的修改很容易遭到中间环节的干扰而失败。而这些干扰,也导致很多在 TCP 协议上的优化变得小心谨慎,步履维艰。
d) 依赖于操作系统的实现导致协议僵化
TCP 是由操作系统在内核系统层面实现的,应用程序只能使用,不能直接修改。虽然应用程序的更新迭代非常快速和简单。但是 TCP 的迭代却非常缓慢,原因就是操作系统升级很麻烦。
服务端系统不依赖用户升级,但是由于操作系统升级涉及到底层软件和运行库的更新,所以也比较保守和缓慢。这也就意味着即使 TCP 有比较好的特性更新,也很难快速推广。比如 TCP Fast Open。它虽然 2013 年就被提出了,但是 Windows 很多系统版本依然不支持它。
e) 队头阻塞
队头阻塞主要是 TCP 协议的可靠性机制引入的。TCP 使用序列号来标识数据的顺序,数据必须按照顺序处理,如果前面的数据丢失,后面的数据就算到达了也不会通知应用层来处理。
另外 TLS 协议层面也有一个队头阻塞,因为 TLS 协议都是按照 record 来处理数据的,如果一个 record 中丢失了数据,也会导致整个 record 无法正确处理。
概括来讲,TCP 和 TLS1.2 之前的协议存在着结构性的问题,如果继续在现有的 TCP、TLS 协议之上实现一个全新的应用层协议,依赖于操作系统、中间设备还有用户的支持。部署成本非常高,阻力非常大。
由于TCP存在以上的缺陷,促进了QUIC的诞生。QUIC是基于 UDP实现的一种协议,因为 UDP 本身没有连接的概念,不需要三次握手,优化了连接建立的握手延迟。同时QUIC在应用程序层面实现了 TCP 的可靠性,TLS 的安全性和 HTTP2 的并发性,只需要用户端和服务端的应用程序支持 QUIC 协议,完全避开了操作系统和中间设备的限制。
QUIC协议是基于UDP协议实现。和与TCP 相反,UDP 协议是无连接协议。客户端发出 UDP 数据包后,只能“假设”这个数据包已经被服务端接收。这样的好处是在网络传输层无需对数据包进行确认,但存在的问题就是为了确保数据传输的可靠性,应用层协议需要自己完成包传输情况的确认。
QUIC 非常类似于在 UDP 上实现的 TCP + TLS + HTTP/2。由于 TCP 是在操作系统内核和中间件固件中实现的,因此对 TCP 进行重大更改几乎是不可能的(TCP 协议栈通常由操作系统实现)。但是,由于 QUIC 建立在 UDP 之上,因此没有这种限制。QUIC 可以实现可靠传输,而且相比于 TCP,它的流控功能在用户空间而不在内核空间,那么使用者就不受限于 CUBIC 或是 BBR,而是可以自由选择,甚至根据应用场景自由调整优化。
QUIC 与现有 TCP + TLS + HTTP/2 方案相比,有以下几点主要特征:
1)利用缓存,显著减少连接建立时间;
2)改善拥塞控制,拥塞控制从内核空间到用户空间;
3)没有队头阻塞的多路复用;
4)前向纠错,减少重传;
5)连接平滑迁移,网络状态的变更不会影响连接断线。
从图上可以看出,QUIC 底层通过 UDP 协议替代了 TCP,上层只需要一层用于和远程服务器交互的 HTTP/2 API。这是因为 QUIC 协议已经包含了多路复用和连接管理,HTTP API 只需要完成 HTTP 协议的解析即可。
有在HTTPS协议中,由于TCP+TLS 需要4~5个RTT,导致连接建立过程较为复杂和耗时,降低了HTTPS的效率。QUIC在握手过程中使用Diffie-Hellman算法协商初始密钥,初始密钥依赖于服务器存储的一组配置参数,该参数会周期性的更新。初始密钥协商成功后,服务器会提供一个临时随机数,双方根据这个数再生成会话密钥。
具体握手过程如下:
(1) 客户端判断本地是否已有服务器的全部配置参数,如果有则直接跳转到(5),否则继续
(2) 客户端向服务器发送inchoate client hello(CHLO)消息,请求服务器传输配置参数
(3) 服务器收到CHLO,回复rejection(REJ)消息,其中包含服务器的部分配置参数
(4) 客户端收到REJ,提取并存储服务器配置参数,跳回到(1)
(5) 客户端向服务器发送full client hello消息,开始正式握手,消息中包括客户端选择的公开数。此时客户端根据获取的服务器配置参数和自己选择的公开数,可以计算出初始密钥。
(6) 服务器收到full client hello,如果不同意连接就回复REJ,同(3);如果同意连接,根据客户端的公开数计算出初始密钥,回复server hello(SHLO)消息,SHLO用初始密钥加密,并且其中包含服务器选择的一个临时公开数。
(7) 客户端收到服务器的回复,如果是REJ则情况同(4);如果是SHLO,则尝试用初始密钥解密,提取出临时公开数
(8) 客户端和服务器根据临时公开数和初始密钥,各自基于SHA-256算法推导出会话密钥
(9) 双方更换为使用会话密钥通信,初始密钥此时已无用,QUIC握手过程完毕。之后会话密钥更新的流程与以上过程类似,只是数据包中的某些字段略有不同。
如上所述, DH密钥协商需要通行双方各自生成自己的非对称公私钥对。server端与客户端的关系是1对N的关系,明显server端生成一份公私钥对, 让N个客户端公用, 能明显减少生成开销, 降低管理成本。server端的这份公私钥对就是专门用于握手使用的,客户端一经获取,就可以缓存下来后续建连时继续使用, 这个就是达成0-RTT握手的关键, 因此server生成的这份公钥称为0-RTT握手公钥。client端首次握手时对server一无所知,需要1个RTT来询问server端的握手公钥(实际的握手交互还会发送诸如版本等其他数据)并缓存下来,本步骤只在首次建连时发生(0-RTT握手公钥的过期也会导致需要重走这一步),但这种情况很少发生。
另外,前面提到在初始密钥后,还会再协商出一个最终的会话密钥,这么做的目的是为了获取所谓的前向安全特性: 因为server端的后面生成的这份公私钥是临时生成的,不会保存下来,也就杜绝了密钥泄漏导致会话数据被恶意收集后的被解密掉的风险。
由上图可以看出,在C和S首次通信的1RTT后就发送了应用数据,这是实现1RTT建立链接的真正原因。在C和S进行非首次通信时,C端已经缓存了S端的基本配置,即可以直接进行业务数据的发送。QUIC使用的加密算法是DH算法。
QUIC建立链接的过程
(1) step0: 配置服务器S密钥对
在S生成一个素数 p p p和一个整数 g g g,生成一个随机数 K p r i K_{pri} Kpri。根据上述三元组生成 K p u b K_{pub} Kpub
K p u b = ( g K p r i ) m o d ( p ) K_{pub}=(g^{K_{pri}})mod(p) Kpub=(gKpri)mod(p)
根据生成的 K p u b K_{pub} Kpub打包 { p , g , K p u b } \{p,g,K_{pub}\} {p,g,Kpub}为服务端的config。
(2) step1: C端首次发起链接
C发送简单的Client Hello到S。
(3) step2: S首次响应C
S把config封装称一个数据包回复给C,内部含有 { p , g , K p u b } \{p,g,K_{pub}\} {p,g,Kpub}元组。
(4) step3: C发送加密数据
C收到 { p , g , K p u b } \{p,g,K_{pub}\} {p,g,Kpub}后随机生成一个数 K c _ p r i K_{c\_pri} Kc_pri做如下计算:
准备业务数据payload1,设加密函数为 E n c ( k e y , d a t a ) Enc(key,data) Enc(key,data),将下列元组D1发送给S:
D 1 = { K c _ p u b , E n c ( K 1 , p a y l o a d 1 ) } D_1=\{K_{c\_pub},Enc(K_1,payload1)\} D1={Kc_pub,Enc(K1,payload1)}
该阶段开始,payload便是加密传输的。
(5) step4: S发送加密数据
S收到 D 1 D_1 D1后,做如下计算:
利用计算计算得到的 K 1 ′ K_{1}^{'} K1′可以解密 E n c ( K 1 , p a y l o a d 1 ) Enc(K_1,payload1) Enc(K1,payload1)
S在发送自己的payload2之前,随机生成一个数 K n _ p r i K_{n\_pri} Kn_pri,做如下计算:
计算出 K 2 K_2 K2后,可以把 D 2 = { K n _ p u b , E n c ( K 2 , p a y l o a d 2 ) } D_2=\{ K_{n\_pub},Enc(K_2,payload2)\} D2={Kn_pub,Enc(K2,payload2)}发送到C。
(6) step5: C收到S的 D 2 D_2 D2
C收到S发来的 D 2 D_2 D2后,解出其中的 K n _ p u b K_{n\_pub} Kn_pub,做如下运算:
用 K 2 ′ K_2^{′} K2′可以正确解密出payload2。其中 K 2 ′ K_2^{′} K2′和 K 2 K_2 K2等价。
此后的通讯,S和C便可以用K2做通信对称密钥了。
(7) step 6:C和S断开连接
S和C之间通信一会儿后,断开连接。
(8) step 7:C直接发送加密数据
一段时间后,C和S再次通信。此时C已经有了S的config元组 p , g , K p u b {p,g,K_{pub}} p,g,Kpub,直接从Step 3开始数据传输。
(9) step 8:S发送加密数据
这里在S收到C的加密数据后,重复step 4重新计算出一个新的“安全对称密钥”作为之后数据传输的对称密钥。
几个问题:
Q1. 为什么QUIC可以实现0RTT建立连接
0 RTT 的效果是因为QUIC的客户端会缓存服务器端发的令牌和证书,当有数据需要再次发送的时候,客户端可以直接使用旧的令牌和证书,这样子就实现了 0 RTT 了。对于没有缓存的情况,服务器端会直接拒绝请求,并且返回新生产的令牌和证书。 所以当令牌失效或者没有缓存的情况下,QUIC还是需要一次握手才能开始传输数据。
Q2 密钥的生成以及DH加密算法是什么?
Tips:
首先明确一个概念是 RTT(round-trip time),顾名思义,就是服务器和终端一次交互需要的时间。RTT一般用于衡量网络延迟。传统的TCP协议,我们需要进行3次握手,也就是1.5 RTT,才开始传输数据。TCP的三次握手的时间也可以是1RTT,因为客户端发送ACK同时,就可以直接下发业务报文数据。
QUIC协议当前默认使用TCP协议的Cubic拥塞控制算法。看似QUIC协议只是把TCP的拥塞算法重新实现了一遍,其实不然。QUIC协议在TCP拥塞算法基础上做了些改进:
(1) 可插拔
什么叫可插拔呢?就是能够非常灵活地生效,变更和停止。
(2) 单调递增的Packet Number
(3) 更多的ACK块
(4) 精确计算RTT时间
基于TCP的HTTP2的一个较大问题是队头阻塞问题。应用程序将TCP连接看作字节流,当TCP数据包丢失时,HTTP2连接上的流不能继续进行,直到数据包被重传并被远端接收——甚至当这些流的数据包已经到达并在缓冲区中等待时也不能。
因为QUIC是专门为多路复用操作而设计的,丢失的数据包携带单个流的数据通常只影响那个特定的流。每个流帧可以在到达时立即分配到该流,因此没有丢失的流可以继续重新组装并在应用程序中继续进行。
补充:
(1) TCP的流量控制策略
TCP 保证了数据的有序和可达性,所以原则上是数据按照序号依次发送和接收,下一个包的发送需要等到上一个包 ACK 到达。这样的话,在相邻两个包的发送间隙存在很长时间的空闲等待,好在 TCP 采用了滑动窗口机制来减少了排队等待时间,双方约定一定大小的窗口,在这个窗口内的包都可以同步发送,接收方收到一个 packet 时会回复 ACK 给发送方,发送方收到 ACK 后移动发送窗口,发送后续数据。
但是如果某个 packet 丢失或者其对应的 ACK 包丢失,同样会出现一方不必要的等待。如下图情况,packet 5的 ACK 包丢失,导致发送方无法移动发送窗口,但接收方已经在等待后面的包了。必须等到接收方超时重传这个 ACK 包,接收方超收到这个 ACK 包后,发送窗口才会移动,继续后面的发送行为。
为了在不等待重传的情况下从丢失的数据包中恢复,QUIC可以用FEC数据包补充一组数据包。很像RAID-4, FEC包包含FEC组中包的奇偶校验。如果组中的一个包丢失,则可以从FEC包和组中的其余包中恢复该包的内容。发送方可以决定是否发送FEC数据包来优化特定场景(例如,请求的开始和结束)。但是这种机制可能会造成传输数据包的冗余,目前已经舍弃
QUIC连接由客户端随机生成的64位连接ID标识。相比之下,TCP连接由源地址、源端口、目的地址和目的端口的4元组标识。这意味着,如果客户端更改了IP地址(例如,从Wi-Fi范围转移到蜂窝网络)或端口(如果NAT映射丢失并重新绑定端口关联),那么任何活动的TCP连接都不再有效。当QUIC客户端更改IP地址时,它可以继续使用来自新IP地址的旧连接ID,而不会中断任何正在运行的请求。
首先分两个定义,Stream和Connections:
(1) 博客一种对QUIC流量控制的解释
当客户端与服务端进行发送数据的时候,有可能因为发送者发送的速度太快,导致接收者来不及接收,因此会出现分组的丢失,因此为了解决这个问题,解决的根本应该是控制发送者的速度,因此服务端在进行TCP通信时,使用滑动窗口协议。
quic的流量控制是在此基础上的改进,分为了两类,第一类是连接上的流量控制,可以类比成TCP连接。第二类是逻辑流上的流量控制,可以类比为HTTP请求,quic对这两种类型分别进行流量控制。
如上图,对于quic中的可用窗口也会分为两类:
针对Connection:可用窗口 = 最大窗口数 - 接收到的最大偏移数;
针对流:可用窗口 = stream1可用 + stream2 + … + streamN
(2) 博客二对流量控制的解释
QUIC 是基于 UDP 传输的,而 UDP 没有流量控制,因此 QUIC 实现了自己的流量控制机制,分为 Stream 和 Connection 两种级别:
1) Stream级别的流控
通过限制 stream 可以发送的数据量,防止单个 stream 消耗连接(connection)的全部缓冲区。与 TCP 不同,就算此前有 packet 没有接收到,它的滑动只取决于接收到的最大偏移字节数(highest received byte offset)。只要还有可用窗口,发送方可以继续发送数据。
可用窗口 = 最大窗口数 - 接收到的最大偏移数
2) Connections级别的流量控制
Connections级别的流量控制:对connection 中所有 streams 相加起来的总字节数进行限制,防止发送方超过 connection 的缓冲(buffer)容量。
在握手时,接收方通过传输参数(transport parameters)设置 connection 的初始限制。
发送方根据计算 connection 中所有 streams 的可用窗口,与这个连接窗口值对比进行流量控制。
如果发送方达到限制,(可选)则可以发送STREAM_BLOCKED帧给接收方,以告示它有数据要发送,但被流量控制限制阻止。
接收方如果有更大的窗口值,可以发送MAX_DATA帧通知发送方增加。
如果发送方违反流量控制的限制,接收方可以关闭连接并返回FLOW_CONTROL_ERROR错误。
下图所示的例子,所有 streams 的最大窗口数为 120,其中:
stream 1 的最大接收偏移为 100,可用窗口 = 120 - 100 = 20
stream 2 的最大接收偏移为 90,可用窗口 = 120 - 90 = 30
stream 3 的最大接收偏移为 110,可用窗口 = 120 - 110 = 10
那么整个 Connection 的可用窗口 = 20 + 30 + 10 = 60
可用窗口 = stream 1 可用窗口 + stream 2 可用窗口 +… + stream N 可用窗口
QUIC 具有特殊包和普通包。有两种类型特殊包:版本协商包 (Version Negotiation Packets) 和 公共复位包 (Public Reset Packets),普通包包含帧。
所有 QUIC 包的大小应该适配在路径的 MTU 以避免IP分片。路径 MTU 发现是正在进行中的工作,而当前 QUIC 实现为 IPv6 使用 1350 字节的最大QUIC包大小,IPv4 使用1370字节。两个大小都没有 IP 和 UDP 过载。
公共包头的格式如下:
--- src
0 1 2 3 4 8
+--------+--------+--------+--------+--------+--- ---+
| Public | Connection ID (64) ... | ->
|Flags(8)| (optional) |
+--------+--------+--------+--------+--------+--- ---+
9 10 11 12
+--------+--------+--------+--------+
| QUIC Version (32) | ->
| (optional) |
+--------+--------+--------+--------+
13 14 15 16 17 18 19 20
+--------+--------+--------+--------+--------+--------+--------+--------+
| Diversification Nonce | ->
| (optional) |
+--------+--------+--------+--------+--------+--------+--------+--------+
21 22 23 24 25 26 27 28
+--------+--------+--------+--------+--------+--------+--------+--------+
| Diversification Nonce Continued | ->
| (optional) |
+--------+--------+--------+--------+--------+--------+--------+--------+
29 30 31 32 33 34 35 36
+--------+--------+--------+--------+--------+--------+--------+--------+
| Diversification Nonce Continued | ->
| (optional) |
+--------+--------+--------+--------+--------+--------+--------+--------+
37 38 39 40 41 42 43 44
+--------+--------+--------+--------+--------+--------+--------+--------+
| Diversification Nonce Continued | ->
| (optional) |
+--------+--------+--------+--------+--------+--------+--------+--------+
45 46 47 48 49 50
+--------+--------+--------+--------+--------+--------+
| Packet Number (8, 16, 32, or 48) |
| (variable length) |
+--------+--------+--------+--------+--------+--------+
---
(1) 公共标记(Public Flags)
(2) 连接ID
这是客户端选择的无符号64位统计随机数,该数字是连接的标识符。由于 QUIC 的连接被设计为,即使客户端漫游,连接依然保持建立状态,因而 IP 4元组(源IP,源端口,目标IP,目标端口)可能不足以标识连接。对每个传输方向,当4元组足以标识连接时,连接ID可以省略。
(3) QUIC版本
表示 QUIC 协议版本的32位不透明标记。只有在公共标记包含 FLAG_VERSION(比如 public_flags & FLAG_VERSION !=0) 时才存在。客户端可以设置这个标记,并 准确 包含一个提议版本,同时包含任意的数据(与该版本一致)。当客户端提议的版本不支持时,服务器可以设置这个标记,并可以提供一个可接受版本的列表(0或多个),但 一定不能(MUST not) 在版本信息之后包含任何数据。最近的实验版本的版本值示例包括 “Q025”,它对应于 byte 9 包含 ‘Q”,byte 10 包含 ‘0”,等等。[参考本文末尾的不同版本变化列表。]
(4) 包号
包号的低 8,16,32,或 48 位,基于公共标记的 FLAG_?BYTE_SEQUENCE_NUMBER 标记被设置为什么。每个普通包(与特别的公共复位和版本协商包相反)由发送者分配包号。由某一端发送的首包包号应该为1,后续每个包的包号应该比前一个的大1。
(1) 版本协商包
只有服务器会发送版本协商包。版本协商包以8位的公共标记和64位的连接ID开始。公共标记必须设置PUBLIC_FLAG_VERSION,并指明64位的连接ID。版本协商包的其余部分是服务器支持的版本的4字节列表:
--- src
0 1 2 3 4 5 6 7 8
+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| Public | Connection ID (64) | ->
|Flags(8)| |
+--------+--------+--------+--------+--------+--------+--------+--------+--------+
9 10 11 12 13 14 15 16 17
+--------+--------+--------+--------+--------+--------+--------+--------+---...--+
| 1st QUIC version supported | 2nd QUIC version supported | ...
| by server (32) | by server (32) |
+--------+--------+--------+--------+--------+--------+--------+--------+---...--+
---
(2) 公共复位包
公共复位包以8位的公共标记和64位的连接ID开始。公共标记必须设置 PUBLIC_FLAG_RESET,并表明64位的连接ID。公共复位包的其余部分像标记 PRST 的加密握手消息那样编码(参考[QUIC-CRYPTO]):
--- src
0 1 2 3 4 8
+--------+--------+--------+--------+--------+-- --+
| Public | Connection ID (64) ... | ->
|Flags(8)| |
+--------+--------+--------+--------+--------+-- --+
9 10 11 12 13 14
+--------+--------+--------+--------+--------+--------+---
| Quic Tag (32) | Tag value map ... ->
| (PRST) | (variable length)
+--------+--------+--------+--------+--------+--------+---
---
标记值映射(Tag value map):标记值映射包含如下的标记值:
普通包已经过认证和加密。公共头部已认证但未加密,从第一帧开始的包的其余部分已加密。紧随公共头部之后,普通包包含 AEAD(authenticated encryption and associated data)数据。要解释内容,这些数据必须先解密。解密之后,明文由一系列帧组成。
(1) 帧包
帧包具有一个载荷,它是一系列的类型前缀帧。帧类型的格式将在本文档的后面定义,但帧包的通用格式如下:
--- src
+--------+---...---+--------+---...---+
| Type | Payload | Type | Payload |
+--------+---...---+--------+---...---+
---
QUIC帧包由帧填充。它具有一个帧类型字节,它本身具有一个依赖类型的解释,后面是依赖类型的帧首部字段。所有的帧被包含在单独的QUIC包中,且没有帧可以跨越QUIC包边界。QUIC帧类型字节有两种解释,导致两种帧类型:特殊帧类型和普通帧类型。特殊帧类型在帧类型字节中同时编码帧类型和对应的标记,而普通帧类型简单地使用帧类型字节。
特殊帧类型的定义如下:
--- src
+------------------+-----------------------------+
| Type-field value | Control Frame-type |
+------------------+-----------------------------+
| 1fdooossB | STREAM |
| 01ntllmmB | ACK |
| 001xxxxxB | CONGESTION_FEEDBACK |
+------------------+-----------------------------+
---
普通帧类型的定义如下:
--- src
+------------------+-----------------------------+
| Type-field value | Control Frame-type |
+------------------+-----------------------------+
| 00000000B (0x00) | PADDING |
| 00000001B (0x01) | RST_STREAM |
| 00000010B (0x02) | CONNECTION_CLOSE |
| 00000011B (0x03) | GOAWAY |
| 00000100B (0x04) | WINDOW_UPDATE |
| 00000101B (0x05) | BLOCKED |
| 00000110B (0x06) | STOP_WAITING |
| 00000111B (0x07) | PING |
+------------------+-----------------------------+
---
(1) STREAM 帧
STREAM帧被隐式地创建流和在流上发送数据,他的格式如下:
--- src
0 1 … SLEN
+--------+--------+--------+--------+--------+
|Type (8)| Stream ID (8, 16, 24, or 32 bits) |
| | (Variable length SLEN bytes) |
+--------+--------+--------+--------+--------+
SLEN+1 SLEN+2 … SLEN+OLEN
+--------+--------+--------+--------+--------+--------+--------+--------+
| Offset (0, 16, 24, 32, 40, 48, 56, or 64 bits) (variable length) |
| (Variable length: OLEN bytes) |
+--------+--------+--------+--------+--------+--------+--------+--------+
SLEN+OLEN+1 SLEN+OLEN+2
+-------------+-------------+
| Data length (0 or 16 bits)|
| Optional(maybe 0 bytes) |
+------------+--------------+
---
STREAM帧首部中的字段如下:
一个流帧必须总是要么具有非零的数据长度,要么设置了FIN位。
(2) ACK帧
QUIC 非常类似于在 UDP 上实现的 TCP + TLS + HTTP/2,因此对此几种基础的协议进行记录
TLS协议旨在为所有正在使用它的应用程序提供三种服务,即:加密,身份验证和完整性。从技术上讲,并非所有三种都可以使用,但在实践中,为了确保安全,通常使用所有三种。
TLS1.3(2018年8月发布)的交互流程如下图所示,需要1RTT,TCP+TLS1.3的时间就是2RTT。
TLS1.3会话恢复时的流程如下图所示,需要0RTT,此时TCP+TLS1.3的时间是1RTT。对话恢复过程中,在客户端发送ClientHello时就已经可以发送数据了。
HTTP/2的目标是回到单个 TCP 连接,解决队头阻塞问题,具体如何解决的可以产看参考文献[7]
HTTP1.1主要存在的问题是无法进行多路复用,每进行一次传输都需要建立一个TCP连接,这中开销是巨大的。HTTP/2主要是提出了多路复用使用一个TCP连接来传输多个数据流。假设有两个流A和B,其中A比较大,B比较小。当两个流使用一个TCP连接进行传输时,并且先传输A,就可能导致B巨大的延迟。比如传输的方式为AAAAAAAAABB,B必须在A传输完成后才能传输。HTTP2通过为每个包添加帧并采用多路复用的方式进解决了上述问题,使得传输变成了ABABAAAAAA这种形式。但是这只是在应用层的层次解决了队头阻塞问题,TCP本身依旧存在阻塞问题,HTTP/2并没有给予解决。
HTTP 是基于 TCP/IP 协议的应用层协议。它不涉及数据包(packet)传输,主要规定了客户端和服务器之间的通信格式,默认使用 80 端口。
最早版本是 1991 年发布的 0.9 版。该版本极其简单,只有一个命令 GET。
GET /index.html
上面命令表示,TCP 连接(connection)建立后,客户端向服务器请求(request)网页 index.html。协议规定,服务器只能回应 HTML 格式的字符串,不能回应别的格式。
Hello World
复制代码服务器发送完毕,就关闭 TCP 连接。
1996 年 5 月,HTTP/1.0 版本发布,内容大大增加。相对于 HTTP/0.9 大致增加了如下几点:
当时其实也存在一些别的问题如下:
HTTP/1.0 版的主要缺点是,每个 TCP 连接只能发送一个请求。发送数据完毕,连接就关闭,如果还要请求其他资源,就必须再新建一个连接。TCP 连接的新建成本很高,因为需要客户端和服务器三次握手,并且开始时发送速率较慢(slow start)。
相对于 HTTP/1.0 版本 HTTP/1.1 做了一些优化大致如下:
长连接: HTTP 1.1 支持长连接(Persistent Connection)和请求的流水线(Pipelining)处理,在一个 TCP 连接上可以传送多个 HTTP 请求和响应,减少了建立和关闭连接的消耗和延迟,在 HTTP1.1 中默认开启 Connection: keep-alive,一定程度上弥补了 HTTP1.0 每次请求都要创建连接的缺点。
缓存处理:在 HTTP1.0 中主要使用 header 里的If-Modified-Since,Expires来做为缓存判断的标准,HTTP1.1 则引入了更多的缓存控制策略例如Entity tag,If-Unmodified-Since, If-Match, If-None-Match等更多可供选择的缓存头来控制缓存策略。
带宽优化及网络连接的使用,HTTP1.0 中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP1.1 则在请求头引入了range 头域,它允许只请求资源的某个部分,即返回码是206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
错误通知的管理,在 HTTP1.1 中新增了24 个错误状态响应码,如 409(Conflict)表示请求的资源与资源的当前状态发生冲突;410(Gone)表示服务器上的某个资源被永久性的删除。
Host 头处理,在 HTTP1.0 中认为每台服务器都绑定一个唯一的 IP 地址,因此,请求消息中的 URL 并没有传递主机名(hostname)。但随着虚拟主机技术的发展,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个 IP 地址。HTTP1.1 的请求消息和响应消息都应支持 Host 头域,且请求消息中如果没有 Host 头域会报告一个错误(400 Bad Request)。
但是同时也存在一些问题如下:
2009 年,谷歌公开了自行研发的 SPDY 协议,主要解决 HTTP/1.1 效率不高的问题。这个协议在 Chrome 浏览器上证明可行以后,就被当作 HTTP/2 的基础,主要特性都在 HTTP/2 之中得到继承。SPDY 可以说是综合了 HTTPS 和 HTTP 两者优点于一体的传输协议,主要解决:
HTTP/2 可以说是 SPDY 的升级版(其实原本也是基于 SPDY 设计的),但是,HTTP2.0 跟 SPDY 仍有不同的地方,主要是以下两点:
HTTP/2 的新特性:
(1) 流 (stream)
流是连接中的一个虚拟信道,可以承载双向的消息;每个流都有一个唯一的整数标识符(1、2…N)。下图就是把两个流分拆开打包进两个TCP Packet。
流的组成如下图所示
(2) 数据帧 (Data Frame)
区别于HTTP/1并不能在一个TCP链接中区分两个数据流,HTTP/2为每个数据流添加了一个流id,为每一个TCP Packet的头部添加了一个数据源帧,该数据帧包含两个重要信息,分别是:1) 下面的块属于哪个资源。每个资源的“字节流(bytestream)”都被分配了一个唯一的数字,即流id(stream id);2) 块的大小是多少。如上图的示意图所示。
(3) 消息
是指逻辑上的 HTTP 消息,比如请求、响应等,由一或多个帧组成。
(4) 帧
客户端与服务器通过交换帧来通信,帧是基于这个新协议通信的最小单位。
(5) 帧、流、消息的关系
每个数据流都以消息的形式发送,而消息又由一个或多个帧组成。 帧是流中的数据单位,一个数据报的 header 帧可以分成多个 header 帧,data 帧可以分成多个 data 帧。其实流的的
(6) 对流和帧的抽象理解
其他blog上对帧和流的解释
(1) 帧(frame)
HTTP/2 中数据传输的最小单位,因此帧不仅要细分表达 HTTP/1.x 中的各个部份,也优化了 HTTP/1.x 表达得不好的地方,同时还增加了 HTTP/1.x 表达不了的方式。
每一帧都包含几个字段,有length、type、flags、stream identifier、frame playload等,其中type 代表帧的类型,在 HTTP/2 的标准中定义了 10 种不同的类型,包括上面所说的 HEADERS frame 和 DATA frame。此外还有:
PRIORITY(设置流的优先级)
RST_STREAM(终止流)
SETTINGS(设置此连接的参数)
PUSH_PROMISE(服务器推送)
PING(测量 RTT)
GOAWAY(终止连接)
WINDOW_UPDATE(流量控制)
CONTINUATION(继续传输头部数据)
在 HTTP 2.0 中,它把数据报的两大部分分成了 header frame 和 data frame。也就是头部帧和数据体帧。
(2) 流(stream)
流: 存在于连接中的一个虚拟通道。流可以承载双向消息,每个流都有一个唯一的整数 ID。
HTTP/2 长连接中的数据包是不按请求-响应顺序发送的,一个完整的请求或响应(称一个数据流 stream,每个数据流都有一个独一无二的编号)可能会分成非连续多次发送。它具有如下几个特点:
- 双向性:同一个流内,可同时发送和接受数据。
- 有序性:流中被传输的数据就是二进制帧 。帧在流上的被发送与被接收都是按照顺序进行的。
- 并行性:流中的 二进制帧 都是被并行传输的,无需按顺序等待。
- 流的创建:流可以被客户端或服务器单方面建立, 使用或共享。
- 流的关闭:流也可以被任意一方关闭。
- HEADERS 帧在 DATA 帧前面。
- 流的 ID 都是奇数,说明是由客户端发起的,这是标准规定的,那么服务端发起的就是偶数了。
http/1中的每个请求都会建立一个单独的连接,除了在每次建立连接过程中的三次握手之外,还存在TCP的慢启动导致的传输速度低。其实大部分的http请求传送的数据都很小,就导致每一次请求基本上都没有达到正常的传输速度。
在http1.1中默认开启keep-alive,解决了上面说到的问题,但是http的传输形式是一问一答的形式,一个请求对应一个响应(http2中已经不成立,一个请求可以有多个响应,server push),在keep-alive中,必须等上一个请求接受才能发起下一个请求,所以会收到前面请求的阻塞。
使用pipe-line可以连续发送一组没有相互依赖的请求而不必等到上一个请求先结束,看似pipe-line是个好东西,但是到目前为止我还没见过这种类型的连接,也间接说明这东西比较鸡肋。pipe-line依然没有解决阻塞的问题,因为请求响应的顺序必须和请求发送的顺序一致,如果中间有某个响应花了很长的时间,后面的响应就算已经完成了也要排队等阻塞的请求返回,这就是线头阻塞。
http2的多路复用就很好的解决了上面所提出的问题。http2的传输是基于二进制帧的。每一个TCP连接中承载了多个双向流通的流,每一个流都有一个独一无二的标识和优先级,而流就是由二进制帧组成的。二进制帧的头部信息会标识自己属于哪一个流,所以这些帧是可以交错传输,然后在接收端通过帧头的信息组装成完整的数据。这样就解决了线头阻塞的问题,同时也提高了网络速度的利用率。
HTTP/2的新特征
多路复用允许同时通过单一的 HTTP/2 连接发起多重的请求-响应消息。即连接共享,即每一个 request 都是是用作连接共享机制的。一个 request 对应一个 id,这样一个连接上可以有多个 request,每个连接的 request 可以随机的混杂在一起,接收方可以根据 request 的 id 将 request 再归属到各自不同的服务端请求里面。
HTTP1.x 的 header 带有大量信息,而且每次都要重复发送,HTTP/2 使用 encoder 来减少需要传输的 header 大小,通讯双方各自cache 一份 header fields 表,既避免了重复 header 的传输,又减小了需要传输的大小。 为了减少这块的资源消耗并提升性能, HTTP/2 对这些首部采取了压缩策略:
两次请求不相同的 header,传说的 header 如下图所示:
Server Push 即服务端能通过 push 的方式将客户端需要的内容预先推送过去,也叫“cache push”。
服务器可以对一个客户端请求发送多个响应。服务器向客户端推送资源无需客户端明确地请求,服务端可以提前给客户端推送必要的资源,这样可以减少请求延迟时间,例如服务端可以主动把 JS 和 CSS 文件推送给客户端,而不是等到 HTML 解析到资源时发送请求,大致过程如下图所示:
相对于HTTP/1.1,HTTP/2主要基于流和数据帧的概念提出了多路复用机制。这有效解决了HTTP/1中N个请求对应N个TCP链接的缺陷,进而极大的节省了建立TCP连接的开销。
贯穿本文档使用的一些术语定义如下。
HTTP2协议虽然大幅提升了HTTP/1.1的性能,然而,基于TCP实现的HTTP2遗留下3个问题:
HTTP3协议解决了这些问题:
本文将会从HTTP3协议的概念讲起,从连接迁移的实现上学习HTTP3的报文格式,再围绕着队头阻塞问题来分析多路复用与QPACK动态表的实现。
HTTP/2的一个主要特性是使用多路复用(multiplexing),因而它可以通过同一个TCP连接发送多个逻辑数据流。复用使得很多事情变得更快更好,它带来更好的拥塞控制、更充分的带宽利用、更长久的TCP连接。这些都比以前更好了,链路能更容易实现全速传输。标头压缩技术也减少了带宽的用量。
采用HTTP/2后,浏览器对每个主机一般只需要 一个 TCP连接,而不是以前常见的 六个 连接。事实上,HTTP/2使用的连接聚合(connection coalescing)和“去分片”(desharding)技术还可以进一步缩减连接数。
HTTP/2解决了HTTP的队头拥塞(head of line blocking)问题,客户端必须等待一个请求完成才能发送下一个请求的日子过去了。
QUIC协议特点
图X:HTTP/2与HTTP/3的对比
基于UDP
QUIC是基于UDP在用户空间实现的传输协议。如果不观察细节,你会觉得QUIC跟UDP报文差不多。
基于UDP意味着它使用UDP端口号来识别指定机器上的特定服务器。
目前已知的所有QUIC实现都位于用户空间,这使它能得到更快速的迭代(相较于内核空间中的实现)。
稳定性
虽然UDP不提供可靠的传输,但QUIC在基于UDP之时增加了一层带来可靠性的层。它提供了数据包重传、拥塞控制、调整传输节奏(pacing)以及其他一些TCP中存在的特性。只要连接没有中断,从QUIC一端传输的数据迟早会出现在另一端。
数据流
类似SCTP、SSH和HTTP/2,QUIC在同一物理连接上可以有多个独立的逻辑数据流。这些数据流并行在同一个连接上传输,不影响其他流。
连接在两个端点之间经过类似TCP连接的方式协商建立。QUIC连接基于UDP端口和IP地址建立,而一旦建立,连接通过其“连接ID”(connection ID)关联。
在已建立的连接上,双方均可以建立传输给对方的数据流。单一数据流的传输是可靠、有序的,但不同的数据流间可能无序传送。
QUIC可对连接和数据流分别进行流量控制(flow control)。
快速握手
QUIC提供0-RTT和1-RTT的连接建立,这意味着QUIC在最佳情况下不需要任何的额外往返时间便可建立新连接。其中更快的0-RTT仅在两个主机之间建立过连接且缓存了该连接的“秘密”(secret)时可以使用。
QUIC工作原理
连接
QUIC连接是两个QUIC端点之间的单次会话(conversation)过程。QUIC建立连接时,加密算法的版本协商与传输层握手合并完成,以减小延迟。
在连接上实际传输数据时需要建立并使用一个或多个数据流。
(1)连接ID(Connection ID)
每个连接过程都有一组连接标识符,或称连接ID,该ID用以识别该连接。每个端点各自选择连接ID。每个端点选择对方使用的连接ID。
连接ID的基本功能是确保底层协议(UDP、IP及其底层协议)的寻址变更不会使QUIC连接传输数据到错误的端点。
利用连接ID的优势,连接可以在IP地址和网络接口迁移的情况下得到保持,而这TCP永远做不到。举例来说,当用户的设备连接到一个Wi-Fi网络时,将进行中的下载从蜂窝网络连接转移到更快速的Wi-Fi连接。与此类似,当Wi-Fi连接不再可用时,将连接转移到蜂窝网络连接。
(2)端口号
QUIC基于UDP建立,因此使用16比特的UDP端口号字段来区分传入的不同连接。
(3)版本协商
客户端的QUIC连接请求会告知服务器所希望使用的QUIC协议版本,服务器端会回复一系列支持的版本供客户端选择。
问题补充
HTTP/2中的多路复用
http/1中的每个请求都会建立一个单独的连接,除了在每次建立连接过程中的三次握手之外,还存在TCP的慢启动导致的传输速度低。其实大部分的http请求传送的数据都很小,就导致每一次请求基本上都没有达到正常的传输速度。
在http1.1中默认开启keep-alive,解决了上面说到的问题,但是http的传输形式是一问一答的形式,一个请求对应一个响应(http2中已经不成立,一个请求可以有多个响应,server push),在keep-alive中,必须等下上一个请求接受才能发起下一个请求,所以会收到前面请求的阻塞。
使用pipe-line可以连续发送一组没有相互依赖的请求而不比等到上一个请求先结束,看似pipe-line是个好东西,但是到目前为止我还没见过这种类型的连接,也间接说明这东西比较鸡肋。pipe-line依然没有解决阻塞的问题,因为请求响应的顺序必须和请求发送的顺序一致,如果中间有某个响应花了很长的时间,后面的响应就算已经完成了也要排队等阻塞的请求返回,这就是线头阻塞。
http2的多路复用就很好的解决了上面所提出的问题。http2的传输是基于二进制帧的。每一个TCP连接中承载了多个双向流通的流,每一个流都有一个独一无二的标识和优先级,而流就是由二进制帧组成的。二进制帧的头部信息会标识自己属于哪一个流,所以这些帧是可以交错传输,然后在接收端通过帧头的信息组装成完整的数据。这样就解决了线头阻塞的问题,同时也提高了网络速度的利用率。
队头拥塞问题
https://zhuanlan.zhihu.com/p/330300133
HTTP1.1主要存在的问题是无法进行多路复用,每进行一次传输都需要建立一个TCP连接,这中开销是巨大的。HTTP/2主要是提出了多路复用使用一个TCP连接来传输多个数据流。假设有两个流A和B,其中A比较大,B比较小。当两个流使用一个TCP连接进行传输时,并且先传输A,就可能导致B巨大的延迟。比如传输的方式为AAAAAAAAABB,B必须在A传输完成后才能传输。HTTP2通过为每个包添加帧并采用多路复用的方式进解决了上述问题,使得传输变成了ABABAAAAAA这种形式。但是这只是在应用层的层次解决了队头阻塞问题,TCP本身依旧存在阻塞问题,HTTP/2并没有给予解决。
(1)HTTP/2解决了什么问题
HTTP/2使用多路复用解决了HTTP1中每个流需要开启一个TCP连接进行传输的问题。基于HTTP2协议,多个流可以采用一个TCP连接进行传输。这也就是解决了客户端必须等待一个请求完成才能发送下一个请求的问题。
(2)TCP存在的队头阻塞问题
基于HTTP/2的多路复用进行数据传输,假设A流为绿色,B流为红色。由于TCP本身的可靠传输机制,有可能使得单个A的包被阻塞。当单个A包被阻塞后,后面的各个包依旧被阻塞。这种单个数据包造成的阻塞,就是TCP上的队头阻塞。
参考文献
(1)[HTTP/3 explained]https://http3-explained.haxx.se/zh/why-quic/why-tcphol#du-li-de-shu-ju-liu-bi-mian-zu-sai-wen-ti
(2)https://zhuanlan.zhihu.com/p/330300133