如果你的App,在不需要任何修改的情况下就能提升15%以上的访问速度。特别是弱网络的时候能够提升20%以上的访问速度。如果你的App,在频繁切换4G和WIFI网络的情况下,不会断线,不需要重连,用户无任何感知。如果你的App,既需要TLS的安全,也想实现HTTP2多路复用的强大。如果你刚刚才听说HTTP2是下一代互联网协议,如果你刚刚才关注到TLS1.3是一个革命性具有里程碑意义的协议,但是这两个协议却一直在被另一个更新兴的协议所影响和挑战。如果这个新兴的协议,它的名字就叫做”快”,并且正在标准化为新一代的互联网传输协议。你愿意花一点点时间了解这个协议吗?你愿意投入精力去研究这个协议吗?你愿意全力推动业务来使用这个协议吗?
QUIC是什么?
QUIC (Quick UDP Internet Connection) 是谷歌推出的一套基于UDP的传输协议,它实现了TCP+HTTPS+HTTP/2的功能,目的是保证可靠性的同时降低网络延迟。因为UDP是一个简单传输协议,基于UDP可以摆脱TCP传输确认、重传慢启动等因素,建立安全连接只需要一个往返时间,它还实现了HTTP/2多路复用、头部压缩等功能。
QUIC协议是一系列协议的集合,主要包括:
- 传输协议(Transport)
- 丢包检测与拥塞控制(Recovery)
- 安全传输协议(TLS)
- HTTP3协议
- HTTP头部压缩协议(QPACK)
- 负载均衡协议(Load Balance)
众所周知UDP比TCP传输速度快,TCP是可靠协议,但是代价是双方确认数据而衍生的一系列消耗。其次TCP是系统内核实现的,如果升级TCP协议,就得让用户升级系统,这个的门槛比较高,而QUIC在UDP基础上由客户端自由发挥,只要有服务器能对接就可以。
Quic相比现在广泛应用的http2+tcp+tls协议有如下优势:
- 减少了TCP三次握手及TLS握手时间
- 改进的拥塞控制
- 避免队头阻塞的多路复用
- 连接迁移
- 前向冗余纠错
HTTP协议发展
HTTP历史进程
- HTTP0.9(1991年)只支持get方法不支持请求头;
- HTTP1.0(1996年)基本成型,支持请求头、富文本、状态码、缓存、连接无法复用;
- HTTP1.1(1999年)支持连接复用、分块发送、断点续传;
- HTTP2.0(2015年)二进制分帧传输、多路复用、头部压缩、服务器推送等;
- HTTP3.0(2018年)QUIC于2013年实现;2018年10月,IETF的HTTP工作组和QUIC工作组共同决定将QUIC上的HTTP映射称为”HTTP/3″,以提前使其成为全球标准。
HTTP1.0和HTTP1.1
- 队头阻塞:下个请求必须在前一个请求返回后才能发出,导致带宽无法被充分利用,后续请求被阻塞(HTTP1.1尝试使用流水线(Pipelining)技术,但先天FIFO(先进先出)机制导致当前请求的执行依赖于上一个请求执行的完成,容易引起队头阻塞,并没有从根本上解决问题);
- 协议开销大:header里携带的内容过大,且不能压缩,增加了传输的成本;
- 单向请求:只能单向请求,客户端请求什么,服务器返回什么;
- HTTP1.0和HTTP1.1的区别:
- HTTP1.0:仅支持保持短暂的TCP连接(连接无法复用);不支持断点续传;前一个请求响应到达之后下一个请求才能发送,存在队头阻塞。
- HTTP1.1:默认支持长连接(请求可复用TCP连接);支持断点续传(通过在Header设置参数);优化了缓存控制策略;管道化,可以一次发送多个请求,但是响应仍是顺序返回,仍然无法解决队头阻塞的问题;新增错误状态码通知;请求消息和响应消息都支持Host头域
HTTP2
解决HTTP1的一些问题,但是解决不了底层TCP协议层面上的队头阻塞问题。
- 二进制传输:二进制格式传输数据解析起来比文本更高效;
- 多路复用:重新定义底层http语义映射,允许同一个连接上使用请求和响应双向数据流。同一域名只需占用一个TCP连接,通过数据流(Stream)以帧为基本协议单位,避免了因频繁创建连接产生的延迟,减少了内存消耗,提升了使用性能,并行请求,且慢的请求或先发送的请求不会阻塞其他请求的返回;
- Header压缩:减少请求中的冗余数据,降低开销;
- 服务端可以主动推送:提前给客户端推送必要的资源,这样就可以相对减少一点延迟时间;
- 流优先级:数据传输优先级可控,使网站可以实现更灵活和强大的页面控制;
- 可重置:能在不中断TCP连接的情况下停止数据的发送。
缺点:HTTP2中,多个请求在一个TCP管道中的,出现了丢包时,HTTP2的表现反倒不如HTTP1.1。因为TCP为了保证可靠传输,有个特别的”丢包重传”机制,丢失的包必须要等待重新传输确认,HTTP2出现丢包时,整个TCP都要开始等待重传,那么就会阻塞该TCP连接中的所有请求。而对于HTTP1.1来说,可以开启多个TCP连接,出现这种情况反到只会影响其中一个连接,剩余的TCP连接还可以正常传输数据。
HTTP3——HTTP Over QUIC
HTTP是建立在TCP协议之上,所有HTTP协议的瓶颈及其优化技巧都是基于TCP协议本身的特性,HTTP2虽然实现了多路复用,底层TCP协议层面上的问题并没有解决(HTTP2.0同一域名下只需要使用一个TCP连接。但是如果这个连接出现了丢包,会导致整个TCP都要开始等待重传,后面的所有数据都被阻塞了),而HTTP3的QUIC就是为解决HTTP2的TCP问题而生。
QUIC/HTTP3的特点:
- 有序传输:用stream的概念,确保数据有序。不同的stream或者packet,不保证有序到达。
- 报文压缩,提高荷载比率:比如QUIC引入了variable-length integer encoding。又比如引入QPACK进行头部压缩
- 可靠传输:支持丢包检测和重传
- 安全传输:TLS1.3安全协议
为什么需要QUIC?
随着移动互联网快速发展以及物联网的逐步兴起,网络交互的场景越来越丰富,网络传输的内容也越来越庞大,用户对网络传输效率和WEB响应速度的要求也越来越高。
一方面是历史悠久使用广泛的古老协议,另外一方面用户的使用场景对传输性能的要求又越来越高。如下几个由来已久的问题和矛盾就变得越来越突出。
中间设备的僵化
可能是TCP协议使用得太久,也非常可靠。所以我们很多中间设备,包括防火墙、NAT网关,整流器等出现了一些约定俗成的动作。
比如有些防火墙只允许通过80和443,不放通其他端口。NAT网关在转换网络地址时重写传输层的头部,有可能导致双方无法使用新的传输格式。整流器和中间代理有时候出于安全的需要,会删除一些它们不认识的选项字段。
TCP协议本来是支持端口、选项及特性的增加和修改。但是由于TCP协议和知名端口及选项使用的历史太悠久,中间设备已经依赖于这些潜规则,所以对这些内容的修改很容易遭到中间环节的干扰而失败。
而这些干扰,也导致很多在TCP协议上的优化变得小心谨慎,步履维艰。
依赖于操作系统的实现导致协议僵化
TCP是由操作系统在内核西方栈层面实现的,应用程序只能使用,不能直接修改。虽然应用程序的更新迭代非常快速和简单。但是TCP的迭代却非常缓慢,原因就是操作系统升级很麻烦。
现在移动终端更加流行,但是移动端部分用户的操作系统升级依然可能滞后数年时间。PC端的系统升级滞后得更加严重,windows xp现在还有大量用户在使用,尽管它已经存在快20年。
服务端系统不依赖用户升级,但是由于操作系统升级涉及到底层软件和运行库的更新,所以也比较保守和缓慢。
这也就意味着即使TCP有比较好的特性更新,也很难快速推广。比如TCP FastOpen。它虽然2013年就被提出了,但是Windows很多系统版本依然不支持它。
QUIC的关键特性
连接迁移
tcp的连接重连之痛
一条TCP连接是由四元组标识的(源IP,源端口,目的IP,目的端口)。什么叫连接迁移呢?就是当其中任何一个元素发生变化时,这条连接依然维持着,能够保持业务逻辑不中断。当然这里面主要关注的是客户端的变化,因为客户端不可控并且网络环境经常发生变化,而服务端的IP和端口一般都是固定的。
比如大家使用手机在WIFI和4G移动网络切换时,客户端的IP肯定会发生变化,需要重新建立和服务端的TCP连接。又比如大家使用公共NAT出口时,有些连接竞争时需要重新绑定端口,导致客户端的端口发生变化,同样需要重新建立TCP连接。所以从TCP连接的角度来讲,这个问题是无解的。
基于UDP的QUIC的连接迁移实现
那QUIC是如何做到连接迁移呢?很简单,QUIC是基于UDP协议的,任何一条QUIC连接不再以IP及端口四元组标识,而是以一个64位的随机数作为ID来标识,这样就算IP或者端口发生变化时,只要ID不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。由于这个ID是客户端随机产生的,并且长度有64位,所以冲突概率非常低。
低连接延时
0RTT建连可以说是QUIC相比HTTP2最大的性能优势。那什么是0RTT建连呢?这里面有两层含义。
- 传输层0RTT就能建立连接。
- 加密层0RTT就能建立加密连接。
比如上图左边是HTTPS的一次完全握手的建连过程,需要3个RTT。就算是Session Resumption,也需要至少2个RTT。
而QUIC呢?由于建立在UDP的基础上,同时又实现了0RTT的安全握手,所以在大部分情况下,只需要0个RTT就能实现数据发送,在实现前向加密的基础上,并且0RTT的成功率相比TLS的Sesison Ticket要高很多。
TLS的连接时延问题
以一次简单的浏览器访问为例,在地址栏中输入https://www.abc.com,实际会产生以下动作:
- DNS递归查询abc.com,获取地址解析的对应IP;
- TCP握手,我们熟悉的TCP三次握手需要需要1个RTT;
- TLS握手,以目前应用最广泛的TLS1.2而言,需要2个RTT。对于非首次建连,可以选择启用会话重用,则可缩小握手时间到1个RTT;
- HTTP业务数据交互,假设com的数据在一次交互就能取回来。那么业务数据的交互需要1个RTT;经过上面的过程分析可知,要完成一次简短的HTTPS业务数据交互,需要经历:新连接4RTT+DNS;会话重用 3RTT+DNS。
所以,对于数据量小的请求而言,单一次的请求握手就占用了大量的时间,对于用户体验的影响非常大。同时,在用户网络不佳的情况下,RTT延时会变得较高,极其影响用户体验。
下图对比了TLS各版本与场景下的延时对比:
从对比我们可以看到,即使用上了TLS1.3,精简了握手过程,最快能做到0-RTT握手(首次是1-RTT);但是对用户感知而言,还要加上1RTT的TCP握手开销。Google有提出Fast open的方案来使得TCP非首次握手就能附带用户数据,但是由于TCP实现僵化,无法升级应用,相关RFC到现今都是experimental状态。这种分层设计带来的延时,有没有办法进一步降低呢?QUIC通过合并加密与连接管理解决了这个问题,我们来看看其是如何实现真正意义上的0-RTT的握手,让与server进行第一个数据包的交互就能带上用户数据。
真·0-RTT的QUIC握手
QUIC由于基于UDP,无需TCP连接,在最好情况下,短连接下QUIC可以做到0RTT开启数据传输。而基于TCP的HTTPS,即使在最好的TLS1.3的early data下仍然需要1RTT开启数据传输。而对于目前线上常见的TLS1.2完全握手的情况,则需要3RTT开启数据传输。对于RTT敏感的业务,QUIC可以有效的降低连接建立延迟。
究其原因一方面是TCP和TLS分层设计导致的:分层的设计需要每个逻辑层次分别建立自己的连接状态。另一方面是TLS的握手阶段复杂的密钥协商机制导致的。要降低建连耗时,需要从这两方面着手。
QUIC具体握手过程如下:
- 客户端判断本地是否已有服务器的全部配置参数(证书配置信息),如果有则直接跳转到(5),否则继续;
- 客户端向服务器发送inchoate client hello(CHLO)消息,请求服务器传输配置参数;
- 服务器收到CHLO,回复rejection(REJ)消息,其中包含服务器的部分配置参数;
- 客户端收到REJ,提取并存储服务器配置参数,跳回到(1);
- 客户端向服务器发送full client hello消息,开始正式握手,消息中包括客户端选择的公开数。此时客户端根据获取的服务器配置参数和自己选择的公开数,可以计算出初始密钥K1;
- 服务器收到full client hello,如果不同意连接就回复REJ,同(3);如果同意连接,根据客户端的公开数计算出初始密钥K1,回复server hello(SHLO)消息,SHLO用初始密钥K1加密,并且其中包含服务器选择的一个临时公开数;
- 客户端收到服务器的回复,如果是REJ则情况同(4);如果是SHLO,则尝试用初始密钥K1解密,提取出临时公开数;
- 客户端和服务器根据临时公开数和初始密钥K1,各自基于SHA-256算法推导出会话密钥K2;
- 双方更换为使用会话密钥K2通信,初始密钥K1此时已无用,QUIC握手过程完毕。之后会话密钥K2更新的流程与以上过程类似,只是数据包中的某些字段略有不同。
可自定义的拥塞控制
Quic使用可插拔的拥塞控制,相较于TCP,它能提供更丰富的拥塞控制信息。比如对于每一个包,不管是原始包还是重传包,都带有一个新的序列号(seq),这使得Quic能够区分ACK是重传包还是原始包,从而避免了TCP重传模糊的问题。Quic同时还带有收到数据包与发出ACK之间的时延信息。这些信息能够帮助更精确的计算rtt。此外,Quic的ACK Frame支持256个NACK区间,相比于TCP的SACK (Selective Acknowledgment)更弹性化,更丰富的信息会让client和server哪些包已经被对方收到。
QUIC的传输控制不再依赖内核的拥塞控制算法,而是实现在应用层上,这意味着我们根据不同的业务场景,实现和配置不同的拥塞控制算法以及参数。GOOGLE提出的BBR拥塞控制算法与CUBIC是思路完全不一样的算法,在弱网和一定丢包场景,BBR比CUBIC更不敏感,性能也更好。在QUIC下我们可以根据业务随意指定拥塞控制算法和参数,甚至同一个业务的不同连接也可以使用不同的拥塞控制算法。
TCP的拥塞控制实际上包含了四个算法:慢启动,拥塞避免,快速重传,快速恢复。
QUIC协议当前默认使用了TCP协议的Cubic拥塞控制算法,同时也支持CubicBytes, Reno, RenoBytes, BBR, PCC等拥塞控制算法。
从拥塞算法本身来看,QUIC只是按照TCP协议重新实现了一遍,那么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,就认为它一定被正确接收,减少了这种干扰。
更多的Ack块
TCP的Sack选项能够告诉发送方已经接收到的连续Segment的范围,方便发送方进行选择性重传。
由于TCP头部最大只有60个字节,标准头部占用了20字节,所以Tcp Option最大长度只有40字节,再加上Tcp Timestamp option占用了10个字节[25],所以留给Sack选项的只有30个字节。
每一个Sack Block的长度是8个,加上Sack Option头部2个字节,也就意味着Tcp Sack Option最大只能提供3个Block。
但是Quic Ack Frame可以同时提供256个Ack Block,在丢包率比较高的网络下,更多的Sack Block可以提升网络的恢复速度,减少重传量。
Ack Delay时间
Tcp的Timestamp选项存在一个问题[25],它只是回显了发送方的时间戳,但是没有计算接收端接收到segment到发送Ack该segment的时间。这个时间可以简称为Ack Delay。
这样就会导致RTT计算误差。如下图:
可以认为TCP的RTT计算:$RTT=timestamp2-timestamp1$
而Quic计算如下:$RTT=timestamp2-timestamp1-AckDelay$
当然RTT的具体计算没有这么简单,需要采样,参考历史数值进行平滑计算,参考如下公式:
$$SRTT=SRTT+\alpha(RTT-SRTT)$$
$$RTO=\mu*SRTT+ \partial*DevRTT$$
无队头阻塞
TCP的队头阻塞问题虽然HTTP2实现了多路复用,但是因为其基于面向字节流的TCP,因此一旦丢包,将会影响多路复用下的所有请求流。QUIC基于UDP,在设计上就解决了队头阻塞问题。
TCP队头阻塞的主要原因是数据包超时确认或丢失阻塞了当前窗口向右滑动,我们最容易想到的解决队头阻塞的方案是不让超时确认或丢失的数据包将当前窗口阻塞在原地。QUIC也正是采用上述方案来解决TCP队头阻塞问题的。
TCP为了保证可靠性,使用了基于字节序号的Sequence Number及Ack来确认消息的有序到达。
如上图,应用层可以顺利读取stream1中的内容,但由于stream2中的第三个segment发生了丢包,TCP为了保证数据的可靠性,需要发送端重传第3个segment才能通知应用层读取接下去的数据。所以即使stream3 stream4的内容已顺利抵达,应用层仍然无法读取,只能等待stream2中丢失的包进行重传。
在弱网环境下,HTTP2的队头阻塞问题在用户体验上极为糟糕。
QUIC的无队头阻塞解决方案
QUIC同样是一个可靠的协议,它使用PacketNumber代替了TCP的SequenceNumber,并且每个PacketNumber都严格递增,也就是说就算PacketN丢失了,重传的PacketN的PacketNumber已经不是N,而是一个比N大的值,比如PacketN+M。
QUIC使用的PacketNumber单调递增的设计,可以让数据包不再像TCP那样必须有序确认,QUIC支持乱序确认,当数据包PacketN丢失后,只要有新的已接收数据包确认,当前窗口就会继续向右滑动。待发送端获知数据包PacketN丢失后,会将需要重传的数据包放到待发送队列,重新编号比如数据包PacketN+M后重新发送给接收端,对重传数据包的处理跟发送新的数据包类似,这样就不会因为丢包重传将当前窗口阻塞在原地,从而解决了队头阻塞问题。那么,既然重传数据包的PacketN+M与丢失数据包的PacketN编号并不一致,我们怎么确定这两个数据包的内容一样呢?
QUIC使用StreamID来标识当前数据流属于哪个资源请求,这同时也是数据包多路复用传输到接收端后能正常组装的依据。重传的数据包PacketN+M和丢失的数据包PacketN单靠StreamID的比对一致仍然不能判断两个数据包内容一致,还需要再新增一个字段StreamOffset,标识当前数据包在当前StreamID中的字节偏移量。
有了StreamOffset字段信息,属于同一个StreamID的数据包也可以乱序传输了(HTTP/2中仅靠StreamID标识,要求同属于一个StreamID的数据帧必须有序传输),通过两个数据包的StreamID与StreamOffset都一致,就说明这两个数据包的内容一致。
QUIC协议组成
QUIC是在UDP的基础上,构建类似TCP的可靠传输协议。HTTP3则在QUIC基础上完成HTTP事务。网络总是分层讨论的,在此我们由低向上分层讨论quic协议:
- UDP层: 在UDP层传输的是UDP报文,此处关注的是UDP报文荷载内容是什么,以及如何高效发送UDP报文
- Connection层: Connection通过CID来确认唯一连接,connection对packet进行可靠传输和安全传输
- Stream层: Stream在相应的Connection中,通过StreamID进行唯一流确认,stream对stream frame进行传输管理
- HTTP3层:HTTP3建立在QUIC Stream的基础上,相对于1和HTTP2.0,HTTP3提供更有效率的HTTP事务传输。HTTP3中通过QPACK协议进行头部压缩
UDP层
本章节讨论QUIC发包的UDP部分的相关问题。
UDP荷载大小
荷载大小受限于3个对象:QUIC协议规定;路径MTU;终端接受能力
- QUIC不能运行在不支持1200字节的单个UDP传输网络路径上 QUIC有规定initial包大小不得小于1200,如果数据本身不足1200(比如initial ack),那么需要用padding方式至少填充到1200字节
- QUIC不希望出现IP层分片现象 本要求意味着udp交给ip层的数据不会大于1个MTU,假设mtu为1500,ipv4场景下,udp的荷载上限为1472字节(1500-20-8),ipv6下,udp荷载上限为1452(1500-40-8)。QUIC建议使用PMTUD以及DPLPMTUD进行mtu探测。在实战中,我们建议设置IPv6的MTU为1280,大于这个值,某些网络会存在丢包现象。
- 终端能接受transport paraments的max_udp_payload_size(0x03)的是终端接受单个udp包大小的能力,发送端应当遵从这一约定。
UDP荷载内容
- UDP荷载内容即为quic协议中的packet。协议规定,如果不超过荷载大小的限制,那么多个packet可以组成一个udp报文发出去。在quic实现中,如果每个udp报文只包含一个quic packet,会更容易出现乱序问题。
高效发UDP包
和tcp不同,quic需要在应用层就完成udp数据组装,且每个udp报文不大于1个mtu,如果不加以优化,比如每个包直接用sendto/sendmsg发送,势必会造成大量的系统调用,影响吞吐
- 通过sendmmsg接口进行优化,sendmmsg可以将用户态的多个udp quic包通过一次系统调用发到内核态。内核态对于每个udp quic包独立作为udp包发出去
- 在)解决了系统调用次数问题,开启GSO可以进步一分包延迟到发给网卡驱动前一刻,可以进一步提高吞吐,降低CPU消耗
- 在)的基础上,现在主流网卡已经支持硬件GSO offload方案,可以进一步提高吞吐,降低cpu消耗
上面介绍的发送方式,事实上可以理解为udp burst发送方式,这带来了一个问题,拥塞控制需要pacing能力!
Connection层
在我们讨论时,可知1个udp报文里传输的其实是一个或多个quic协议定义的packet。那么在Connection这一层面,其实是以packet为单位进行管理的。一个packet到来,终端需要解析出目标ConnectionID(DCID)字段,并将该packet交给找到对应的quic connection。一个packet是由header加payload两部分组成。
connection id
不同于tcp的4元组唯一确认一条连接的方式,QUIC定义了一个和网络路由无关的ConnectionID来确认唯一连接的。这带来一个好处,可以在四元组发生变化时(比如nat rebinding或者终端网络切换wifi->4G),依然保持连接。当然,虽然连接状态依然保持,但由于路径发生变化,拥塞控制也需要能够及时调整。
packet头部IETF的quic header分为两种类型,long header, short header。其中long header有分为initial, 0rtt, handshake, retry四种类型。类型的定义可以直接参考rfc文档,此处不再赘述。
quic规定packet number始终为自增的,就算某个packet的内容为重传的frame数据,其packet number也必须自增,这相对于TCP来说,带来一个优点,能够更加精确的采集到路径的RTT属性。
packet number编解码: packet number是一个0~262-1的取值范围,quic为了节约空间,在计算packet number时,引入了unacked的概念,通过截断(只保留有效bit位)的方式,只用了1-4个字节,即可以encode/decode出正确的packet number。rfc文档中有附录详细讲解了enc/dec的过程。
packet头在安全传输中是被保护对象,这也意味着在没有ssl信息的情况下,无法使用wireshake对packet进行时序分析。中间网络设备也无法向TCP那样获得packet number进行乱序重组。
packet荷载
在对packet进行解密,且去除掉packet header后,packet的荷载里就都是frame了(至少包括1个)。
如果packet的荷载里,不包括ACK, PADDING, and CONNECTION_CLOSE这种三种类型的帧,那么这个packet则被定义为ack-eliciting,意味着对端必须对这种packet生成相应的ack通知发送方,以确保数据没有丢失。
packet的荷载里frames的类型在多达30种类型,每种类型都有自己的应用场景,如ACK Frame用于可靠传输(Recovery),Crypto用于安全传输(TLS握手),Stream Frame用于业务数据传递,MAX_DATA/DATA_BLOCKED用于流控,PING Frame可以用于mtu探测,具体描述参考rfc文档。
安全传输
QUIC的安全传输依赖TLS 1.3,而boringssl是众多quic实现的依赖库。协议对Packet的头部以及荷载均进行了保护(包括packet number)。TLS 1.3 0RTT的能力,在提供数据保护的同时,能在第一时间(服务端收到第一个请求报文时)就将Response Header发给客户端。大大降低了HTTP业务中的首包时间。为了支持0RTT,客户端需要保存PSK信息,以及部分transport parament信息。
安全传输也经常会涉及到性能问题,在目前主流的服务端,AES G由于cpu提供了硬件加速,所以性能表现最好。CHACHA20则需要更多的CPU资源。在短视频业务上,出于对首帧的要求,通常直接使用明文传输。
Transport Paramenter(TP)协商是在安全传输的握手阶段完成,除了协议规定的TP外,用户也可以扩展私有TP内容,这一特性带来了很大的便利,比如:客户端可以利用tp告知服务端进行明文传输。
可靠传输
QUIC协议是需要像TCP能够进行可靠传输,所以QUIC单独有一个rfc描述了丢包检测和拥塞控制的话题,
丢包检测:协议利用两种方式来判断丢包是否发生:一种是基于ack的检测,通过time threshold和packet threshold根据已经到达的packet,推断在此包之前发出去的包是否丢失。第二种,在失去了参考包的情况下,那么只能通过PTO的方式来推断包是否丢失。一般来说,大量被触发的应该是ACK的检测方式。如果PTO被大量触发,会影响发包效率。
拥塞控制:QUIC针对TCP协议中的一些缺陷,专门做了优化。比如始终递增的packet number,丰富的ack range,host delay计算等。同时tcp的拥塞控制需要内核态实现,而QUIC在用户态实现,这大大降低了研究高效率的可靠传输协议的门槛。Recovery协议中,描述了new Reno的实现方式。在GOOGLE chrome中,实现了cubic, bbr, bbrv2,而mvfst项目则更为丰富,包括了ccp, copa协议。
Stream层
stream是一个抽象的概念,它表达了一个有序传输的字节流,而这些字节其实就是由Stream Frame排在一起构成。在一个quic connection上,可以同时传输多条流。
Stream头部
在Quic协议里,stream分为单向流或双向流,又分为客户端发起或服务端发起。stream的不同类型定义在HTTP3中得到了充分的利用。
Stream荷载
Stream的荷载即为一系列Stream Frame,通过Stream Frame头部的Stream ID来确认单个流。
在TCP里,如果一个segment传递丢失,那么后续segment乱序到达,也不会被应用层使用,只到丢失的segment重传成功为止,因此TCP实现的HTTP2的多路复用能力受到制约。在QUIC协议中,有序的概念仅维护在单个stream中,stream之间和packet都不要求有序,假设某个packet丢失,只会影响包含在这个包里的stream,其他stream仍然可以从后续乱序到达的packet中提取到自己所需要的数据交给应用层。
HTTP3层
stream分类
在引入HTTP3后,stream的单向流类型被扩展成:控制流,Push流和其他保留类型。其中HTTP3的setting则是在控制流中传输,而HTTP数据传输是在客户端发起的双向流中,所以读者会发现,HTTP数据传输的stream id都是模4等于0的。
在引入QPACK后,单向流被进一步扩展了两个类型,encoder流,decoder流,QPACK中动态表的更新则依赖这两个流。
QPACK
QPACK的作用是头部压缩。类似HPACK,QPACK定义了静态表,动态表用于头部索引。静态表是针对常见的头部,协议预先定义的。动态表则是在该QUIC Connection服务HTTP过程中,逐渐建立的。QPACK所建立的Encoder/Decoder流是伴随用于HTTP事务的QUIC Connection生命周期。
动态表不是HTTP3能够运行的必须项,所以在某些QUIC开源项目中,并没有实现复杂的动态表功能。
在QPACK的动态表业务中,数据流,编码流,解码流3种对象共同参与,编码流和解码流负责维护动态表变化,数据流则解析出头部的索引号,去动态表中查询,得到最终的头部定义。
其他
Flow Control流控
QUIC协议引入了flow control的概念,用于表达接收端的接受能力。流控分两级,Connection级别,和Stream级别。发送端发送的数据偏移量不能超过流控的限制,如果达到限制,那么发送端应该通过DATA_BLOCKED/STREAM_DATA_BLOCKED来通知接收端。如果为了传输性能,接收端应该尽量保持限制足够大,比如达到max_data的一半时,就及时更新max_data传给发送端。如果接收端不希望太快接受数据,也可以利用流控对发送端进行约束。
QUIC版本
QUIC一开始由google主导设计开发,在chromium项目中,可以看到google quic(GQUIC)版本号被定义为Q039, Q043, Q046, Q050等。
随着IETF版本的QUIC推出,ietf quic(IQUIC)也有很多版本,如29, 30, 34(最新版)等,不同版本可能是无法互通的,比如不同版本安全传输的salt变量规定不一样。所以IQUIC引入了版本协商的功能,用于不同的客户端和服务端协商出可以互通的版本。
在实践中,还会遇到一个需求,要求一个服务能够同时服务GQUIC的不同版本,又能服务IQUIC的不同版本。这就要求服务在收取到packet后,需要对packet作出判断,分析出它属于iquic的,还是gquic的,然后进行逻辑分流。
QUIC的packet除了个别报文比如PUBLIC_RESET和CHLO,所有报文头部都是经过认证的,报文Body都是经过加密的。这样只要对QUIC报文任何修改,接收端都能够及时发现,有效地降低了安全风险。
如图所示,红色部分是Stream Frame的报文头部,有认证。绿色部分是报文内容,全部经过加密。
- Flags:用于表示ConnectionID长度、PacketNumber长度等信息;
- QUICVersion:QUIC协议的版本号,32位的可选字段。如果PublicFlag & FLAG_VERSION != 0,这个字段必填。客户端设置PublicFlag中的Bit0为1,并且填写期望的版本号。如果客户端期望的版本号服务端不支持,服务端设置PublicFlag中的Bit0为1,并且在该字段中列出服务端支持的协议版本(0或者多个),并且该字段后不能有任何报文;
- PacketNumber:长度取决于PublicFlag中Bit4及Bit5两位的值,最大长度6字节。发送端在每个普通报文中设置PacketNumber。发送端发送的第一个包的序列号是1,随后的数据包中的序列号的都大于前一个包中的序列号;
- StreamID:用于标识当前数据流属于哪个资源请求;
- Offset:标识当前数据包在当前StreamID中的字节偏移量。
ConnectionID:客户端随机选择的最大长度为64位的无符号整数。但是,长度可以协商;
QUIC报文的大小需要满足路径MTU的大小以避免被分片。当前QUIC在IPV6下的最大报文长度为1350,IPV4下的最大报文长度为1370。
QUIC应用场景
- 图片小文件:明显降低文件下载总耗时,提升效率
- 视频点播:提升首屏秒开率,降低卡顿率,提升用户观看体验
- 动态请求:适用于动态请求,提升访问速度,如网页登录、交易等交互体验提升
- 弱网环境:在丢包和网络延迟严重的情况下仍可提供可用的服务,并优化卡顿率、请求失败率、秒开率、提高连接成功率等传输指标
- 大并发连接:连接可靠性强,支持页面资源数较多、并发连接数较多情况下的访问速率提升
- 加密连接:具备安全、可靠的传输性能
参考链接: