QUIC协议介绍

QUIC协议介绍

QUIC的由来

HTTP/2的硬伤就是TCP,TCP连接的稳定和可靠解决了当时的网络问题,在如今网络可靠性和速度上,TCP的弊端尤为突出,而且TCP更新优化依赖系统内核更新,而且一次更新可能需要十年时间普及。

构建基础网络,或者更新HTTP可以一定程度上加速网络,减轻用户感知延迟,下图可以说明这个问题。

quic

日益高速高速发展的应用层和缓慢进化的网络传输层的矛盾日渐明显,Google的工程师决定抛弃TCP,转向UDP,因为UDP不需要建连,协议简单,是不稳定的乐观协议。至于保证数据可靠、防止IP攻击、加密处理等等工作放在UDP协议之上,也就是QUIC协议,升级不依赖底层系统,能实现TCP类似的功能,可定制化,而且能带来网络速度和可靠性的提升。

QUIC简介

QUIC 是 Quick UDP Internet Connections 的缩写,谷歌发明的新传输协议。与 TCP 相比,QUIC 可以减少延迟。从表面上看,QUIC 非常类似于在 UDP 上实现的 TCP + TLS + HTTP/2。由于 TCP 是在操作系统内核和中间件固件中实现的,因此对 TCP 进行重大更改几乎是不可能的。但是,由于 QUIC 建立在 UDP 之上,因此没有这种限制。QUIC 可以实现可靠传输,而且相比于 TCP,它的流控功能在用户空间而不在内核空间,那么使用者就 不受限于 CUBIC 或是 BBR,而是可以自由选择,甚至根据应用场景自由调整优化。

QUIC 与现有 TCP + TLS + HTTP/2 方案相比,有以下几点主要特征:

  • 基于UDP的TCP+TLS+SPDY
  • 比TLS/TCP更快的网络连接,利用缓存,显著减少连接建立时间
    • 通常是0-RTT,有时是1-RTT
  • 处理包丢失上比TCP协议表现更好
  • 基于流(STREAM)级别和连接(Connection)级别的流控制
  • 前向纠错,减少重传
  • 没有 head of line 阻塞的多路复用
  • 连接平滑迁移,网络状态的变更不会影响连接断线
  • 改善拥塞控制,拥塞控制从内核空间到用户空间

下图可以清晰的表示QUIC在网络中的位置:

quic架构

QUIC 在 UDP 之上,如果想要和 TCP/IP 体系类比,那么就是上图。QUIC 可以类比 TCP/IP 中的 TLS 一层。但是功能又不完全是 TLS ,还有一部分 HTTP/2 ,下面还包括一部分 TCP 的功能,比如 拥塞控制、丢包恢复、流量控制等特性。

QUIC相比tls1.3在首次握手、弱网环境上都有很大的优势。

QUIC优势

减少了 TCP 三次握手及 TLS 握手时间

传统的TCP协议,我们需要进行3次握手,也就是1.5 RTT,才开始传输数据。而且HTTP/2的实现上大都绑定了TLS,需要确定加密版本、加密密钥等,因此TCP+TLS需要3 RTT。

0 RTT 的效果是因为QUIC的客户端会缓存服务器端发的令牌和证书,当有数据需要再次发送的时候,客户端可以直接使用旧的令牌和证书,这样子就实现了 0 RTT 了。对于没有缓存的情况,服务器端会直接拒绝请求,并且返回新生产的令牌和证书。 所以当令牌失效或者没有缓存的情况下,QUIC还是需要一次握手才能开始传输数据。

连接建立

为了保证安全,QUIC也是加密传输数据的,所以在QUIC的建连过程中也需要双方协商出一个加密私钥。但与TLS不同,QUIC采用的加密算法仅需要一个RTT就能实现密钥交换,并且该算法也被用于目前正在草案阶段的TLS1.3协议。该就是Diffie-Hellman密钥交换算法。

建连

可以看到,客户端和服务端各自保留了自己的私钥a和b,通过交换各自的公钥B和A,以及基底G和很大的质数P,双方就能计算出相等的私钥S,这个S就是加密传输的对称密钥。
另外,根据离散对数的不可逆,即使拿到G,P,和质数B,也很难推导出私钥b(同理私钥a),也就保证了计算密钥的安全。
该过程对应到QUIC建连的过程中如下图。

1RTT

  • 客户端发起Inchoate client hello
  • 服务器返回Rejection,包括密钥交换算法的公钥信息,算法信息,证书信息等被放到server config中传给客户端
  • 客户端发起client hello,包括客户端公钥信息

此时,双方各自计算出了对称密钥。QUIC的1RTT建连过程结束,平均只耗时100ms以内。

后续发起连接的过程中,一旦客户端缓存或持久化了server config,就可以复用并结合本地生成的私钥进行加密数据传输了,不需要再次握手,从而实现0RTT建立连接。

时间延迟
  • 0-RTT
    • 超过50%的时间延迟提升(大概在50%和95%之间)
  • 提升丢失恢复
    • 基于超时的重传次数减少了10倍以上,可以改善尾部延迟和YouTube视频回放率
  • 其他更小的改进
    • 例如,避免队头阻塞,更高效的分帧
改进的拥塞控制

目前的 QUIC 的拥塞控制重新实现了一遍 TCP 的算法,毕竟 TCP 的算法是经过几十年的生产验证的,包括 TCP 的慢启动,拥塞避免,快重传,快恢复。在这些拥塞控制算法的基础上,再进行改进。

  • 灵活性,不在内核层,可以随意更改,目前谷歌提供了两套算法(Cubic和NewReno)以供选择,并提供了一套很灵活友好的接口,让你去实验新的拥塞控制算法,而且还可以为不同应用设定不同的拥塞控制策略。

  • 提供更为详细的信息,如单调包序号,SACK

  • 尽可能避免超时重传,为了尽可能的实现快重传而不是超时重传,QUIC采用了Tail Loss Probes (TLPs)实现某些情况下的快重传机制触发。

单调递增的 Packet Number。TCP 使用了基于字节序号 Sequence Number 和 ACK 来保证消息的有序到达。但是 Sequence Number 在重传的时候有二义性。你不知道下一个 ACK 是上一次请求的响应还是这次重传的响应。而单调递增的 Packet Number 可以避免这个问题,保证采样 RTT 的准确。

为了避免AIMD机制带来的带宽利用率低,采用了packet pacing来探测网络带宽。思路是,QUIC会通过追踪包的到达时间来预测当前带宽的使用情况,以决定是否提高,保持或者减少发送包的速率来避免网络拥塞。

packet_pacing

QUIC ACK帧支持256个ACK块,相比TCP的SACK在TCP选项中实现,有长度限制,最多只支持3个ACK块

QUIC ACK包同时携带了从收到包到回复ACK的延时,这样结合递增的包序号,能够精确的计算RTT。

TLP算法如下图,服务器的segments 6-10丢失,客户端在等待s6时,由于没有收到后续的序列,因此无法触发快速重传机制,时间达到probe阈值(PTO)后,TLP算法对segments10进行重传,客户端收到这个重传序列,就能触发快速重传机制。而QUIC会在PTO之前就发送两个TLPs来尽可能避免等到超时再重传。

TLP

避免队头阻塞的多路复用

SPDY 和 HTTP/2 已经实现了多路复用。多路复用的指的是我们不需要在为每个资源重新建立一次 TCP 连接,多个资源的传输可以共用一个连接。

多路复用

如此一来,在启用了 HTTP/2 的网站,我们再也不需要对资源进行合并了(:)压缩还是可以做一下的),毕竟一次性发出去多个资源和建立多个连接一个一个下载资源相比还是会快一下的。

然后 HTTP/2 的多路复用会有个很大的问题,那就是队头阻塞。原因还是因为 TCP 的 Sequence Number 机制,为了保证资源的有序到达,如果传输队列的队头某个资源丢失了,TCP 必须等到这个资源重传成功之后才会通知应用层处理后续资源。

多路复用

由于 QUIC 避开了 TCP, 他设计 connection 和 stream 的概念,一个 connection 可以复用传输多个 stream,每个 stream 之间都是独立的,单一一个 stream 丢包并不会影响到其他资源处理。

多路复用

连接迁移

TCP 是按照 4-要素(客户端IP、端口, 服务器IP、端口) 要确定一个连接的,当这4个要素其中一个发生变化的时候,连接就需要重新建立。而在移动端,我们经常会切换 4G/wifi 使用,每一次切换,我们只能重新建立连接。

在 QUIC 中,连接是由其维护的。 于是 QUIC 通过生成客户端生成一个 Connection ID (64位)的东西来区别不同连接,只要生成的 UUID 不变, 连接就不需要重新建立,即便是客户端的网络发生变化。

多路复用

前向冗余纠错

这里的错误指的是某个包丢了。当某个 packet 丢失的时候,QUIC 能够通过已经接收到的其他包对资源进行修复。

这意味着,实际上每个 packet 都携带着多余的信息,通过这些信息,QUIC 能够重组对应资源,而无需进行重传。

目前大概每 10 个包能修复一个 packet。

总是加密

