1.什么是QUIC
QUIC(Quick UDP Internet Connections),即快速UDP网络连接,是被设计用在传输层的网络协议,最初由Google的Jim Roskind提出,最初实现和部署在2012年,截止目前仍然是一个因特网草案,但已经被广泛应用于Google浏览器和Google服务器之间。目前Chorme、Microsoft Edge、Firefox、Safari均已经支持QUIC,尽管不常用。
QUIC增加了面向连接的TCP网络应用程序的性能,它通过使用UDP在两个端点之间建立一系列多路复用(multiplexing)的连接实现这个目的,它同时被用来代替(obsolesce)TCP在网络层的作用,因此也被戏称为TCP/2。
QUIC与HTTP/2的多路复用连接紧密结合,允许多个数据流独立的到达终端,因此一个数据包与其他的数据流传输的数据包丢失无关。与之相对的是,TCP如果有任何数据包的丢失或延迟,就会发生队头阻塞。
QUIC的另一个目标是减少连接和传输时候的延迟,以及评估每一个方向的带宽来避免阻塞。它还将拥塞控制算法移动到两个端点的用户空间,而不是内核空间,根据QUIC的实现,这将会提升算法的性能。此外,当遇到预期的错误的时候,QUIC协议可以使用前向纠错(forward error correction)FEC来提升性能。2018年10月,IETF的HTTP和QUIC工作组共同决定将QUIC上的HTTP映射称为HTTP/3,以使其在全球范围内标准化。
1.1为什么需要QUIC
传统的TCP网络通信协议旨在提供一个接口,然后再两个端口之间发送数据流。TCP的传输需要保证数据报按顺序来接收,如果发现接收顺序错误,就需要使用自动重传请求来通知发送方重新发送数据包,同时建立连接的三次握手在复杂的网络环境和地理限制也是一个重要的考虑内容。
此外,由于TCP设计像一个"数据管道",如果单个数据包有问题,后续的所有数据报的发送将会被阻塞。现代社会的应用场景对更低延迟、良好的传输性能的要求越来越高,于是提出一个新的解决方案就十分有必要了。
1.2QUIC做了什么
QUIC的目标几乎等同于TCP连接,但是延迟却会更少。它通过两个更改来实现:
1.3减少连接期间的开销
提高网络交换事件期间的性能。例如从wifi切换到移动网络能更快的切换。
QUIC大致可以通过如下公式概括:TCP + TLS + HTTP2 = UDP + QUIC + HTTP2’s API
从公式可看出:QUIC协议虽然是基于UDP,但它不但具有TCP的可靠性、拥塞控制、流量控制等,且在TCP协议的基础上做了一些改进,比如避免了队首阻塞;另外,QUIC协议具有TLS的安全传输特性,实现了TLS的保密功能,同时又使用更少的RTT建立安全的会话。
2.深入QUIC
QUIC解决了一些现代网站应用的传输层和应用层问题,并且只需要一点或根本不需要改变客户端应用。QUIC非常类似于TCP+TLS+HTTP2,但是是使用UDP实现的。使用QUIC作为一个独立的协议可以实现现有协议无法实现的创新,因为现有协议往往受客户端和中间设备的妨碍。
现在存在的硬件以及软件不足:
路由封杀UDP 443端口( 这正是QUIC 部署的端口);
UDP包过多,由于QS限定,会被服务商误认为是攻击,UDP包被丢弃;
无论是路由器还是防火墙目前对QUIC都还没有做好准备;
相比于TCP+TLS+HTTP2,QUIC主要在五个方面更具有优势(特性):
建立连接延迟
改善拥塞控制
没有队头阻塞的多路复用
前向纠错
连接迁移
下面将一一介绍
2.1建立连接(Connection Establishment)
建立连接的低延迟可以说是QUIC的核心特性。
发送数据之前,QUIC只需要0RTT就能够建立连接,而传统的TCP+TLS则需要1-3RTT才能够建立连接。具体的QUIC建立连接的方法如下:
QUIC客户端第一次连接到服务器时,客户端必须执行1次往返握手,以获取完成握手所需的信息。客户端发送早期(empty)客户端Hello问候(CHLO),服务器发送拒绝(rejection)(REJ),其中包含客户端前进所需的信息,包括源地址令牌和服务器的证书。客户端下次发送CHLO时,可以使用以前连接中的缓存凭据来立即将加密的请求发送到服务器。
我们再深入的了解一下QUIC的加密协议。
根据因特网草案所述,QUIC现在使用TLS1.3版本来保证传输的安全可靠性。
阅读TLS1.3文档,我们可以发现TLS的功能其中一项是支持了零往返时间(0-RTT)模式,节省了往返时间。
我们对比一下TLS1.2和TLS1.3协议:
1.TLS1.2协议
从上面可以看出,为什么叫0RTT呢?
1.当客户端首次发起QUIC连接时,客户端想服务器发送一个client hello消息,服务器回复一个server reject消息。该消息中有包括server config,类似于TLS1.3中的key_share交换。这需要产生1-RTT. 事实上,QUIC加密协议的作者也明确指出当前的QUIC加密协议是「注定要死掉的」(destined to die), 未来将会被TLS1.3代替。只是在QUIC提出来的时候,TLS1.3还没出生?,这只是一个临时的加密方案。
2.当客户端获取到server config以后,就可以直接计算出密钥,发送应用数据了,可以认为是0-RTT。
3.因此,QUIC握手除去首次连接需要产生1-RTT,理论上,后续握手都是0-RTT的。
4.假设1-RTT=100ms, QUIC建立安全连接连接的握手开销为0ms, 功能上等价于TCP+TLS, 但是握手开销比建立普通的TCP连接延迟都低:
(正常体为首次建立连接的延迟,粗体部分为后续握手的延迟)
总结一下:首次建立连接需要一个RTT,但是后续连接只需要可以直接发送数据,故称为0RTT。
前面图片是1RTT,后面是0RTT。
2.2拥塞控制(Congestion Control)
让我们回想一下TCP的拥塞控制:慢启动,拥塞避免,快重传,快恢复。
QUIC的拥塞控制基于了TCP NewReno。NewReno是基于拥塞窗口的拥塞控制。根据QUIC草案对于拥塞部分的描述QUIC包括了一些具体的拥塞控制算法:
显示拥塞控制:如果路径支持ECN,QUIC会将Congestion Experienced codepoint(CEC)标记视为拥塞信号;
慢启动: 拥塞窗口一直增大直到到达阈值;
拥塞避免:如果有一个数据丢失,拥塞窗口减半然后重新设置阈值;
恢复期Recovery Period(暂译):区别于TCP的快恢复,QUIC的恢复期是检测到丢失的一段时间内拥塞窗口变为1;
忽略不可解密的数据包丢失:从上面的建立连接我们可以知道,TLS发送会有一个秘匙,如果某些数据包发送过快的话,而秘匙还没到,就会造成无法解析这个包的数据;
探测超时:发送一个探测包,如果没有收到确认,可能拥塞;
持续性拥塞:如果收到一个ACK帧,与前一个已经收到的ACK帧相差较大,可能拥塞;
从拥塞算法来看,QUIC相比于TCP没有太大不不同,那么QUIC在什么地方和TCP不同呢?
2.2.1可拔插的拥塞控制
什么是可拔插?就是可以灵活的使用拥塞算法,一次选择一个或几个拥塞算法同时工作。
在应用层实现拥塞算法,而以前实现对应的拥塞算法,需要部署到操作系统内核中。现在可以更快的迭代升级。不同的平台具有不同的底层和网络环境,现在我们能够灵活的选择拥塞控制,比如选择A选择Cubic,B则选择显示拥塞控制。
应用程序不需要停机和升级,我们在服务端进行的修改,现在只需要简单的reload一下就能实现不同拥塞控制切换。
2.2.2单调递增的包编号
回想TCP,TCP使发送方的发送顺序与接收方的发送顺序相抵触,从而导致重传带有相同序号的相同数据,从而导致“重传歧义”。
QUIC并没有使用TCP的基于字节序号及ACK来确认消息的有序到达,QUIC使用的是Packet Number,每个Packet Number严格递增,所以如果Packet N丢失了,重传Packet N的Packet Number已不是N,而是一个大于N的值。 这样就很容易解决TCP的重传歧义问题。
2.2.3没有Reneging
什么叫 Reneging 呢?就是接收方丢弃已经接收并且上报给 SACK 选项的内容。
QUIC ACK包含类似于TCP SACK的信息,但是QUIC不允许重新发送任何确认的数据包,从而极大地简化了双方的实现并减轻了发送方的内存压力。
2.2.4更多的ACK帧
QUIC支持许多ACK范围,与TCP的3 SACK范围相反。
由于 TCP 头部最大只有 60 个字节,标准头部占用了 20 字节,所以 Tcp Option 最大长度只有 40 字节,再加上 Tcp Timestamp option 占用了 10 个字节 ,所以留给 Sack 选项的只有 30 个字节。
每一个 Sack Block 的长度是 8 个,加上 Sack Option 头部 2 个字节,也就意味着 Tcp Sack Option 最大只能提供 3 个 Block。
但是 Quic Ack Frame 可以同时提供 256 个 Ack Block,在丢包率比较高的网络下,更多的 Sack Block 可以提升网络的恢复速度,减少重传量。
2.2.5延迟确认的显式更正
QUIC端点会测量接收到数据包与发送相应确认之间的延迟,使对等方可以保持更准确的往返时间估计。
2.3流(Stream)的多路复用(Multiplexing)
HTTP2的最大特性就是多路复用,而HTTP2最大的问题就是队头阻塞。
首先了解下为什么会出现队头阻塞。比如HTTP2在一个TCP连接上同时发送3个stream,其中第2个stream丢了一个Packet,TCP为了保证数据可靠性,需要发送端重传丢失的数据包,虽然这时候第3个数据包已经到达接收端,但被阻塞了。这就是所谓的队头阻塞。
而QUIC多路复用可以避免这个问题,因为QUIC的丢包、流控都是基于stream的,所有stream是相互独立的,一条stream上的丢包,不会影响其他stream的数据传输。
下面简要的介绍一下流:
QUIC中的流向应用程序提供了轻量级的,有序的字节流抽象。QUIC流的另一种观点是作为一种弹性的“消息”抽象。
流可以由任一端点创建流,可以同时发送与其他流交错的数据,并且可以将其取消。
2.3.1发送流
发送部分的状态端点启动的流发送部分(type:客户端为0和2 ,服务器为1和3)由应用程序打开。在 “就绪”状态代表一个新创建的数据流,它能够从应用程序接受数据。流数据可能在此状态下被缓冲以准备发送。
2.3.2接受流
接收流的部分的状态由对等方(type:客户端的类型1和3 ,或0和2)发起的流的接收部分(对于服务器而言),则在为该流接收到第一个STREAM,STREAM_DATA_BLOCKED或RESET_STREAM时创建。对于由对等方发起的双向流,接收到MAX_STREAM_DATA或STOP_SENDING帧作为流还创建接收部分。
你可以把流比作为发送和接受数据的数据结构。
2.3.3Connections连接
什么是连接呢?
Connection 可以类比一条 TCP 连接。多路复用意味着在一条 Connetion 上会同时存在多条 Stream。既需要对单个 Stream 进行控制,又需要针对所有 Stream 进行总体控制。
2.4前向纠错(Forward Error Correction)
为了从丢失的数据包中恢复而无需等待重新传输,QUIC可以用FEC数据包来补充一组数据包。与RAID-4相似,FEC数据包包含FEC组中数据包的奇偶校验。如果该组中的一个数据包丢失,则可以从FEC数据包和该组中的其余数据包中恢复该数据包的内容。发送者可以决定是否发送FEC分组以优化特定场景(例如,请求的开始和结束).
在这里需要注意的是:早期QUIC中使用的FEC算法是基于XOR的简单实现,不过IETF的QUIC协议标准中已经没有FEC的踪影,猜测是FEC在QUIC协议的应用场景中难以被高效的使用。
2.5连接迁移(Connection Migration)
QUIC一个令人激动的特性就是连接迁移了,想象一下,当你从wifi切换到数据网络的时候,客户端IP会发生变化,这时候需要重新建立TCP连接
那 QUIC 是如何做到连接迁移呢?很简单,任何一条 QUIC 连接不再以 IP 及端口四元组标识,而是以一个 64 位的随机数作为 ID 来标识,这样就算 IP 或者端口发生变化时,只要 ID 不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。
2.5.1启动连接迁移
端点可以通过发送包含来自该地址的非探测帧的数据包,将连接迁移到新的本地地址。
2.5.2响应连接迁移
从包含非探测帧的新对等方地址接收到数据包表明对等方已迁移到该地址。
2.5.3损失检测和拥塞控制
当响应后,中间可能会有数据损失和拥塞控制问题:新路径上的可用容量可能与旧路径上的容量不同。在旧路径上发送的数据包不应有助于新路径的拥塞控制或RTT估计。端点确认对等方对其新地址的所有权后,应立即为新路径重置拥塞控制器和往返时间估计器。
2.6流量控制
有必要限制接收方可以缓冲的数据量,以防止快速发送方压倒慢速接收方,或者防止恶意发送
QUCI主要采取两种流量控制:
1.流控制:
通过限制可以在任何流上发送的数据量来防止单个流占用整个连接的接收缓冲区。
2.连接控制:
通过限制所有流上以STREAM帧发送的流数据的总字节数,来防止发送方超出连接的接收方缓冲区容量。
QUIC 实现流量控制的原理比较简单:
通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。
针对Stream:
可用窗口=最大窗口数-接收到的最大偏移数
针对Connection:
可用窗口=Stream1可用窗口+Stream2可用窗口+...+StreamN可用窗口
至此,我们关于QUIC的主要特性就讲完了。
3.一个简单的QUIC通信实现
分析的源码基于quic-go:quic-go
前置条件:Go1.14版本以上
3.1源码下载编译运行
使用git命令将源代码clone到本地
git clone https://github.com/lucas-clemente/quic-go.git
接着,根据官方提示,运行
go test ./...
这里我使用了go test -v ./...来获得更详细的信息
部分测试截图
测试成功,源代码没有问题,我们开始编写服务端和客户端的通信代码。先简单的贴出服务端和客户端的核心代码:
服务端
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
客户端
session, err := quic.DialAddr(addr, tlsConf, nil)