QUIC结构
QUIC协议模型如下图所示,其放弃了TCP∕IP网络中使用五元组(源IP,源端口,目的IP,目的端口,协议标识符)来唯一标识一条连接的方式,而使用一个全局唯一的随机生成的ID(即Connection ID) 来标识一条连接。
由低向上分层讨论QUIC协议:
•UDP层:在UDP层传输的是UDP报文,此处关注的是UDP报文荷载内容是什么,以及如何高效发送UDP报文;
•Connection层:Connection通过Connection ID来确认唯一连接,connection对packet进行可靠传输和安全传输;
•Stream层:在相应的Connection中,Stream通过Stream ID进行唯一流确认,并对stream frame进行传输管理。
Quic协议相关术语
- 数据包(Packet):QUIC 协议中一个完整可处理的单元,可以封装在UDP 数据报(datagram)中。多个QUIC 数据包(packets)可以封装在一个UDP 数据报(datagram)中。
- 帧(Frame):QUIC 数据包(packet)的有效载荷(payload)。
- 端点(Endpoint):在QUIC 连接中生成、接收和处理 QUIC 数据包(packets)的终端。QUIC中只有两端点(endpoints):客户端(client)和服务端(server)。
- 客户端(Client): 创建QUIC 连接的端点。
- 服务端(Server): 接收QUIC 连接的端点。
- 地址(Address):未经限定使用时,表示网络路径一端的IP 版本、IP地址和 UDP 端口号的元组。
- 连接ID(Connection ID): 用于标识端点 QUIC 连接的一种标识符。每个端点(endpoint)为其对端(peer)选择一个或多个连接 ID,将其包含在发送到该端点的数据包(packets)中。这个值对peer 不透明。
- 流(Stream):QUIC 连接中有序字节的单向(unidirectional)或双向(bidirectional)通道。一个QUIC 连接可以同时携带多个流。
- 应用程序(Application):使用QUIC 发送或者接收数据的实体。
UDP层
UDP荷载大小
•荷载大小受限于3个对象:QUIC协议规定;路径MTU;终端接受能力
1、QUIC不能运行在不支持1200字节的单个UDP传输网络路径上 QUIC规定initial包大小不得小于1200,如果数据本身不足1200(比如initial ack),那么需要用padding方式至少填充到1200字节
2、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,大于这个值,某些网络会存在丢包现象。
3、终端能接受 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发送,势必会造成大量的系统调用,影响吞吐
1、通过sendmmsg接口进行优化,sendmmsg可以将用户态的多个UDP QUIC包通过一次系统调用发到内核态。内核态对于每个UDP QUIC包独立作为UDP包发出去
2、在1.)解决了系统调用次数问题,开启GSO可以进步一分包延迟到发给网卡驱动前一刻,可以进一步提高吞吐,降低CPU消耗。
Connection层
上节说到,1个udp报文里传输的其实是一个或多个QIUC协议打包的packet。所以在Connection这一层面,其实是以packet为单位进行管理的。一个packet到来,终端需要解析出目标Connection ID(DCID)字段,并将该packet交给找到对应的QIUC connection。一个packet是由header加payload两部分组成。
Connection ID
相较于TCP/IP使用五元组标识一条连接,QIUC在Connection层采用客户端随机产生的64位随机数作为Connection ID标识连接,这样IP或者端口发生变化时,只要ID 不变,这条连接依然维持,可以做到连接平滑迁移。
连接建立时使用UDP端口号来识别指定机器上的特定server,而一旦建立,连接通过其connection ID关联。
上图左边是HTTPS的一次完全握手的建连过程,需要3 个 RTT。就算是Session Resumption,也需要至少 2个 RTT。而 QUIC 由于建立在UDP 的基础上,同时又实现了 0RTT的安全握手,所以在大部分情况下,只需要0 个 RTT就能实现数据发送,在实现前向加密的基础上,并且 0RTT 的成功率相比TLS 的 Sesison Ticket要高很多。QUIC握手(handshake)合并了加密和传输参数的协商,只需要1-RTT 即可完成握手,提升了建立连接到交换应用程序数据的速度。第二次连接时,可以通过第一次连接时获取到的预共享密钥(pre-shared secret)立即发送数据(0-RTT)。
安全传输
QUIC的安全传输依赖TLS1.3,而boring ssl是众多quic实现的依赖库。协议对Packet的头部以及荷载均进行了保护(包括packet number)。TLS1.3提供了0-RTT的能力,在提供数据保护的同时,能在第一时间(服务端收到第一个请求报文时)就将Response Header发给客户端。大大降低了HTTP业务中的首包时间。为了支持0-RTT,客户端需要保存PSK信息,以及部分transport parament信息。
安全传输也经常会涉及到性能问题,在目前主流的服务端,AESG由于cpu提供了硬件加速,所以性能表现最好。CHACHA20则需要更多的CPU资源。在短视频业务上,出于对首帧的要求,通常直接使用明文传输。
Transport Paramenter(TP)协商是在安全传输的握手阶段完成,除了协议规定的TP外,用户也可以扩展私有TP内容,这一特性带来了很大的便利,比如:客户端可以利用tp告知服务端进行明文传输。
可靠传输
QUIC协议是需要像TCP能够进行可靠传输,所以QUIC单独有一个rfc描述了丢包检测和拥塞控制的话题,
- 丢包检测:
TCP 为了保证可靠性,使用了基于字节序号的 Sequence Number 及 Ack 来确认消息的有序到达。
QUIC 同样是一个可靠的协议,它使用 Packet Number 代替了 TCP 的 sequence number,并且每个 Packet Number 都严格递增。而 TCP ,重传 segment 的 sequence number 和原始的 segment 的 Sequence Number 保持不变,也正是由于这个特性,引入了 TCP 重传的歧义问题。
在普通的TCP里面,如果发送方收到三个重复的ACK就会触发快速重传,如果太久没收到ACK就会触发超时重传,而QUIC使用NACK (Negative Acknowledgement) 可以直接告知发送方哪些包丢了,不用等到超时重传。TCP有一个SACK的选项,也具备NACK的功能,QUIC的NACK有一个区别它每次重传的报文序号都是新的。
但是单纯依靠严格递增的 Packet Number 肯定是无法保证数据的顺序性和可靠性。QUIC 又引入了一个 Stream Offset 的概念,即一个 Stream 可以经过多个 Packet 传输,Packet Number 严格递增,没有依赖。但是 Packet 里的 Payload 如果是 Stream 的话,就需要依靠 Stream 的 Offset 来保证应用数据的顺序。
- 拥塞控制:QUIC针对TCP协议中的一些缺陷,专门做了优化。QUIC 重新实现了TCP 协议的Cubic算法进行拥塞控制,并在此基础上做了不少改进。
- 热插拔:tcp的拥塞控制需要内核态实现,而QUIC在用户态实现,因此QUIC 修改拥塞控制策略只需要在应用层操作,并且QUIC 会根据不同的网络环境、用户来动态选择拥塞控制算法。
- 前向纠错 FEC:QUIC 使用前向纠错(FEC,Forward Error Correction)技术增加协议的容错性。一段数据被切分为10 个包后,依次对每个包进行异或运算,运算结果会作为 FEC 包与数据包一起被传输,当出现丢包时可根据剩余的包和FEC包推算出丢的包。
单调递增的Packet Number
TCP 为了保证可靠性,使用Sequence Number 和 ACK确认消息是否有序到达,但这样的设计存在缺陷。超时发生后客户端发起重传,随后接收到了ACK确认,但因为原始请求和重传请求所返回的ACK 消息一样,所以客户端无法分辨此 ACK 对应的是原始请求还是重传请求。如果客户端认为是原始请求的ACK,但实际上是左图的情形,则计算的采样 RTT 偏大;如果客户端认为是重传请求的ACK,但实际上是右图的情形,又会导致采样 RTT 偏小。采样 RTT 会影响超时重传时间(Retransmission TimeOut)的 计算。
QUIC解决了上面的歧义问题。与采用Sequence Number 标记不同的是,其使用的Packet Number标记严格单调递增,如果 Packet N 丢失了,那么重传时 Packet 的标识不会是 N,而是比 N 大的数字,比如N+M,这样发送方接收到确认消息时就能方便地知道 ACK 对应的是原始请求还是重传请求。
Connection层
更大的ACK block
一般来说,接收方收到发送方的消息后都应该发送一个 ACK回复,表示收到了数据。但每收到一个数据就返回一个ACK 回复太麻烦,所以一般不会立即回复,而是接收到多个数据后再回复,TCP SACK 最多提供 3个 ACK block。但有些场景下,比如下载,只需要服务器返回数据就好,但按照 TCP 的设计,每收到 3 个数据包就要返回一个ACK。而QUIC 最多可以捎带 256 个ACK block。在丢包率比较严重的网络下,更多的 ACK block 可以减少返回包的量,提升网络效率。
流量控制
TCP 会对每个 TCP 连接进行流量控制,通过滑动窗口进行实现。
QUIC 的流量控制有两个级别:连接级别和Stream级别,用于表达接收端的接受能力。
单条 Stream的流量控制如上图所示。Stream 还没传输数据时,接收窗口(flow control receive window)就是最大接收窗口(flow control receive window),随着接收方接收到数据后,接收窗口不断缩小。在接收到的数据中,有的数据已被处理,而有的数据还没来得及被处理。蓝色块表示已处理数据,黄色块表示未处理数据,这部分数据的到来,使得Stream的接收窗口缩小。
随着数据不断被处理,接收方就有能力处理更多数据。当满足(flow control receive offset - consumed bytes) < (max receive window / 2) 时,接收方会发送WINDOW_UPDATE frame 告诉发送方你可以再多发送些数据过来。这时flow control receive offset就会偏移,接收窗口增大,发送方可以发送更多数据到接收方。
由于QUIC协议允许多路复用,因此Stream 级别的控制对防止接收端接收过多数据作用有限,更需要借助 Connection 级别的流量控制。
针对stream:
可用窗口数 = 最大窗口数 – 接收到的最大偏移数
针对connection:
可用窗口数 = stream1可用窗口数+ … + streamN可用窗口数
QUIC 的流量控制和TCP 有点区别,TCP为了保证可靠性,窗口左边沿向右滑动时的长度取决于已经确认的字节数。如果中间出现丢包,就算接收到了更大序号的Segment,窗口起始changdu也无法超过这个序列号。 QUIC 不同,就算此前有些 packet 没有接收到,它的滑动窗口也只取决于接收到的最大偏移字节数。
Stream层
Stream是一个抽象的概念,用以表示一个有序传输的数据流,而这些数据其实就是由Stream Frame排列构成。QUIC 使用帧(frames)进行端到端的通信。一个或多个帧(frame)被组装成一个 QUIC 包(packet)。在一个quic connection上,可以同时传输多条流,QUIC通过对多路传输的支持,解决了TCP中的队头阻塞问题。
在QUIC协议中,有序的概念仅维护在单个stream中,stream之间和packet都不要求有序,假设某个packet丢失,只会影响包含在这个包里的stream,其他stream仍然可以从后续乱序到达的packet中提取到自己所需帧交给应用层。
Stream头部
可以创建两种类型的流:双向流(bidirectional streams),允许客户端和服务端互相发送数据。单向流(unidirectional streams),允许单个端点(endpoint)发送数据。一个基于信用的方案(credit-based scheme)用于限制流的创建并限制可发送的数据量。stream的不同类型定义在HTTP3中得到了充分的利用。
Stream荷载
Stream的荷载即为一系列Stream Frame,通过Stream Frame头部的Stream ID来确认单个流。在TCP里,如果一个segment传递丢失,那么后续segment乱序到达,也不会被应用层使用,只到丢失的segment重传成功为止,因此TCP实现的HTTP2的多路复用能力受到制约。在QUIC协议中,有序的概念仅维护在单个stream中,stream之间和packet都不要求有序,假设某个packet丢失,只会影响包含在这个包里的stream,其他stream仍然可以从后续乱序到达的packet中提取到自己所需要的数据交给应用层。
Quic相关开源库
gQUIC | iQUIC |
chromium:quic-client/server-demo模块封装了支持HTTPS的QUIC实现 | MsQuicMsQuic是IETF quic协议的Microsoft实现。它是跨平台的,用C语言编写,设计成一个通用的QUIC库。 |
chromium:net/quic模块封装QUIC在更底层模仿TCP Socket操作,在chrome75版本后被弃用,本次quic模块基于74-75版本的模块实现。 | quic-go使用Go语言来重写的QUIC协议实现库,从github上面看其对于iQUIC和gQUIC这两个分支流派都提供了支持。 |
quiche目前谷歌使用的QUIC开源代码库,将QUIC从chromium独立出来提供QUIC协议的支持,与iQUIC兼容。 | QuicwgIETF quic实现工作小组 |