QUIC加密协议是QUIC的一部分,它为连接提供了传输安全性。QUIC加密协议是 注定要消亡的。未来它将由TLS 1.3替代,但在TLS 1.3 最终启用之前QUIC需要一个加密协议。

借助于当前的QUIC加密协议,当客户端已经缓存了关于服务器的信息时,它可以无需往返就建立一个加密的连接。TLS,相反地,至少需要两次往返(算上TCP的3次握手)。QUIC握手应该比普通的TLS 握手(2048-bit RSA)高效大约5倍,而且安全等级更高。

  • 源地址欺骗

  • 重放攻击

  • 握手开销小

握手协议如下图

quic-handshake-flow

双级别流量控制

QUIC是多路复用的,多条stream可以建立在一条connection上,所以QUIC的流量控制不仅基于单个stream,还基于connection。
stream级别的流控能够控制单stream的数据发送情况。另外,接收窗口的收缩取决于最大接收字节的偏移而不是所有已接受字节的总和,它不像tcp流控,不会受到丢失数据的影响。

stream_flow

如果满足(flow control receive offset - consumed bytes) < (max receive window / 2)会触发WINDOW_UPDATE frame的发送来增大发送窗口大小。

window_update_be

window_update_af

connection级别流控算法和stream一致,各项数值是所有stream的总和。
connection级别的流控存在的必要是,即使做好了stream流控,但如果stream过多也会导致connection过度消耗带宽和系统资源;而且即使某一条stream过慢,其他stream依然能触发

connection级别的WINDOW_UPDATE,从而不会被影响。

Connection

实现QUIC

chrome

要想在浏览器上实现 QUIC ,有一些前置条件。由于现在好像只有 chrome 浏览器支持 QUIC 协议,所以下面的条件是针对 chrome 浏览器的。

整个 QUIC 协议比较复杂,想自己完全实现一套对笔者来说还比较困难。所以先看看开源实现有哪些:

1. Chromium

这个是官方支持的。优点自然很多,Google 官方维护基本没有坑,随时可以跟随 chrome 更新到最新版本。不过编译 Chromium 比较麻烦,它有单独的一套编译工具。暂时不考虑这个方案。

2. proto-quic

从 chromium 剥离的一个 QUIC 协议部分,但是其 github 主页已宣布不再支持,仅作实验使用。不考虑这个方案。

3. goquic

goquic 封装了 libquic 的 go 语言封装,而 libquic 也是从 chromium 剥离的,好几年不维护了,仅支持到 quic-36, goquic 提供一个反向代理,测试发现由于 QUIC 版本太低,最新 chrome 浏览器已无法支持。不考虑这个方案。

4. quic-go

quic-go 是完全用 go 写的 QUIC 协议栈,开发很活跃,已在 Caddy 中使用,MIT 许可,目前看是比较好的方案。

这里我做了两个测试:

  • 一个是基于proto-quic所做的正反代理,具体部署参考quic_toy
  • 采用 caddy 来部署实现 QUIC

request

部署方案如下:

nginx 还是继续使用,不过 nginx 只用来响应 TCP/443 端口,UDP/443 交给 caddy 来响应。nginx 返回响应头告诉 chrome 浏览器支持 QUIC,然后 caddy 作为反向代理 proxy 来转发。

但是caddy不支持关闭TCP端口,需要更改caddy的源码,关闭TCP:443端口,与nginx共存,让浏览器支持quic协议通信。

chrome版本必须 62-65 之间,如果高于这个版本或者低于这个版本,都会导致 QUIC 握手失败,进而无法进行 QUIC 通讯。

性能测试

2017年海外部署测试

QUIC还处于实验性阶段,但是已经在chrome等大多浏览器,腾讯云,部分服务站点得到支持。

为了测试quic在国外外网环境差的情况下的表现,设计正反代理,代理之间走quic协议,这种方式测试quic的表现,如下所示,架构图。

canada

其中quic proxy是通过官网提供的proto_quic的c++源码,加上自身的proxy解析协议实现的,具体demo可以参考视频。

测试过程是通过Q调进行的,具体的数据如下:

quic

direct

可以看出其中经过quic加速后速度确实有提升,正如官方宣称的那样。

香港proxy端是接入的天天P图的业务,经测试是正常的。

QQ页面接入测试

QQ会员团队通过灰度现网的一个页面来考察QUIC在现网的性能情况。

  • 页面情况
    Android日PV100w,页面大小95KB
    总请求30个,其中主资源请求1个,CDN请求24个,其他请求5个
    展示部分依赖php直出和js渲染

  • 灰度情况
    QUIC请求1个(php页面主资源),HTTP2请求29个

  • 灰度策略

客户端每天放量,对比灰度过程中页面主资源的HTTP2和QUIC的性能数据

  • 灰度效果

qq test

  • 效果说明

    因为建连依赖于1RTT和0RTT机制,使得QUIC建连平均耗时仅需46ms,比HTTP2的225ms减少180ms左右。

    由于目前灰度量只占到总请求量的10%,因此更严谨的性能对比数据有待进一步提高灰度范围,以上仅作现阶段参考。

    但依然可以看到QUIC在现网环境总体表现忧于HTTP2。

quic在弱网中的表现如下:

quic_test

性能优化

性能优化1:提升 0RTT 成功率

安全传输层虽然能够实现 0RTT,优势非常明显。但问题是,不是每一次连接都能实现 0RTT,对于我们的客户端和服务端来讲,如何最大程度地提升 0RTT 的成功率?

0RTT 能实现的关键是 ServerConfig。就像 TLS session resume 实现的关键是 session id 或者 session ticket 一样。ServerConfig 到达服务端后,我们根据 ServerConfig ID 查找本地内存,如果找到了,即认为这个数据是可信的,能够完成 0RTT 握手。

性能优化2:连接迁移 (Connection Migration) 的实现

那 服务端如何实现的呢?我们在 CLB 四层转发层面实现了根据 ID 进行哈希的负载均衡算法,保证将相同 ID 的 QUIC 请求落到相同的 CLB7 层集群上,在 CLB7 上,我们又会优先根据 ID 进行处理。

同一台 CLB7 保存了相同的 Stream 及 Connection 处理上下文,能够将该请求继续调度到相同的业务 RS 机器。

整个网络和 IP 切换过程,对于用户和业务来讲,没有任何感知。

性能优化3:动态的流量控制和拥塞控制

在连接和 Stream 级别设置了不同的窗口数。

最重要的是,我们可以在内存不足或者上游处理性能出现问题时,通过流量控制来限制传输速率,保障服务可用性。

包类型和格式

QUIC 具有特殊包和普通包。有两种类型特殊包:版本协商包 (Version Negotiation Packets) 和 公共复位包 (Public Reset Packets),普通包包含帧。

所有 QUIC 包的大小应该适配在路径的 MTU 以避免IP分片。路径 MTU 发现是正在进行中的工作,而当前 QUIC 实现为 IPv6 使用 1350 字节的最大QUIC包大小,IPv4 使用1370字节。两个大小都没有 IP 和 UDP 过载。

QUIC公共包头

传输的所有 QUIC 包以大小介于1至51字节的公共包头开始。公共包头的格式如下:

--- 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)                  |
+--------+--------+--------+--------+--------+--------+

公共头部中的字段如下:

  • 公共标记(Public Flags)

    • 0x01 = PUBLIC_FLAG_VERSION。这个标记的含义与包是由服务器还是客户端发送的有关。当由客户端发送时,设置它表示头部包含 QUIC 版本 (参考下面的说明)。客户端必须在所有的包中设置这个位,直到客户端收到来自服务器的确认,同意所提议的版本。服务器通过发送不设置该位的包来表示同意版本。当这个位由服务器设置时,包是版本协商包。版本协商在后面更详细地描述
    • 0x02 = PUBLIC_FLAG_RESET。设置来表示包是公共复位包。
    • 0x04 = 表明在头部中存在 32字节的多样化随机数。
    • 0x08 = 表明包中存在完整的8字节连接ID。必须为所有包设置该位,直到为给定方向协商出不同的值 (比如,客户端可以请求包含更少字节的连接ID)。
    • 0x30 处的两位表示每个包中存在的数据包编号的低位字节数。这些位只用于帧包。没有包号的公共复位和版本协商包 (由服务器发送) ,不使用这些位,且必须被设置为0。这2位的掩码:
      • 0x30 表示包号占用6个字节
      • 0x20 表示包号占用4个字节
      • 0x10 表示包号占用2个字节
      • 0x00 表示包号占用1个字节
    • 0x40 为多路径使用保留。
    • 0x80 当前未使用,且必须被设置为0。
  • 连接ID:这是客户端选择的无符号64位统计随机数,该数字是连接的标识符。由于 QUIC 的连接被设计为,即使客户端漫游,连接依然保持建立状态,因而 IP 4元组(源IP,源端口,目标IP,目标端口)可能不足以标识连接。对每个传输方向,当4元组足以标识连接时,连接ID可以省略。

  • QUIC版本:表示 QUIC 协议版本的32位不透明标记。只有在公共标记包含 FLAG_VERSION(比如 public_flags & FLAG_VERSION !=0) 时才存在。客户端可以设置这个标记,并 准确 包含一个提议版本,同时包含任意的数据(与该版本一致)。当客户端提议的版本不支持时,服务器可以设置这个标记,并可以提供一个可接受版本的列表(0或多个),但 一定不能(MUST not) 在版本信息之后包含任何数据。

  • 包号(Packet Number):包号的低 8,16,32,或 48 位,基于公共标记的FLAG_BYTE_SEQUENCE_NUMBER 标记。每个普通包(与特别的公共复位和版本协商包相反)由发送者分配包号。由某一端发送的首包包号应该为1,后续每个包的包号应该比前一个的大1。包号的低64位被用作加密随机数的一部分;然而,QUIC 端点一定不能发送其包号无法以 64 位表示的包。如果 QUIC 端点传输了包号为 (2^64-1) 的包,则该包必须包含错误码为 QUIC_SEQUENCE_NUMBER_LIMIT_REACHED 的 CONNECTION_CLOSE 帧,且对端一定不能再传输任何其它包。

