QUIC 协议

QUIC (QUIC)是一种新的默认加密的互联网传输协议,它提供了许多改进,旨在加速 HTTP 传输并使其更加安全,其目标是最终取代网络上的 TCP 和 TLS。在这篇博客文章中,我们将概述 QUIC 的一些关键特性,以及它们如何使网络受益,还有支持这种激进的新协议所面临的一些挑战。

事实上,有两个协议具有相同的名称: “ Google QUIC”(简称“ gQUIC”) ,这是 Google 工程师几年前设计的最初协议,经过多年的试验,现在已被 IETF (互联网工程任务组)用于标准化。

“ IETF QUIC”(从现在开始只是“ QUIC”)已经与 gQUIC 有很大的分歧,因此它可以被认为是一个单独的协议。从数据包的有线格式,到握手和 HTTP 的映射,QUIC 通过许多组织和个人的开放式协作,改进了原来的 gQUIC 设计,共同的目标是使互联网更快更安全。

那么,QUIC 提供了哪些改进呢?

自带加密

QUIC 与现在广泛使用的 TCP 的一个更根本的不同之处在于,它的设计目标是提供一个默认安全的传输协议。QUIC 通过提供安全特性(如身份验证和加密)来实现这一点,这些特性通常由传输协议本身的更高层协议(如 TLS)来处理。

初始的 QUIC 握手结合了 TCP 中典型的三次握手,以及 TLS 1.3 的握手,后者提供端点身份验证以及密码参数协商。对于那些熟悉 TLS 协议的用户,QUIC 用自己的帧格式替换 TLS 记录层,同时保持相同的 TLS 握手消息。

这不仅可以确保连接始终经过身份验证和加密,而且还可以加快初始连接的建立速度: 与 TCP 和 TLS 1.3握手结合所需的两次往返相比,典型的 QUIC 握手只需要在客户机和服务器之间完成一次往返。

但是 QUIC 甚至更进一步,还加密了额外的连接元数据,这些元数据可能被中间设备(middle-boxes)滥用以干扰连接。例如,当使用连接迁移时,攻击者可以使用数据包编号来关联多个网络路径上的用户活动。通过对数据包编号进行加密,QUIC 可以确保它们不能被任何实体(除了连接中的端点)用于关联活动。

可插拔式设计

应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。这是一个飞跃,因为传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,这在产品快速迭代,网络爆炸式增长的今天,显然有点满足不了需求。

即使是单个应用程序的不同连接也能支持配置不同的拥塞控制。就算是一台服务器,接入的用户网络环境也千差万别,结合大数据及人工智能处理,我们能为各个用户提供不同的但又更加精准更加有效的拥塞控制。比如 BBR 适合,Cubic 适合。

应用程序不需要停机和升级就能实现拥塞控制的变更,我们在服务端只需要修改一下配置,reload 一下,完全不需要停止服务就能实现拥塞控制的切换。

Packet Number

TCP 为了保证可靠性,使用了基于字节序号的 Sequence Number 及 Ack 来确认消息的有序到达。QUIC 同样是一个可靠的协议,它使用 Packet Number 代替了 TCP 的 sequence number,并且每个 Packet Number 都严格递增,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。而 TCP 呢,重传 segment 的 sequence number 和原始的 segment 的 Sequence Number 保持不变,也正是由于这个特性,可能会导致 TCP 重传的歧义问题。

如上图所示,超时事件 RTO 发生后,客户端发起重传,然后接收到了 Ack 数据。由于序列号一样,这个 Ack 数据到底是原始请求的响应还是重传请求的响应呢?不好判断。

如果算成原始请求的响应,但实际上是重传请求的响应(上图左),会导致采样 RTT 变大。如果算成重传请求的响应,但实际上是原始请求的响应,又很容易导致采样 RTT 过小。

由于 Quic 重传的 Packet 和原始 Packet 的 Pakcet Number 是严格递增的,所以很容易就解决了这个问题。

