Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

Quick, UDP, Internet, Connections

诞生背景

  • HTTP/1:每次请求都建立一个 TCP 连接

  • HTTP/1.1:支持长连接,同一个 IP 对应一个 TCP 连接

  • HTTP/2:TCP 多路复用,同一个 TCP 并发 多个 HTTP 请求

  • 并发数量在浏览器实现上有限制,以 Chrome 为例为 6,普遍为 6~8(可能为滑动窗口大小限制,或者因为更多的并发数量若发生头部拥塞使得总体传输速率下降)

使用 HTTP/2 所提供的多路复用功能在链路出现丢包时,TCP 的按序确认机制使得丢失的数据包需要等待重新发送和确认,滑动窗口停滞,其后的所有数据包都被阻塞,这样一来 HTTP/2 在这种情形下的表现反而不如 HTTP/1。

此外,HTTP/2 在建立 TCP 连接的时,需要和服务器进行三次握手来确认连接成功,会消耗 1.5 个 RTT,如果使用 HTTPS 的话,还需要使用 TLS 协议进行加密,而 TLS 也根据版本需要 1~2 个 RTT(TLS1.2 需要 1RTT),也就是说,使用 HTTP/2 在信息得到传输前就需要消耗 3~4 个 RTT(至少 2.5RTT)的时间。

TCP 的短板问题

  1. TCP +TSL 握手占用时间(至少 2.5RTT)

  2. TCP 巨大的头部浪费带宽(20~60 字节)

  3. TCP 头部拥塞