公共标记处理流程:

--- src
Check the public flags in public header
                 |
                 |
                 V
           +--------------+
           | Public Reset |    YES
           | flag set?    |---------------> Public Reset Packet
           +--------------+
                 |
                 | NO
                 V
           +------------+          +-------------+
           | Version    |   YES    | Packet sent |  YES
           | flag set?  |--------->| by server?  |--------> Version Negotiation
           +------------+          +-------------+               Packet
                 |                        |
                 | NO                     | NO
                 V                        V
           Regular Packet         Regular Packet with
                               QUIC Version present in header
---

特殊包

版本协商包

只有服务器会发送版本协商包。版本协商包以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)                |             
+--------+--------+--------+--------+--------+--------+--------+--------+---...--+
公共复位包

公共复位包以8位的公共标记和64位的连接ID开始。公共标记必须设置 PUBLIC_FLAG_RESET,并表明64位的连接ID。公共复位包的其余部分像标记 PRST 的加密握手消息那样编码

--- 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)                         
+--------+--------+--------+--------+--------+--------+---

标记值映射:标记值映射包含如下的标记值:

  • RNON (public reset nonce proof) - 一个64位的无符号整数
  • RSEQ (rejected packet number) - 一个64位的包号
  • CADR (client address) - 观察到的客户端IP地址和端口号。它当前只被用于调试,因而是可选的

普通包

普通包已经过认证和加密。公共头部已认证但未加密,从第一帧开始的包的其余部分已加密。紧随公共头部之后,普通包包含 AEAD(authenticated encryption and associated data)数据。要解释内容,这些数据必须先解密。解密之后,明文由一系列帧组成。

帧包

帧包具有一个载荷,它是一系列的类型前缀帧。帧类型的格式将在本文档的后面定义,但帧包的通用格式如下:

--- src
+--------+---...---+--------+---...---+
| Type   | Payload | Type   | Payload |
+--------+---...---+--------+---...---

QUIC连接生命周期

连接建立

QUIC 客户端是初始化连接的端点。QUIC 的连接建立将版本协商与加密和传输握手交织在一起以减少连接建立延迟。

最初由客户端发向服务器的每个包必须设置版本标记,而且必须指定使用的协议版本。客户端发送的每个包必须开启版本标记,直到它从服务器收到了版本标记关闭的包。在服务器从客户端收到了第一个版本标记关闭的包后,它必须忽略(可能由于延迟)任何版本标记打开的包。

当服务器收到一个含有新连接连接ID的包时,它将对比客户端的版本和它支持的版本。如果客户端的版本对于服务器来说可以接受,服务器将在连接的整个生命周期中使用这个协议版本。在这种情况下,服务器发送的所有包的版本标记都是关闭的。

如果服务器不接受客户端的版本,则导致 1-RTT 的延迟。服务器将给客户端发送一个版本协商包。该包将设置版本标记,并包含服务器支持版本的集合。

数据传输

QUIC 实现了连接可靠性,拥塞控制,和流控。QUIC 流控紧随 HTTP/2 的流控之后。QUIC 可靠性和拥塞控制在一份附带文档中描述。QUIC 连接使用一个单独的包序列号空间,以此跨连接共享拥塞控制和丢失恢复。

QUIC 连接中传输的所有数据,包括加密握手,被作为流内数据发送,但ACKs 确认 QUIC 包。

QUIC流的生命

流是被分割为流帧的双向数据的独立序列。流可以由客户端或服务器创建,可以与其它流并发交错地发送数据,且可以取消。

