QUIC协议剖析及性能优化
如果你的App,在不需要任何修改的情况下就能提升15%以上的访问速度。特别是弱网络的时候能够提升20%以上的访问速度。如果你的App,在频繁切换4G和WIFI网络的情况下,不会断线,不需要重连,用户无任何感知。如果你的App,既需要TLS的安全,也想实现多路复用的强大。如果你刚刚才听说HTTP2是下一代互联网协议,如果你刚刚才关注到TLS1.3是一个革命性具有里程碑意义的协议,但是这两个协议却一直在被另一个更新兴的协议所影响和挑战。如果这个新兴的协议,它的名字就叫做“快”,并且正在标准化为新一代的互联网传输协议。你愿意花一点点时间了解这个协议吗?你愿意投入精力去研究这个协议吗?你愿意全力推动业务来使用这个协议吗?这篇文章,会告诉你答案。
背景
Quic全称quick udp internet connection[1],“快速UDP互联网连接”,(和英文quick谐音,简称“快”)是由google提出的使用udp进行多路并发传输的协议,特性有点类似基于TCP和TLS协议的http2。Quic相比现在广泛应用的http2+tcp+tls协议有如下优势[2]:
- 减少了TCP三次握手及TLS握手时间。
- 改进的拥塞控制。
- 避免队头阻塞的多路复用。
- 连接迁移。
- 前向冗余纠错。
QUIC主要原理及优化
QUIC的特性大家可能听说得比较多,比如0RTT,安全等。但是关于QUIC的原理分析文章还比较少。比如为什么能实现0RTT,里面有什么风险,QUIC如何保障可靠性,工程实现方面需要注意什么问题等。
接下去我们就给大家深度剖析一下QUIC协议的主要特性。
0RTT建连
0RTT建连可以说是QUIC相比HTTP2最大的性能优势。那什么是0RTT建连呢?这里面有两层含义。
1.传输层0RTT就能建立连接。
2.加密层0RTT就能建立加密连接。
HTTPS及QUIC建连过程
比如上图左边是HTTPS的一次完全握手的建连过程,需要3个RTT。就算是Session Resumption,也需要至少2个RTT。
而QUIC呢?只需要0个RTT就能实现数据发送,并且0RTT的成功率相比TLS的Sesison Ticket要高很多。
下面我们分别介绍一下。
传输层0RTT建连
由于QUIC是构建在UDP协议[20]上,和TCP基于四元组的连接不同[19],UDP本身就没有连接的概念,第一个包就可以直接发送数据,没有RTT的延迟。
由于这是UDP协议本身的特性,比较好理解,不多做介绍。
加密层0RTT建连
TLS1.2版本之前,TLS协议至少需要一个RTT才能建立TLS连接(TLS1.3虽然支持0RTT建连[3],但由于没有正式发布,这里暂且不表)。
那QUIC是如何实现0RTT建立加密连接的呢?这里的关键有两点[4]:
客户端缓存ServerConfig。
服务端强制使用DH密钥交换算法。
什么是ServerConfig呢?简单来说就是一个服务端生成并且通过Rejection消息发送给客户端的凭证,有点类似TLS的SessionTicket[14]。
ServerConfig里包含的内容比较多,其中KEXS规定了密钥交换算法,PUBS传递了密钥交换算法的公钥。
1RTT握手
如果客户端没有收到ServerConfig消息,根本就不知道使用何种密钥交换算法,没有公钥信息,也就不可能实现0RTT握手。
所以对于全新的连接来说,至少要1RTT才能完成握手:
QUIC 1RTT 握手
其中的Rejection就会返回ServerConfig及证书等信息。在拿到ServerConfig消息后,客户端就能根据ServerConfig里面规定的算法和公钥,计算出对称密钥,然后将客户端的公钥和应用层内容发送给服务端。
服务端接收到客户端的公钥后,也能根据算法计算出对称密钥。总共经过1个RTT完成认证和密钥协商。
0RTT握手
明白了1RTT和DH算法,0RTT的实现也就非常好理解和实现了。
QUIC 0RTT握手
0RTT的前提是客户端必须已经拥有了ServerConfig,并且要结合证书验证ServerConfig消息,验证无误后就能够获取到密钥交换算法及公钥,此时再生成一个全新的密钥b,按照DH算法和参数,就能在本地计算出对称密钥Sc。
服务端收到客户端的ClientHello后,同时也包含了客户端的公钥信息,服务端也能结合之前的公钥参数计算出Ss。
0RTT的问题
加密层虽然能够实现0RTT,优势非常明显,但也引入了几个问题。
前向加密安全
为了提升0RTT的成功率,ServerConfig需要保存一个比较长的时间。在这段时间里,如果服务端密钥a(注意不是X509证书[23]的私钥)泄露,意味着此段时间里所有加密内容都能够被解密。这就是非前向加密的安全问题[17]。那如何解决这个问题呢?协议层面提供一个密钥升级的方案。
- 客户端在接收到ServerHello前,使用Initial Key进行加密,这个Key是非前向安全的。
- 服务端接收到ClientHello后,立即更新私钥a和ServerHello中的公钥,并生成全新的前向加密密钥S。
- 客户端接收到ServerHello后,立即计算生成全新的前身加密密钥S。
此后客户端和服务端都使用这个新的密钥S进行加解密,实现了前向加密安全。
这样做的好处是:
- 保障了前向安全。
- 提升0RTT的成功率。因为有前向安全,所以ServerConfig可以长时间地保存在服务端和客户端地硬盘里。相比TLS协议的Session Ticket及Session ID[15],由于不能保证前向安全,出于安全起见,默认保存在内存中,每次程序重载,都需要完全握手。这也是QUIC的0RTT成功率相比TLS Session Resumption成功率要高很多的原因。
签名计算的问题及优化
从前面的描述来看,ServerConfig非常重要。这么重要的信息自然需要签名来保证权威性和信任度。ServerConfig是如何签名的呢?使用X509证书中的私钥进行RSA签名[21]或者ECDSA签名[20]。而X509证书的RSA签名的私钥长度至少是2048位,非常消耗我们的CPU资源。
根据之前的测试数据,RSA私钥签名计算会降低90%的性能[18]。那如何优化呢?使用RSA或者ECDSA异步代理计算。核心思路也是三点:
- 算法分离。剥离私钥计算部分,不让这个过程占用本地CPU资源。
- 异步执行。算法剥离和执行异步的,上层服务不需要同步等待这个计算过程的完成。
- 并行计算。我们使用配置了专用硬件的私钥计算集群来完成私钥计算。
图12 QUIC接入架构中的计算集群就是用来处理高强度私钥计算的。
ServerConfig命中率问题及优化
ServerConfig到达服务端后,我们根据ServerConfig ID查找本地内存,如果找到了,即认为这个数据是可信的,能够完成0RTT握手。但是会有两个问题:
- 进程间ID数据无法共享。
- 多台服务器间的ID数据无法共享。
那如何提升0RTT的命中率呢?同样地,工程层面需要实现多进程共享及分布式多集群的ID共享。
图12 QUIC接入架构中的Cache集群主要就是用来提升ServerConfig命中率的。
连接迁移(Connection Migration)
一条TCP连接是由四元组标识的(源IP,源端口,目的IP,目的端口)。什么叫连接迁移呢?就是当其中任何一个元素发生变化时,这条连接依然维持着,能够保持业务逻辑不中断。当然这里面主要关注的是客户端的变化,因为客户端不可控并且网络环境经常发生变化,而服务端的IP和端口一般都是固定的。
比如大家使用手机在WIFI和4G移动网络切换时,客户端的IP肯定会发生变化,需要重新建立和服务端的TCP连接。又比如大家使用公共NAT出口时,有些连接竞争时需要重新绑定端口,导致客户端的端口发生变化,同样需要重新建立TCP连接。
针对TCP的连接变化,MPTCP[6]其实已经有了解决方案,但是由于MPTCP需要操作系统及网络协议栈支持,部署阻力非常大,目前并不适用。所以从TCP连接的角度来讲,这个问题是无解的。 那QUIC是如何做到连接迁移呢?很简单,任何一条QUIC连接不再以IP及端口四元组标识,而是以一个64位的随机数作为ID来标识,这样就算IP或者端口发生变化时,只要ID不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。由于这个ID是客户端随机产生的,并且长度有64位,所以冲突概率非常低。
那TGW服务端如何实现的呢?我们在TGW四层转发层面实现了根据ID进行哈希的负载均衡算法,保证将相同ID的QUIC请求落到相同的STGW上,在STGW上,我们又会优先根据ID进行处理。图示如下:
QUIC连接迁移
如上图所述,客户端最开始使用4G移动网络访问业务,源IP假设为IP1,整个访问流程使用蓝色线条标识。当用户进入WIFI网络时,源IP发生了变化,从IP1切换到了IP2,整个访问流程使用绿色线条标识。
由于接入的TGW有可能发生变化,但整个TGW集群统一使用QUIC Connection ID调度,只要QUIC连接的ID没有发生变化,能够将该请求调度到相同的STGW。同一台STGW保存了相同的Stream及Connection处理上下文,能够将该请求继续调度到相同的业务RS机器。
整个网络和IP切换过程,对于用户和业务来讲,没有任何感知。
改进的拥塞控制
TCP的拥塞控制实际上包含了四个算法:慢启动,拥塞避免,快速重传,快速恢复[24]。
QUIC协议当前默认使用了TCP协议的Cubic拥塞控制算法[7],同时也支持CubicBytes, Reno, RenoBytes, BBR, PCC等拥塞控制算法。
从拥塞算法本身来看,QUIC只是按照TCP协议重新实现了一遍,那么QUIC协议到底改进在哪些方面呢?主要有如下几点:
可插拔
什么叫可插拔呢?就是能够非常灵活地生效,变更和停止。体现在如下方面:
- 应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。这是一个飞跃,因为传统的TCP拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,这在产品快速迭代,网络爆炸式增长的今天,显然有点满足不了需求。
- 即使是单个应用程序的不同连接也能支持配置不同的拥塞控制。就算是一台服务器,接入的用户网络环境也千差万别,结合大数据及人工智能处理,我们能为各个用户提供不同的但又更加精准更加有效的拥塞控制。比如BBR适合,Cubic适合。
- 应用程序不需要停机和升级就能实现拥塞控制的变更,我们在服务端只需要修改一下配置,reload一下,完全不需要停止服务就能实现拥塞控制的切换。
STGW在配置层面进行了优化,我们可以针对不同业务,不同网络制式,甚至不同的RTT,使用不同的拥塞控制算法。
单调递增的Packet Number
TCP为了保证可靠性,使用了基于字节序号的Sequence Number及Ack来确认消息的有序到达。
QUIC同样是一个可靠的协议,它使用Packet Number代替了TCP 的sequence number,并且每个Packet Number都严格递增,也就是说就算Packet N丢失了,重传的Packet N的PacketNumber已经不是N,而是一个比N大的值。而TCP呢,重传segment的sequence number和原始的segment的Sequence Number保持不变,也正是由于这个特性,引入了Tcp重传的歧义问题。
Tcp 重传歧义性
如上图所示,超时事件RTO发生后,客户端发起重传,然后接收到了Ack数据。由于序列号一样,这个Ack数据到底是原始请求的响应还是重传请求的响应呢?不好判断。
如果算成原始请求的响应,但实际上是重传请求的响应(上图左),会导致采样RTT变大。如果算成重传请求的响应,但实际上是原始请求的响应,又很容易导致采样RTT过小。
由于Quic重传的Packet和原始Packet的Pakcet Number是严格递增的,所以很容易就解决了这个问题。
Quic重传没有歧义性
如上图所示,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来保证应用数据的顺序。如图7 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按照顺序组织起来,交给应用程序处理。
Stream Offset保证有序性
不允许Reneging
什么叫Reneging呢?就是接收方丢弃已经接收并且上报给SACK选项的内容[9]。TCP协议不鼓励这种行为,但是协议层面允许这样的行为。主要是考虑到服务器资源有限,比如Buffer溢出,内存不够等情况。
Reneging对数据重传会产生很大的干扰。因为Sack都已经表明接收到了,但是接收端事实上丢弃了该数据。
QUIC在协议层面禁止Reneging,一个Packet只要被Ack,就认为它一定被正确接收,减少了这种干扰。
更多的Ack块
TCP的Sack选项能够告诉发送方已经接收到的连续Segment的范围,方便发送方进行选择性重传。
由于TCP头部最大只有60个字节,标准头部占用了20字节,所以Tcp Option最大长度只有40字节,再加上TcpTimestamp option占用了10个字节[27],所以留给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选项存在一个问题[27],它只是回显了发送方的时间戳,但是没有计算接收端接收到segment到发送Ack该segment的时间。这个时间可以简称为Ack Delay。
这样就会导致RTT计算误差。如下图:
可以认为TCP的RTT计算:
RTT = timestamp2 - timestamp1
而Quic计算如下:
RTT = timestamp2 - timestamp1 - ACK delay
当然RTT的具体计算没有这么简单,需要采样,参考历史数值进行平滑计算,参考如下公式[10]。
改进的多路复用
多路复用是HTTP2最强大的特性[8],能够将多条请求在一条TCP连接上同时发出去。但也恶化了TCP的一个问题,队头阻塞[12],如下图示:
图8 HTTP2队头阻塞
HTTP2在一个TCP连接上同时发送4个Stream。其中Stream1已经正确到达,并被应用层读取。但是Stream2的第三个tcp segment丢失了,TCP为了保证数据的可靠性,需要发送端重传第3个segment才能通知应用层读取接下去的数据,虽然这个时候Stream3和Stream4的全部数据已经到达了接收端,但都被阻塞住了。
不仅如此,由于HTTP2强制使用TLS,还存在一个TLS协议层面的队头阻塞[13]。
TLS队头阻塞
Record是TLS协议处理的最小单位,最大不能超过16K,一些服务器比如Nginx默认的大小就是16K。由于一个record必须经过数据一致性校验才能进行加解密,所以一个16K的record,就算丢了一个字节,也会导致已经接收到的15.99K数据无法处理,因为它不完整。
那QUIC多路复用为什么能避免上述问题呢?
-QUIC最基本的传输单元是Packet,不会超过MTU的大小,整个加密和认证过程都是基于Packet的,不会跨越多个Packet。这样就能避免TLS协议存在的队头阻塞
- Stream之间相互独立,比如Stream2丢了一个Pakcet,不会影响Stream3和Stream4。不存在TCP队头阻塞。
QUIC多路复用时没有队头阻塞的问题
当然,并不是所有的QUIC数据都不会受到队头阻塞的影响,比如QUIC当前也是使用Hpack压缩算法[11],由于算法的限制,丢失一个头部数据时,可能遇到队头阻塞。
总体来说,QUIC在传输大量数据时,比如视频,受到队头阻塞的影响很小。
流量控制
UDP协议本身没有滑动窗口,也没有流量控制的机制,那QUIC是如何实现流量控制的呢?
QUIC的流量控制[24]类似HTTP2,即在Connection和Stream级别提供了两种流量控制。为什么需要两类流量控制呢?主要是因为QUIC支持多路复用:
- Stream可以认为就是一条HTTP请求。
- Connection可以类比一条TCP连接。多路复用意味着在一条Connetion上会同时存在多条Stream。既需要对单个Stream进行控制,又需要针对所有Stream进行总体控制。
QUIC实现流量控制的原理比较简单:
- 通过window_update帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
- 通过BlockFrame告诉对端由于流量控制被阻塞了,无法发送数据。
QUIC的流量控制和TCP有点区别,TCP为了保证可靠性,窗口左边沿向右滑动时的长度取决于已经确认的字节数。如果中间出现丢包,就算接收到了更大序号的Segment,窗口也无法超过这个序列号。但QUIC不同,就算此前有些packet没有接收到,它的滑动只取决于接收到的最大偏移字节数。
Quic Flow Control
针对Stream:
可用窗口 = 最大窗口数-接收到的最大偏移量
针对Connnection
可用窗口 = stream1可用窗口 + stream2可用窗口 + stream N可用窗口
同样地,STGW也在连接和Stream级别设置了不同的窗口数。最重要的是,我们可以在内存不足或者上游处理性能出现问题时,通过流量控制来限制传输速率,保障服务可用性。
其他亮点
此外,QUIC还能实现前向冗余纠错,在重要的包比如握手消息发生丢失时,能够根据冗余信息还原出握手消息。
QUIC还能实现证书压缩,减少证书传输量,针对包头进行验证等。
限于篇幅,本文不再详细介绍,有兴趣的可以参考文档[25]和文档[4]。
STGW QUIC接入架构
考虑到前端接入的重要性和业务安全,下图只是简略地描述了一下STGW在协议接入特别是QUIC协议接入的架构。
QUIC接入架构
简单说一下流程:
- 用户侧使用的各种请求,首先接入四层TGW。
- 如果是QUIC协议,四层TGW使用ID负载均衡算法将请求转发到STGWQUIC集群。如果是非QUIC协议,使用用户配置的各种负载均衡算法进行转发。
请求落到STGW集群后,会做一些判断,如果需要私钥计算(比如TLS完全握手),会将部分重要参数转发到私钥计算集群进行高强度计算。如果需要查询Session ID或者 - ServerConfig ID,会做一些缓存查询。这样能够减少计算量,也能减少握手时间。
- STGW接下去会将QUIC/HTTP2/HTTPS协议统一转换成HTTP1.1请求。由于QUIC使用的是UDP协议,HTTP1.1使用的是TCP,也就是说UDP协议的内容经过转换后会使用TCP进行转发。
- 业务集群只需要处理HTTP1.1协议即可。如果是自定义的私有协议,我们也支持TCP/UDP透明转发,由业务自行处理。
测试结论
由于公司内网的WIFI环境不稳定,多次测试发现数据跳动较大,4G环境下的数据更加稳定可靠,所以主要结论参考4G网络下的数据。
比较4G网络下各个协议的表现,Quic的优势很明显,结论如下:
域名 | 元素个数 | 首字节时间 | 页面加载时间 |
---|---|---|---|
QUICvs HTTP | 3/24 | +0% | +36% |
QUICvs HTTP | 3/24 | +0% | +36% |
QUIC vs HTTP2 | 3/24 | +39.9% | +47.5% |
QUIC vs HTTPS | 3/24 | +47.4% | +64% |
QUICvs HTTP | 3/12 | +8% | +9% |
QUIC vs HTTP2 | 3/12 | +29% | +42.3% |
QUIC vs HTTPS | 3/12 | +37.7% | +52.8% |
可以看出QUIC的优势非常明显,即使在元素比较少(12个元素)的情况下,相比HTTP也能提升9%,相比HTTP2提升42%,相比HTTPS提升52%。
在页面元素增多的情况下,QUIC的优势就更加明显,相比HTTP提升36%,相比HTTP2提升47%,相比HTTPS提升64%。
结论
QUIC协议非常复杂,因为它做了太多事情:
- 为了实现传输的可靠性,它基本上实现并且改进了整个TCP协议的功能,包括序列号,重传,拥塞控制,流量控制等
- 为了实现传输的安全性,它又彻底重构了TLS协议,包括证书压缩,握手消息,0RTT等。虽然后续可能会采用TLS1.3协议,但是事实上是QUIC推动了TLS1.3的发展。
- 为了实现传输的并发性,它又实现了HTTP2的大部分特性,包括多路复用,流量控制等。
- 虽然如此复杂,但是QUIC作为一个新兴的协议,已经展现了非常强大的生命力和广阔的前景。目前国内外除了Google大规模采用外,还鲜有其他互联网公司使用。