如上图所示,RTO 发生后,根据重传的 Packet Number 就能确定精确的 RTT 计算。如果 Ack 的 Packet Number 是 N+M,就根据重传请求计算采样 RTT。如果 Ack 的 Pakcet Number 是 N,就根据原始请求的时间计算采样 RTT,没有歧义性。

但是单纯依靠严格递增的 Packet Number 肯定是无法保证数据的顺序性和可靠性。QUIC 又引入了一个 Stream Offset 的概念。

即一个 Stream 可以经过多个 Packet 传输,Packet Number 严格递增,没有依赖。但是 Packet 里的 Payload 如果是 Stream 的话,就需要依靠 Stream 的 Offset 来保证应用数据的顺序。如错误! 未找到引用源。所示,发送端先后发送了 Pakcet N 和 Pakcet N+1,Stream 的 Offset 分别是 x 和 x+y。

假设 Packet N 丢失了,发起重传,重传的 Packet Number 是 N+2,但是它的 Stream 的 Offset 依然是 x,这样就算 Packet N + 2 是后到的,依然可以将 Stream x 和 Stream x+y 按照顺序组织起来,交给应用程序处理。

不允许 Reneging

什么叫 Reneging 呢?就是接收方丢弃已经接收并且上报给 SACK 选项的内容。TCP 协议不鼓励这种行为,但是协议层面允许这样的行为。主要是考虑到服务器资源有限,比如 Buffer 溢出,内存不够等情况。

Reneging 对数据重传会产生很大的干扰。因为 Sack 都已经表明接收到了,但是接收端事实上丢弃了该数据。QUIC 在协议层面禁止 Reneging,一个 Packet 只要被 Ack,就认为它一定被正确接收,减少了这种干扰。

基于 stream 和 connecton 级别的流量控制

QUIC 的流量控制类似 HTTP2,即在 Connection 和 Stream 级别提供了两种流量控制。为什么需要两类流量控制呢?主要是因为 QUIC 支持多路复用。

  1. Stream 可以认为就是一条 HTTP 请求。
  2. Connection 可以类比一条 TCP 连接。多路复用意味着在一条 Connetion 上会同时存在多条 Stream。既需要对单个 Stream 进行控制,又需要针对所有 Stream 进行总体控制。

QUIC 实现流量控制的原理比较简单:

通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。

通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。

QUIC 的流量控制和 TCP 有点区别,TCP 为了保证可靠性,窗口左边沿向右滑动时的长度取决于已经确认的字节数。如果中间出现丢包,就算接收到了更大序号的 Segment,窗口也无法超过这个序列号。

但 QUIC 不同,就算此前有些 packet 没有接收到,它的滑动只取决于接收到的最大偏移字节数。

针对 Stream:

针对 Connection:

同样地,STGW 也在连接和 Stream 级别设置了不同的窗口数。

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

没有阻塞和多路复用

QUIC 的多路复用和 HTTP2 类似。在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (stream)。但是 QUIC 的多路复用相比 HTTP2 有一个很大的优势。

QUIC 一个连接上的多个 stream 之间没有依赖。这样假如 stream2 丢了一个 udp packet,也只会影响 stream2 的处理。不会影响 stream2 之前及之后的 stream 的处理。

这也就在很大程度上缓解甚至消除了队头阻塞的影响。

遇到的挑战

为了实现它的承诺,QUIC 协议需要打破许多网络应用程序认为理所当然的一些假设,这可能使得 QUIC 的实现和部署变得更加困难。

NAT 问题

QUIC 被设计成在 UDP 数据报之上交付,以简化部署并避免来自网络设备的问题,这些设备从未知协议中丢弃数据包,因为大多数设备已经支持 UDP。这也允许 QUIC 实现生存在用户空间中,这样,例如,浏览器将能够实现新的协议特性并将它们发送给用户,而不必等待操作系统的更新。

然而,尽管预期的目标是避免损耗,但这也使得防止滥用和正确地将数据包路由到正确的端点更具挑战性。

典型的 NAT 路由器通过使用传统的4元组(源 IP 地址和端口,目标 IP 地址和端口)来跟踪穿过它们的 TCP 连接,通过观察 TCP SYN、 ACK 和 FIN 包在网络上传输,它们可以检测到新连接何时建立以及何时终止。这使得它们能够精确地管理 NAT 绑定的生命周期,内部 IP 地址和端口之间的关联,以及外部的关联。

使用 QUIC 这是不可能的,因为现在部署在野外的 NAT 路由器还不了解 QUIC,所以它们通常退回到默认的、无法精确的对 UDP 流处理,这可能会影响长时间运行的连接。

当发生 NAT 重绑定时(例如由于超时) ,NAT 外围的端点将看到来自不同源端口的数据包,而不是最初建立连接时观察到的端口,这使得仅使用4元组无法跟踪连接。

不仅仅是 NAT!QUIC 打算提供的一个特性称为“连接迁移” ,它允许 QUIC 端点随意迁移连接到不同的 IP 地址和网络路径。例如,当已知的 WiFi 网络可用时,移动客户端将能够在蜂窝数据网络和 WiFi 之间迁移 QUIC 连接(比如用户进入他们最喜欢的咖啡店)。

QUIC 试图通过引入连接 ID 的概念来解决这个问题: 一个任意长度的 blob 类型的变量,由 QUIC 数据包携带,用于识别连接。端点可以使用这个 ID 来跟踪它们负责的连接,而不需要检查4元组(实际上可能有多个 ID 标识同一个连接,例如在使用连接迁移时避免链接不同的路径,但是这种行为是由端点而不是中间设备控制的)。

然而,这也给使用选播寻址和 ECMP 路由的网络运营商带来了一个问题,在这种情况下,单个目的地 IP 地址可能识别数百甚至数千台服务器。由于这些网络使用的边缘路由器也不知道如何处理 QUIC 流量,可能会发生这样的情况: 属于同一个 QUIC 连接(即具有相同的连接 ID)但具有不同的4元组(由于 NAT 重新绑定或连接迁移)的 UDP 数据包最终被路由到不同的服务器,从而中断连接。

为了解决这个问题,网络运营商可能需要采用更智能的第四层负载平衡解决方案,这种方案可以在软件中实现并部署,而不需要触及边缘路由器(例如 Facebook 的 Katran 项目)。

头部压缩

其次是头部压缩的问题。

HTTP/2 引入的另一个好处是头部压缩(或 HPACK) ,它允许 HTTP/2 端点通过删除 HTTP 请求和响应中的冗余来减少通过网络传输的数据量。

HPACK 使用了动态表,这些表填充了从以前的 HTTP 请求(或响应)发送(或接收)的头,允许端点引用以前在新请求(或响应)中遇到的头,而不必再次传输它们。

HPACK 的动态表需要在编码器(发送 HTTP 请求或响应的一方)和解码器(接收它们的一方)之间同步,否则解码器将无法解码它接收到的内容。

对于 TCP 上的 HTTP/2,这种同步是透明的,因为传输层(TCP)负责以发送 HTTP 请求和响应的相同顺序发送请求和响应,更新表的指令只需由编码器作为请求(或响应)本身的一部分发送,使得编码非常简单。但是对于 QUIC 来说,情况要复杂得多。

QUIC 可以在不同的流上独立地交付多个 HTTP 请求(或响应) ,这意味着尽管它负责按照单个流的顺序交付数据,但是在多个流之间不存在顺序保证。

例如,如果客户端通过 QUIC 流 a 发送 HTTP 请求 a,并通过流 b 请求 b,那么由于网络中的数据包重新排序或丢失,可能会发生请求 b 在请求 a 之前被服务器接收到,如果请求 b 被编码成引用请求 a 的报头,服务器将无法解码,因为它还没有看到请求 a。

在 gQUIC 协议中,这个问题通过在同一个 gQUIC 流上序列化所有 HTTP 请求和响应头(而不是正文)来解决,这意味着无论如何头都会按顺序发送。这是一个非常简单的方案,允许实现重用大量现有的 HTTP/2代码,但另一方面,它增加了 QUIC 设计用来减少的队头阻塞。IETF QUIC 工作组因此设计了一个新的 HTTP 和 QUIC (“ HTTP/QUIC”)之间的映射,以及一个新的头压缩方案,称为“ QPACK”。

在 HTTP/QUIC 映射和 QPACK 规范的最新草案中,每个 HTTP 请求/响应交换都使用自己的双向 QUIC 流,因此没有队头阻塞。此外,为了支持 QPACK,每个对等点创建两个额外的单向 QUIC 流,一个用于向另一个对等点发送 QPACK 表更新,另一个用于确认对方接收到的更新。这样,QPACK 编码器只有在被解码器显式地确认之后才能使用动态表引用。

容易被攻击

基于 udp 协议的一个常见问题是它们容易受到反射攻击(reflection attacks),攻击者通过欺骗定向到服务器的数据包的源 IP 地址,使它们看起来像来自受害者,从而欺骗本来无辜的服务器,将大量数据发送给第三方受害者。

当服务器发送的响应恰好大于它接收到的请求时,这种攻击非常有效,这也就是所谓的“amplification”。

由于 TCP 在握手过程中传输的初始数据包(SYN,SYN + ack,...)具有相同的长度,所以它们不具有任何放大潜力,因此 TCP 通常不用于这种攻击。

另一方面,QUIC 的握手非常不对称: 对于 TLS,在其第一次传输中,QUIC 服务器通常发送自己的证书链,这个链可能非常大,而客户端只需发送几个字节(将 TLS clienthal 消息嵌入到 QUIC 数据包中)。由于这个原因,客户端发送的初始 QUIC 数据包必须被填充到特定的最小长度(即使数据包的实际内容要小得多)。然而,这种方式仍然缓解不了多少,因为典型的服务器响应跨越多个包,因此仍然远远大于被填充的客户端的包。

QUIC 协议还定义了一种显式源地址验证机制,在这种机制中,服务器不发送长响应,而只发送一个小得多的“重试”数据包,其中包含一个唯一的加密标记,然后客户端必须在一个新的初始数据包中回传给服务器。通过这种方式,服务器对客户端不会欺骗自己的源 IP 地址(因为它收到了重试数据包)有更高的信心,并且可以完成握手。这种缓解的缺点是,它将初始握手持续时间从单次往返增加到两次。

另一种解决方案涉及到减少服务器的响应,使反射攻击变得不那么有效,例如通过使用 ECDSA 证书(通常比 RSA 证书小得多)。我们还一直在试验使用 zlib 和 brotli 等现成的压缩算法压缩 TLS 证书的机制,这是 gQUIC 最初引入的一个特性,但目前在 TLS 中不可用。

性能优化问题

QUIC 反复出现的问题之一是涉及到现有硬件和软件无法理解它。我们已经了解了 QUIC 如何尝试解决路由器等网络中间设备的问题,但另一个潜在的问题是 QUIC 端点本身通过 UDP 发送和接收数据的性能。多年来,许多工作都在尽可能优化 TCP 实现,包括在软件(比如操作系统)和硬件(比如网络接口)中构建卸载功能,但是目前 UDP 还没有这些功能。

然而,QUIC 实现能够利用这些功能只是时间问题。看看最近在 LInux 上实现 UDP 通用分段卸载的努力,这将允许应用程序在用户空间和内核空间网络堆栈之间捆绑和传输多个 UDP 段,代价是一个单一的(或者足够接近的) ,以及在 LInux 上添加 zerocopy 套接字支持的应用程序,这将允许应用程序避免将用户空间内存复制到内核空间的代价。