流创建通过为给定的流发送一个 STREAM 帧显式地完成。为了避免流 ID 冲突,如果流由服务器初始化,则流 ID 必须是偶数,而如果由客户端初始化则必须为奇数。0 不是一个有效的流 ID。流 1 为加密握手保留,它应该是第一个客户端初始化的流。当基于 QUIC 使用 HTTP/2 时,流 3 为传输所有其它流的压缩首部而保留,以确保首部的处理和传送可靠且有序。

帧类型和格式

帧类型字节有两种解释,产生两种帧类型:特殊帧类型,和常规帧类型。特殊帧类型在帧类型字节中编码帧类型和对应的标志,而常规帧类型简单地使用帧类型字节。

当前定义的特殊帧类型如下:

--- 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                       |
   +------------------+-----------------------------+
---
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 帧首部中的字段如下:

  • 帧类型: 帧类型字节是一个包含多种标记 (1fdooossB) 的 8 位值:
    • 最左边的位必须被设为 1 以表明这是一个 STREAM 帧。
    • ‘f’ 位是FIN位。当被设置为 1 时,这个位表明发送者已经完成在流上的发送并希望 “half-close(半关闭)”
    • ‘d’ 位表明 STREAM 头部中是否包含数据长度。当设为 0 时,这个字段表明 STREAM 帧扩展至数据包的结尾处。
    • 接下来的三个 ‘ooo’ 位编码 Offset 头部字段的长度为 0,16,24,32,40,48,56,或 64 位长。
    • 接下来的两个 ‘ss’ 位编码流 ID 头部字段的长度为 8,16,24,或 32 位长。
  • 流 ID: 一个大小可变的流唯一的无符号ID
  • 偏移: 一个大小可变的无符号数字描述流中这块数据的字节偏移
  • 数据长度: 一个可选的 16 位无符号数字描述这个流帧中数据的长度。只有当包是 “全大小(full-sized)” 包时,才应该省略长度,以避免通过填充发生腐败的风险

流帧必须总是具有非零数据长度,或设置 FIN 位。

ACK帧

发送 ACK 帧以通知对端哪些包已经收到,以及接收者仍然认为哪些包丢失了(丢失包的内容可能需要被重发)。ACK 帧包含 1 到 256 个 ack 块。Ack 块是确认的包的范围,与 TCP 的 SACK 块类似,但 QUIC 没有 TCP 的累积ack 点的等价物,因为包将以新的序列号重传。

不像TCP SACK,QUIC ACK块是不可变的,因此一旦一个包被确认了,即使它没有出现在未来的ack帧中,它也被假设已经确认。

段偏移

0:Ack帧的起始位置。

T:时间戳段起始位置的字节偏移量。

A:Ack块段起始位置的字节偏移量。

N:最大已确认的字节长度。

--- src
     0                            1  => N                     N+1 => A(aka N + 3)
+---------+-------------------------------------------------+--------+--------+
|   Type  |                   Largest Acked                 |  Largest Acked  |
|   (8)   |    (8, 16, 32, or 48 bits, determined by ll)    | Delta Time (16) |
|01nullmm |                                                 |                 |
+---------+-------------------------------------------------+--------+--------+
     A             A + 1  ==>  A + N
+--------+----------------------------------------+              
| Number |             First Ack                  |
|Blocks-1|           Block Length                 |
| (opt)  |(8, 16, 32 or 48 bits, determined by mm)|
+--------+----------------------------------------+
  A + N + 1                A + N + 2  ==>  T(aka A + 2N + 1)
+------------+-------------------------------------------------+
| Gap to next|              Ack Block Length                   |
| Block (8)  |   (8, 16, 32, or 48 bits, determined by mm)     |
| (Repeats)  |       (repeats Number Ranges times)             |
+------------+-------------------------------------------------+
     T        T+1             T+2                 (Repeated Num Timestamps)
+----------+--------+---------------------+ ...  --------+------------------+  
|   Num    | Delta  |     Time Since      |     | Delta  |       Time       |
|Timestamps|Largest |    Largest Acked    |     |Largest |  Since Previous  |
|   (8)    | Acked  |      (32 bits)      |     | Acked  |Timestamp(16 bits)|
+----------+--------+---------------------+     +--------+------------------+
---