TCP 的按序确认除了导致头部拥塞外,还导致了另一个重传包数量问题:TCP 接收方可以将未按序到达的数据包 37、38、40 先行缓存(并且引入快速重传机制,回送缺失数据包 34 的 ack 不断提醒发送方,如果发送方连续收到 3 次相同的 ack,就会重传,防止超时引发窗口缩小),但是由于 ack 序列号只能确认连续的数据包,所以无法通知发送方 37、38、40 已经先行到达,只能发送数据包 34 的 ack,而发送方在接收到重传请求后不确定从 35~41 这些已经发送的数据包要不要同样重传,因为后续的包可能被接收,也可能丢失。如果全部重传,那么会浪费带宽,如果不重传,那么如果这些包丢失,就会浪费时间。(后续又引入了 SACK 机制:https://caoziye.top/2019/10/TCP-Options/,但是 SACK 又继续增大了 TCP 的头部)

  1. TCP 连接无法迁移(源 IP + 源 Port + 目标 IP + 目标 Port + 传输层协议)

除了传输层协议是 TCP 不变以外,剩下的四元组其中任一发生变化,TCP 连接就会断开,需要重新和新的 ip:port 重新握手建立连接。比如移动设备 wifi 和 5g 网络的切换,或者是行车过程中导致的移动网络节点的切换都会让 TCP 的连接断开。

传输层协议带来的问题无法在应用层协议上得到解决,并且 TCP 因为已经存在了 40 多年,基于 TCP 协议的更新非常难以推进(因为被大量内置于操作系统内核、中间件固件以及硬件实现中),因此 Google 基于 UDP 协议推出了 QUIC 协议。

UDP 协议相较于 TCP,拥有更小的头部,简单而高效,但是不保证可靠交付,因此使用 UDP 协议同时为了确保数据传输的可靠性,需要自己维护丢包检测、数据确认、拥塞控制、重传等等一系列基础设施。

QUIC 主要特性

多路复用

HTTP/2.0 使得一个 TCP 连接能够顺序传输多个文件,再通过 SPDY 协议实现请求的并发以及优先级控制,但是终归会受到头部拥塞的限制。

而 QUIC 是基于 UDP 的,在传输层层面并没有固定的连接,可以根据需要开辟任意逻辑链路。QUIC 一次建立一个 Connection,一个 Connection 下包含多个 Stream 流(每个 stream 独自维护一个逻辑连接,因为 UDP 层面上是无连接的),每个流对应一个文件传输,并将不同的 Stream 中的数据交付给不同的上层应用。QUIC 的一个 Connection 对应多个 Stream,Stream 之间相互独立,因此任意一条链路断开都不会导致其他数据阻塞。

协议头部

QUIC 是基于 UDP 的,所以最外层是 UDP 头部(单位为 Bit)

内部是 QUIC Connection 头部和每个 Stream 的 Frame 头部(单位为 Bit)

具体每个头部字段含义和标志位过于机械和繁杂,有兴趣可以直接查看原文 https://datatracker.ietf.org/doc/html/rfc9000#section-12

  • Flags: 用于表示 Connection ID 长度、Packet Number 长度等信息;

  • Connection ID:客户端选择的无符号 64 位统计随机数,该数字是连接的标识符。由于 QUIC 的连接被设计为,即使客户端漫游,连接依然保持建立状态,因而 IP 4 元组(源 IP,源端口,目标 IP,目标端口)可能不足以标识连接。对每个传输方向,当 4 元组足以标识连接时,连接 ID 可以省略。

  • QUIC Version:QUIC 协议的版本号,32 位的可选字段。

  • Diversification nonce:这是服务端用于生成会话密钥的字段,仅存于服务端 -> 客户端的请求中。一旦前向保密连接得到建立,后续就不会再包含这个字段了,简单理解就是只在服务端 -> 客户端的握手请求中才会使用。(因此 QUIC 工作组也推进 TLS 的后续标准将这个字段整合进 TLS1.3 的头部中,而不存在于 QUIC 中)

  • Packet Number:长度取决于 Public Flag 中 Bit4 及 Bit5 两位的值,最大长度 6 字节。发送端在每个普通报文中设置 Packet Number。发送端发送的第一个包的序列号是 1,随后的数据包中的序列号的都大于前一个包中的序列号;

  • Stream ID:用于标识当前数据流属于哪个资源请求;

  • Offset:标识当前数据包在当前 Stream ID 中的偏移量。

数据流控制

QUIC 提供了两种层面上的数据流控制方案:

  • Stream 流量控制,通过限制在任何 stream 上可以发送的最大绝对字节偏移量,防止单个 stream 消耗连接(connection)的全部接收缓冲。

  • Connection 流量控制,通过限制所有STREAM帧的数据总字节数,防止发送方超过接收方的连接缓冲容量。

Stream 控制

  • QUIC 的 Stream 流基于 Stream ID+Offset 进行包确认,流量控制需要保证所发送的所有包 offset 小于最大绝对字节偏移量 maximum absolute byte offset 该值是基于当前已经提交的字节偏移量(offset of data consumed) 而进行确定的,QUIC 会把连续的已确认的 offset 数据向上层应用提交。QUIC 支持乱序确认,但本身也是按序(offset 顺序)发送数据包。

  • QUIC 利用 ack frame 来进行数据包的确认,来保证可靠传输。一个 ack frame 只包含多个确认信息,没有正文。

  • 如果数据包 N 超时,发送端将超时数据包 N 重新设置编号 M(即下一个顺序的数据包编号) 后发送给接收端。

  • 在一个数据包发生超时后,其余的已经发送的数据包依旧可以基于 Offset 得到确认,避免了 TCP 利用 SACK 才能解决的重传问题。

💡 其实 QUIC 的乱序确认设计思想并不新鲜,大量网络视频流就是通过类似的基于 UDP 的 RUDP、RTP、UDT 等协议来实现快速可靠传输的。他们同样支持乱序确认,所以就会导致这样的观看体验:明明进度条显示还有一段缓存,但是画面就是卡着不动了,如果跳过的话视频又能够播放了。

  1. 如图所示,当前缓冲区大小为 8,QUIC 按序(offset 顺序)发送 29-36 的数据包:

  1. 31、32、34 数据包先到达,基于 offset 被优先乱序确认,但 30 数据包没有确认,所以当前已提交的字节偏移量不变,缓存区不变。

  1. 30 到达并确认,缓存区收缩到阈值,接收方发送 MAX_STREAM_DATA frame(协商缓存大小的特定帧)给发送方,请求增长最大绝对字节偏移量。

  1. 协商完毕后最大绝对字节偏移量右移,缓存区变大,同时发送方发现数据包 33 超时

  1. 发送方将超时数据包重新编号为 42 继续发送

以上就是最基本的数据包发送 - 接收过程,控制数据发送的唯一限制就是最大绝对字节偏移量,该值是接收方基于当前已经提交的偏移量(连续已确认并向上层应用提交的数据包 offset)和发送方协商得出。

Connection 控制

除了 Stream 层面的数据流控制之外,QUIC 还提供了 Connection 层面的总体缓存大小控制,Connection 具有总体的缓冲区大小限制,并且可以为其中的各个 stream 动态分配缓冲区大小,在总体缓冲区大小不变的情况下优先向速度更快的 stream 倾斜(并不是平均分配)。

如图所示,Connection 具有传输字节上限,即 Stream1、2、3 的 Maximum Offset 之和不得超过该上限,QUIC 会根据网络情况为各个 Stream 分配不同的偏移量,并且随着传输的进行,接收方会发送 MAX_DATA frame 通知发送方提高 Connection 总体传输字节分配上限,并在 Stream 连接中通过 MAX_STREAM_DATA frame 为各个 Stream 分配更多的缓存。

快速握手与加密传输

QUIC 在握手过程中使用 Diffie-Hellman 算法协商初始密钥,初始情况下服务器存储的配置参数如下:

  1. Server Config:一个服务器配置文件,包括服务器端的 Diffie-Hellman 算法的长期公钥 A 以及两个固定质数 g 和 p

  2. Certificate Chain:用来对服务器进行认证的信任链证书

  3. Signature of the Server Config:Server Config 的签名并用信任链的叶子证书的私钥加密

  4. Source-Address Token:一个经过身份验证的加密块,包含客户端可见的 IP 地址和服务器的时间戳。

这些参数会周期性的更新。

Diffie-Hellman 算法的基本原理

Diffie-Hellman 并不是加密算法,而是密钥的一种交换技术,可以通过该算法在双方互不知情的情况下建立加密通讯

假设 Alice 为服务器,Bob 为客户端

  • Alice 和 Bob 都知道两个素数(g、p)的存在

  • Alice 随机选择 a 作为 private key,Bob 随机选择 b 作为 private key

于是,双方都有了一个共享密钥 (初始密钥)K。简单理解,a、b 就相当于密钥,A、B 就相当于公钥。

随后再利用这个初始密钥商定会话密钥,之后就一直用会话密钥沟通了。

密钥交换过程

QUIC 首次连接需要 1RTT,具体过程如下:

step1: 客户端发送 Inchoate Client Hello 消息(CHLO)请求建立连接。

step2: 服务器根据一组质数 p 以及其原根 g 和 a(长期私钥)算出 A(长期公钥),将 Apg(通过 CA 证书私钥加密后)放在 serverConfig 里面,发到 Rejection 消息(REJ)到客户端;

服务器一开始不直接使用随机生成的短期密钥的原因就是因为客户端可以缓存下服务端的长期公钥,这样在下一次连接的时候客户端就可以直接使用这个长期公钥实现 0-RTT 握手并直接发送加密数据

setp3&4: 客户端在接收到 REJ 消息后,会随机选择一个数 b(短期密钥),并用 CA 证书获取的公钥解密出 serverConfig 里面的 p、A 和 b 就可以算出初始密钥 K,并将 B(Complete client hello 消息)和用初始密钥 K 加密的 Data 数据发到服务器。

step5: 服务器收到客户端发来的公开数 B,再利用 p、g 计算得到同样的初始秘钥 K,来解密客户端发来的数据。这时会利用其他加密算法随机生成此次会话密钥 K' ,再通过初始密钥 K 加密 K'发送给客户端 (SHLO)(每次会话都是用随机密钥,并且服务器会定期更新 a 和 A,实际上这就是为了保证前向安全性)

在密码学中,前向保密(Forward Secrecy)是密码学中通讯协议的安全属性,指的是当前使用的主密钥泄漏不会导致过去的会话密钥泄漏。

step6: 客户端收到 SHLO 后利用初始密钥 K 解出会话密钥 K',二者后续的会话都使用 K'加密。

连接迁移

TCP 的连接标识是通过 “源 IP + 源 Port + 目标 IP + 目标 Port + 传输层协议(TCP)” 组成的唯一五元组,一旦其中一个参数发生变化,则需要重新创建新的 TCP 连接。

  • 比如 wifi 和 5g 网络切换

  • 服务数据节点切换

都会造成 TCP 断线,需要客户端上层应用重新发送请求建立连接(又一次进行握手)

QUIC 连接不再以 IP 及端口四元组标识,而是以一个服务端产生的 64 位的随机数作为 ID 来标识,这样就算 IP 或者端口发生变化时,只要 ID 不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。(当然如果 UDP 和 IP 协议所包含的源 IP + 源 Port + 目标 IP + 目标 Port 四元组已经能够标识链接的唯一性的话,connection 头部是可忽略的)

连接迁移的简化流程(实际情况更为复杂):

  1. 连接迁移之前,客户端的 IP 1,使用非探测包(Non-probing Packet)和服务端进行通信。

  2. 客户端的 IP 变成 2,它继续发送非探测包维持通信,将连接迁移到新的地址。

  3. 服务端收到包后在新路径启动路径验证 [1],验证新路径的可达性,以及客户端对其新 IP 地址的所有权。

  4. 服务端发送包含PATH_CHALLENGE帧的探测包(Probing Packet),PATH_CHALLENGE帧里面包含一个不可预测的随机值。

  5. 客户端在PATH_RESPONSE帧里面包含前一步PATH_CHALLENGE接收到的随机值,响应探测包(Probing Packet)。

  6. 服务端接收到客户端发送的的PATH_RESPONSE ,验证 payload 里面的值是否正确。

  7. 随后客户端也会对服务端进行路径验证保证双向通信。

丢包检测

TCP 传输的数据只包括校验码,并没有增加纠错码等冗余数据,如果出现部分数据丢失或损坏,只能重新发送该数据包。

QUIC 引入了前向冗余纠错码(FEC: Fowrard Error Correcting),如果接收端出现少量(不超过 FEC 的纠错能力)的丢包或错包,可以借助冗余纠错码恢复丢失或损坏的数据包,这就不需要再重传该数据包了,降低了丢包重传概率,自然就减少了拥塞控制机制的触发次数,可以维持较高的网络利用效率。因此需要根据当前网络状况设置一定比率的冗余数据,就可以带来网络利用率的提升。

此外由于 QUIC 采用单向递增的 Packet Number 来标识数据包,所以不像 TCP 会因为超时重传的同样序列的数据包而和原数据包重叠,造成 RTT 测量的不准确,进而导致 RTO(Retransmission Time Out:重传超时时间)的不准确。

TCP 的 RTT 计算

TCP 对于此问题也是非常头疼,于是也不断进行改进,比如

  • 忽略重传,不把重传的 RTT 做采样, 但是当网络波动产生大延时,所有的包都需要重传而此时 RTO 又不会被更新,导致数据包超时时间估算不准确。

  • 通过各种参数修正的计算方法:

    QUIC 的 RTT 计算

  • 首先计算平滑平滑 RTT(Smooth RTT)

  • 计算平滑 RTT 和真实的差距(加权移动平均)

  • 再经过各种修正最终得出 RTO:

  • 在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4 —— nobody knows why, it just works…

QUIC 的包号不会重复,重传的包采用了新的 Packet Number,因此不会产生 RTT 歧义问题

因此 QUIC 对于 RTT 的计算更为准确,预估的超时时间能够有效防止更多的重传请求被错误地发送回发送端。同时也给予了 QUIC 网络更为快速的反应时间,及时通知发送方重传数据包。

自定义拥塞控制

QUIC 的传输控制不再依赖内核的拥塞控制算法,而是实现在应用层上,这意味着我们根据不同的业务场景,实现和配置不同的拥塞控制算法以及参数。比如 BRR 或者 Cubic,如果有兴趣可以自行查阅相关算法资料。

在 HTTP/3 上的应用

  1. wifi 和移动网络无缝切换

  2. 更强的网络安全性(前向安全 + 全载荷加密)

  3. 在慢网情况下更高的传输速率

QUIC 离我们并不遥远

QUIC 早在 2012 年就已经开始试验性部署,关于其详细草案在 2015 年向 IETF 提出,终于在 2021 年五月被接受并于 RFC9000 中标准化。

chrome://flags/#enable-quic 在 chrome 浏览器中可以选择是否开启 QUIC 实验性功能,如果服务端支持 QUIC 协议,就会启用该协议(大部分都是 Google 的服务器)。

推荐一个插件可以查看当前网页支持的连接类型:HTTP/2 and SPDY indicator:

https://chrome.google.com/webstore/detail/http2-and-spdy-indicator/mpbpobfflnpcgagjijhmgnchggcjblin

性能参考(数据来源:腾讯 PCG 研发部)

https://mp.weixin.qq.com/s/DHvvp6EUR5tDffJqzVir0A

60kb 主页面资源加载速度(单位:毫秒)

弱网环境下的表现

不同丢包率下的下载耗时

从总体上来看,QUIC 在网络环境良好的情况下对于当前 HTTP2 的提升有限,尤其是首次 1-RTT 握手的总体时间消耗提升只有 15% 左右,但是在后续有缓存的情况下建立连接的速度就会快很多,首次响应时间将会大大缩小。

此外在弱网环境下,尤其是丢包率高的情况下 QUIC 对于性能提升十分惊人,良好的 RTO 估算机制使得超时重发的估算变得更为精确。同时多个逻辑连接使得文件与文件之间的传输互不干扰阻塞,加上更加轻量的头部和简单高效的握手方式,因此能够在弱网环境下取得更为强大的表现。

总结

随着网络基础设施的提升,UDP 的传输准确率也得到了很大的提升,而 TCP 却因为 20~60 字节的头部以及可能的头部拥塞导致一定的效率降低,但是 TCP 协议已经被大量内置于操作系统内核中,因此只能利用 UDP 进行定制化。虽然 QUIC 可能会在小页面的性能不如 TCP,但随着前端日益复杂化,资源量不断增大的情况下,使用 QUIC 替换 TCP 将能够显著提升传输速率。

放弃 TCP 而使用基于 UDP 的 QUIC,有点类似早期 x86cpu 内置的 tss 硬件切换不好用,linux 系统内核直接使用软件控制进程上下文切换。

参考文献

【HTTP/2 与 HTTP/3 的新特性】https://blog.csdn.net/howgod/article/details/102597450

【QUIC 协议原理浅解】https://www.163.com/dy/article/G5D1ETVH0518R7MO.html

【QUIC 加密握手中共享密钥算法】https://blog.csdn.net/chuanglan/article/details/85106706

【QUIC 流量控制】:https://zhuanlan.zhihu.com/p/337175711

【QUIC 加密传输和握手】https://zhuanlan.zhihu.com/p/301505712

【TCP 乱序缓存和重传的改进方式】https://blog.csdn.net/cws1214/article/details/52430554

【科普:QUIC 协议原理分析】https://zhuanlan.zhihu.com/p/32553477

【rfc9000】https://datatracker.ietf.org/doc/html/rfc9000

参考资料

[1]

路径验证: _https://zhuanlan.zhihu.com/p/290694322_