ACK 帧中的字段如下:

  • 帧类型: 帧类型字节是一个包含多种标记(01nullmmB)的 8 位值
    • 开始的两位必须被设置为 01,以表明这是一个 ACK 帧
    • ‘n’ 位表明帧是否有多于 1 个的 Ack 范围
    • ‘u’ 位尚未使用
    • 两个 ‘ll’ 位编码最大已观察字段的长度为 1,2,4,或者 6 字节长
    • 两个 ‘mm’ 位编码丢失包序列号差值字段的长度为 1,2,4,或者 6 字节长
  • 最大已确认(Largest Acked): 一个大小可变的无符号值,表示对端已观察到的最大包号
  • 最大已确认差值时间(Largest Acked Delta Time): 一个 16 位无符号浮点数,其中 11 个显式位为底数,5 位的显式指数,描述自收到最大已确认包至这个 Ack 帧发送之间经过的微秒数
  • Ack 块段 (Ack Block Section):
    • 块个数 (Num Blocks) :一个可选的8位无符号值描述 ack 块的个数减一。只有在 ‘n’ 标记位为 1 时才存在
    • Ack块长度 (Ack block length) :一个大小可变的包号差值。对于第一个丢失包范围,Ack 块以最大已确认包开始。对于首个 Ack 块,Ack 块的长度为 1 + 该值。对于后续的 Ack 块,它是 Ack 块的长度。对于非首个块,0值表示多于 256 个包丢失了
    • 到下一块的间隙 (Gap to next block) :一个8位无符号值,描述 Ack 块之间的包个数
  • 时间戳段 (Timestamp Section):
    • 时间戳个数 (Num Timestamp) :一个 8 位无符号值描述包含在这个 Ack 帧中的时间戳个数。在后面的 timestamps 中将有许多 <packet number, timestamp> 对。
    • 已观察最大差值 (Delta Largest Observed) :一个8位无符号值描述首个时间戳和最大已观察包之间包号的差值。然而,包号为最大已观察包号 减去 已观察最大差值 (delta largest observed)。
    • 首个时间戳 (First Timestamp) :一个32位无符号值,描述从最大观测值指定的数据包到达的连接开始减去最大观测差值的时间增量(以微秒为单位)。
    • 最大观察差值(重复)(Delta Largest Observed(Repeated)) :(同上。)
    • 自前一个时间戳的时间(重复) (Time Since Previous Timestamp (Repeated)) :一个 16 位无符号值描述与前一个时间戳的差值。它的编码格式与 Ack Delay Time 相同。
STOP_WAITING 帧

STOP_WAITING 帧用于通知对端,它不应该继续等待包号小于特定值的包。包号以1,2,4或6字节编码,using the same coding length as is specified for the packet number for the enclosing packet’s header (specified in the QUIC Frame Packet’s Public Flags field.) 这个帧如下:

--- src
     0        1        2        3         4       5       6  
+--------+--------+--------+--------+--------+-------+-------+
|Type (8)|   Least unacked delta (8, 16, 32, or 48 bits)     |
|        |                       (variable length)           |
+--------+--------+--------+--------+--------+--------+------+
---

STOP_WAITING帧中的字段如下:

  • 帧类型:帧类型是一个8位的值,它必须被设置为0x06以表明这是一个STOP_WAITING帧
  • 最小未确认差值:一个可变长度的包号差值,与包首部的包号长度相同。将它从头部的包号减去以确定最小的未确认包。结果的最小未确认包是发送者依然在等待确认的包号最小的包。如果接收者丢失了任何比这个值小的包,接收者应该将那些包认做无可挽回的丢失
WINDOW_UPDATE 帧

WINDOW_UPDATE 帧用于通知对端一个端点的流量控制接收窗口的增长。流ID可以是0,表示WINDOW_UPDATE应用于连接级的流量控制窗口,或者 > 0 表示指定的流应该增长它的流量控制窗口。帧如下:

--- src
    0         1                 4        5                 12
+--------+--------+-- ... --+-------+--------+-- ... --+-------+
|Type(8) |    Stream ID (32 bits)   |  Byte offset (64 bits)   | 
+--------+--------+-- ... --+-------+--------+-- ... --+-------+
---

WINDOW_UPDATE帧中的字段如下:

  • 帧类型:帧类型是一个8位值,它必须被设置为0x04以表示这是一个WINDOW_UPDATE帧。
  • 流 ID:要更新流控制窗口的流的ID,或者为0来描述连接级的流控制窗口。
  • 字节偏移: 一个64位无符号整型值,表示在给定的流上可以发送的数据的完整字节偏移量。在连接级流量控制的情况下,是在当前所有打开的流上可以发送的字节的总和。
BLOCKED 帧

BLOCKED帧用于向远端指明本端点已经准备好发送数据了(且有数据要发送),但是当前被流量控制阻塞了。这是一个纯粹的信息帧,它对于调试极其有用。BLOCKED帧的接收者应该简单的丢弃它(可能在打印了一条有帮助的log消息之后)。帧如下:

--- src
     0        1        2        3         4
+--------+--------+--------+--------+--------+
|Type(8) |          Stream ID (32 bits)      |  
+--------+--------+--------+--------+--------+
---

BLOCKED帧中的字段如下:

  • 帧类型:帧类型是一个8位值,它必须被设置为0x05以表示这是一个BLOCKED帧。
  • 流 ID:一个32位的无符号数,表示流量控制阻塞的流。非零 流 ID 字段描述了被流量控制阻塞的流。当这个值为0时,流 ID字段在连接级指明连接被流量控制阻塞了。
RST_STREAM 帧

RST_STREAM帧允许异常终止一条流。当这个帧是流的创建者发出的,表示创建者希望取消这条流。当接收端发送这个帧,表示有错误或者当前接收端不希望接收这个流,因此这个流应该被关闭。帧结构如下:

--- src
     0        1            4      5              12     8             16
+-------+--------+-- ... ----+--------+-- ... ------+-------+-- ... ------+
|Type(8)| StreamID (32 bits) | Byte offset (64 bits)| Error code (32 bits)|
+-------+--------+-- ... ----+--------+-- ... ------+-------+-- ... ------+
---

RST_STREAM帧的字段如下:

  • 帧类型: 帧类型是一个8位的值,必须设置为0x01表示这是一个RST_STREAM帧。
  • 流标识符: 32位流标识符,表示将被终止的流。
  • 字节偏移: 64位无符号整型表示流数据的绝对字节偏移。
  • 错误码: 32位的QUIC错误码表示流被关闭的原因,错误码在文档后续列出。
CONNECTION_CLOSE 帧

CONNECTION_CLOSE帧用来通知连接将被关闭。如果流仍然有数据在发送,那么在连接关闭时,这些流将被隐式关闭。(理论上一个GOAWAY帧应该已经被发送了足够的时间使所有流都关闭。)帧结构如下:

--- src
     0        1             4        5        6       7       
+--------+--------+-- ... -----+--------+--------+--------+----- ...
|Type(8) | Error code (32 bits)| Reason phrase   |  Reason phrase  
|        |                     | length (16 bits)|(variable length)
+--------+--------+-- ... -----+--------+--------+--------+----- ...
---

CONNECTION_CLOSE帧的字段如下:

  • 帧类型: 8位的值必须设置为0x02,表示这个帧是一个CONNECTION_CLOSE帧。
  • 错误码: 32位字段包含了QUIC错误码,表明连接关闭原因。
  • 原因描述长度: 16位无符号数,表示reason phrase的长度。如果发送方除了错误码之外,并不打算给出详细情况,那么该字段可能为0。
  • 原因描述: 可选的可读的连接关闭的原因。

QUIC现状

HTTP-over-QUIC 将被吸收改名为 HTTP/3。未来的 web 传输不再依赖 TCP 协议,升级更新也不再需要依赖系统内核升级了,未来的 HTTP 可以跟其他产品一样月更、甚至周更。

目前,如果想体验 QUIC 可以使用 candy 服务器。candy 在 0.9 版本之后就支持 QUIC 了。

在 github 上面找到了 C++ 版本的实现,利用 Nodejs 的 C++ 模块,我们可以快速实现一个 node-quic 的样子。也可以实现自己的C++版本,或者在chrome中打开quic选项,体验quic加速的效果。

总结

QUIC是一个实现在UDP上的可靠的、多路复用的协议,总是加密的,更少的连接延迟时间,FEC差错冗余,快速更新的用户空间级别实现,还是开源,将会成为HTTP/3,是以后网络发展的趋势。

据说,QUIC作为一个试验场,很多idear都会被平移到更加规范的标准中,比如BBR之于TCP(不过我是非常不看好TCP的,BBR在QUIC上持续持久发展难道不更好吗?)。同样这个0RTT的思路也将会被吸纳到在途的TLS1.3版本中,非常期待。

这一切非常感谢Google,一家伟大的公司。从看到HTTP的弊端到SPDY,然后再到HTTP2.0,进而又看到了TCP的弊端,因此从SPDY/HTTP2.0直接衍生出QUIC,子啊QUIC本身的进化过程中,对于TCP也是择其善者而从之,其不善者而改之,这就是我们现在接触到的QUIC协议,集HTTP2.0,TCP于大成的QUIC协议。

不管怎么说,我个人是比较看好QUIC的。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1522515386@qq.com

文章标题:QUIC协议介绍

文章字数:9.7k

本文作者:Blitz

发布时间:2019-10-26, 11:16:37

最后更新:2019-10-26, 17:25:41

原始链接:http://yoursite.com/2019/10/26/QUIC%E5%8D%8F%E8%AE%AE%E4%BB%8B%E7%BB%8D/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录
×

喜欢就点赞,疼爱就打赏