近些年 QUIC 协议在网络通信领域掀起热潮,QUIC、HTTP/3 等协议名称在各个技术网站上随处可见。
那 QUIC 到底是什么?HTTP/3 又跟 QUIC 什么关系和区别?本文就从 QUIC 协议优秀特性的原理入手,来介绍一下 OPPO 自研 QUIC 协议( OQUIC 协议)的实现及应用。
随着互联网的发展,尤其是移动互联网的加入,一方面网络变得越来越拥挤的,另一方面人们对网络时延的要求也越来越高。然而网络上图片、视频等大资源也越来越多,音频及短视频越来越普及,人们对网络传输的实时性要求更高,希望页面瞬间能打开、短视频流畅不卡顿。近几十年网络优化领域从业者都在致力于加速数据的网络传输的研究,终于在2013年,Google向世界投下一颗惊雷:使用“QUIC”协议替代传统的TCP协议,直至今天热度依然很高。
1.1 TCP为什么“不行”了?
TCP的建连过程必须经过1个 RTT ( Round Trip Time ) ,MPTCP的建连过程更慢(共需要3个 RTT ),HTTP/2 及以前的版本的协议,在 TCP 建连后还需 TLS 建连,TLS 握手又需要1~2个 RTT,在客户端和服务端距离较远的用户看来,这个建连过程就已经令人抓狂了。
由于TCP的可靠性传输特性,要求数据的按顺序到达。TCP使用包序号(Sequence Number)来保证有序性。但是在复杂的网络传输过程中,先发出去的数据包不一定先到达目的地。如果丢包了,TCP要求必须阻塞等待重传到达后接收窗口才能滑动,才能继续接收序号较大的包,这种需要阻塞等待的行为叫 队头阻塞。HTTP/2协议用流的概念解决了HTTP协议的队头阻塞问题,但是TCP协议的队头阻塞无法解决。
TCP协议至今已经五十年了,在内核中实现。每次更新TCP协议,就需要升级操作系统,所以即使TCP有比较好的特性更新,也很难快速推广。
由于TCP协议使用的太久了,中间设备,比如防火墙、NAT网关等已经固化下来了,如果想对TCP进行大的修改,中间设备就首先“不答应”,后果就是用户更新了自己本端的TCP协议后上不了网。
1.2 QUIC 协议如何解决上述问题的?
QUIC协议基于UDP实现,UDP不可靠传输,无需建连,QUIC协议在应用层进行保证可靠传输,QUIC协议有个特别突出的特性:0-RTT,也就是QUIC的整个建连过程是不需要网络延迟的。0-RTT的实现过程我们在2.1章节详述。
QUIC是在用户态实现的传输层协议,所以只需要两个端点进行协议匹配即可,不涉及操作系统的更新,对中间设备更是透明的。
QUIC协议基于UDP而实现,所以天然解决了TCP的队头阻塞问题。
1.3 HTTP/3 又是什么?
相信有不少人会对这些概念有疑惑:为什么有的人叫 QUIC,又有的人叫 HTTP/3,还有iQUIC,gQUIC,这些都是什么呢?
早在2012年 Google 在提出这个 QUIC 协议概念的时候,QUIC 协议内容是包含了两层的(传输层和应用层),不仅有传输层的功能,也包含了应用层的功能。这个时候的 QUIC 协议也叫 HTTP/2 over QUIC。后来 IETF 组织发现:诶呦,这个协议不错哦,我需要把它做成规范化。所以在IETF的努力下,对 QUIC 协议进行改造,把 Google 最初的 QUIC 剥离成两层协议,传输层叫 QUIC 协议,只负责传输层的功能;应用层部分还是叫 HTTP 协议,但是给它升了个版本号,叫 HTTP/3 协议。IETF 在规范化 QUIC 的传输层功能的时候,对其也进行了优化,优化后的 QUIC 协议叫 iQUIC ( i 指 IETF ),自然地,为了区分,优化前的 Google 那一套叫 gQUIC ( g 指的是 Google )。由于 gQUIC 越来越不流行 ( 就连 Google 也做 iQUIC 了),所以 OPPO 实现的 QUIC 协议,是 iQUIC 协议。
2.1 0-RTT
gQUIC 协议与 iQUIC 协议的 0-RTT 握手过程是不一样的。gQUIC 的关键点在于 SCFG ,iQUIC 的关键点在于 PSK。本文只讲解 iQUIC 的过程。
我们的 QUIC 的握手过程是 TLS1.3 标准流程,使用了 BoringSSL 库( OpenSSL 的 TLS1.3 只支持 TCP,不支持 QUIC,目前支持 QUIC 的只有 BoringSSL ) 。
首先,客户端和服务端的建连,不是每一次都能做到 0-RTT 的。在以下两种情况下:
1) 客户端和服务端从未建连过,QUIC 握手需要一个完整的 RTT。
2) 客户端保存的PSK文件过期后,QUIC 握手需要一个完整的 RTT。
我们先来看 QUIC 握手的一个完整RTT的过程:
2)生成随机数作为客户端的椭圆曲线私钥 ( Ra ),保留在本地。
3)根据基点 G 和私钥计算出客户端的椭圆曲线公钥:
Pa(x, y) = Ra * G(x, y)
4)客户端支持的算法套、客户端使用的椭圆曲线、psk 的模式等信息。
1)生成随机数作为服务端的椭圆曲线私钥 ( Rb ),保留在本地。
2)根据基点G和私钥计算出服务端的椭圆曲线公钥:
Pb(x, y) = Rb * G(x, y)
3)再次生成一个 随机数,用于最终会话密钥的计算。
此时,客户端有:客户端的私钥,服务端的公钥;服务端有:服务端的私钥,客户端的公钥;
客户端计算
Sa(x, y) = Ra * Pb(x, y)
;
服务器计算
Sb(x, y) = Rb *Pa(x, y
)
;
根据椭圆曲线算法:
Sa = Sb = S
,提取其中的S的x向量作为预主密钥 ( pre-master)
最终的会话密钥,由 client Random, Server Random,pre-master 三个经过计算生成。
此时,客户端和服务端的连接已经建立完成,客户端就可以发送 HTTP request 了。
在 1-RTT 连接建立完成之后,服务端还会发送一个 New Session Ticket 报文。这个报文非常重要,是 0-RTT 的基础。
经过 1-RTT 握手后,客户端和服务端已经从“陌生人”变成了“朋友”了之后,后续该客户端再次访问这个业务时,QUIC 的 0-RTT 就“上岗”了。
Client 和 Server 共享同一个 PSK ( 通过第一次握手中 NewSessionTicket 获取 ),client 在第一个发送出去的消息中携带数据 ( “early data” ),并使用 PSK 恢复会话密钥对 early-data 进行加密。
从图中我们也可以看出,0-RTT 依然有 ClientHello 和 ServerHello 报文,所以 0-RTT 并非是不需要握手,而只是在握手的时候就已经携带了 HTTP Request 数据了。
只有三个条件同时满足,0RTT 会话恢复模式开启,否则是 1RTT 的会话恢复:
1) server 在第一次完整握手后,发送了 New Session Ticket,并且 Session Ticket 中存在 max_early_data_size 扩展表示愿意接受 early_data。
2) 在 PSK 会话恢复中,ClientHello 的扩展中配置了 early data 扩展,表示 Client 要开启 0RTT 模式。
3) Server 在 EnCrypted Extensions 消息中携带了 early data 扩展表示同意读取 early data。
2.2 连接迁移
一条 TCP 连接是通过四元组唯一标识的。只要四元组(源 IP,源 port,目的IP,目的 port)其中任何一个发生变化,当前连接就会断掉,就需要重新创建一条连接。什么叫连接迁移呢?就是当四元组其中任何一个元素发生变化时,当前连接不会中断,可以继续传输数据。在生活中,我们的手机经常在 wifi 和蜂窝数据之间进行切换,离开家会自动切换为蜂窝数据,回到家会自动切换为 wifi,进入公司\餐厅\咖啡厅等公共场所自动切换为 wifi,离开后又切换为蜂窝数据。每一次切换都会带来源 IP 和端口号的改变,每一次切换都会造成当前连接断掉,每一次切换都会造成短暂的无网,视频会卡顿、网页会加载失败... QUIC 协议的连接迁移功能很好的解决了这个问题,QUIC 基于连接 ID 唯一识别连接。当源地址发生改变时,QUIC 仍然可以保证连接存活和数据正常收发。
从上图中我们可以看出,连接迁移发生时,连接 ID ( conn_id ) 是没有变化的,虽然切换网络通道后( WIFI ->蜂窝),源IP发生变化,但 QUIC 服务端会根据连接 ID 来判断是否是同一个 QUIC 连接。
值得注意的是,连接迁移存在一定的攻击风险,为了防止第三方攻击,协议规定在发送后续数据前需要进行地址校验,确认对端的可靠性。这个过程通过 PC 帧 ( Path Challenge ) 和PR帧 ( Path Response ) 来完成。
客户端数据经过四层负载均衡器、再经过七层网关集群,最后到达业务后端服务器。我们将 QUIC 客户端部署在用户端侧,将 QUIC 服务端部署在七层网关集群容器中。
解决方案:可以获取网卡状态(需要用户对 APP 进行授权),也可以用 IP2 发探测报文来通知服务端进行连接迁移。
四层负载均衡器( DPVS )需要将同一条连接上的数据包负载均衡到同一个七层网关容器中。
当连接迁移发生时,需要保证连接不断的同时,也要保证连接的正确性。也就是连接迁移之前:客户端 A 是与 服务端 B 进行通信的,发生连接迁移后,我们需要保证客户端 A 的数据包仍然必须发送到服务端 B 上,不能发送到其他服务端上(这样会导致连接出现错误)。
解决方案:传统意义上,四层负载均衡器一般按照四元组(源 IP、源 PORT、目的 IP、目的 PORT )进行一致性哈希选择后端七层网关,但是连接迁移后,四元组发生变化,所以会哈希到其他的七层网关。要解决这个问题,四层负载均衡器就不能再以四元组进行哈希,而是根据 QUIC 协议的连接 ID 进行选择上游七层网关。
我们使用一个讨巧的方案来解决这个问题:服务端在生成SCID时,将本端的IP和端口号进行编码,作为 SCID 的一部分。
四层负载均衡器需要解析客户端发送的 QUIC 包中的 DCID(服务端的 SCID 在客户端发送的包中是 DCID ) ,这样即可保证四层负载均衡器将连接迁移后的数据包发送到同一个服务端。
在七层网关是多核的情况下,内核如何将同一个连接的数据交给同一个进程
解决方案:内核传统的做法是,通过四元组进行哈希,找到 socket fd,同一个 fd 对应着唯一的应用层进程。通过 eBPF 修改这一过程,通过连接 ID 进行哈希。
2.3 更优秀的拥塞控制算法
QUIC 协议的拥塞控制算法的优势,主要表现在两个方面:
QUIC 协议可以针对连接的每个流进行配置不同的拥塞控制算法,我们知道每个拥塞控制算法都有各自的适应场景,换句话说,不同的业务场景,不同的网络环境用不同的拥塞控制算法更为合适。在传统 TCP 连接里,拥塞控制算法的选择是在内核中进行配置。
sysctl net.ipv4.tcp_available_congestion_control
linux 上查询系统当前正在使用的拥塞控制算法:
sysctl net.ipv4.tcp_congestion_control
linux 上修改当前系统使用的拥塞控制算法(以bbr算法为例):
echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.conf
可见,一旦配置为某个拥塞控制算法,那么这台服务器上所有的业务所有的连接都只能使用该拥塞控制算法;
而 QUIC 不同,由于实现在应用层,我们可以随时更改拥塞控制算法,也可以对每个连接中不同的流使用不同的拥塞控制算法。
TCP 为了保证可靠性,使用了基于字节序号的 Sequence Number 及 Ack 来确认消息的有序到达。
QUIC 同样是一个可靠的协议,它使用 Packet Number 代替了 TCP 的 sequence number,并且每个 Packet Number 都严格递增,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。而 TCP 呢,重传 packet 的 sequence number 和原始的 packet 的 Sequence Number 保持不变,也正是由于这个特性,引入了 TCP 重传的歧义问题。
如上图的左流程,TCP 重传的歧义问题,就会导致 RTT 或者过大或者过小,而 RTT 是拥塞控制算法的重要输入参数,在 RTT 不准确的情况下,拥塞控制算法就无法做到精确。
QUIC 由于 Packet Number 严格递增,不会出现重传的歧义问题,拥塞控制算法更为精确。
另外,在普通的 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 来保证应用数据的顺序。
2.4 两级流量控制
所谓流控,就是接收端需要控制发送端的发送速度,以免发送端发送速度过快,导致自己“无能力”接收。TCP 的流量控制是经典的“滑动窗口”算法。但由于 TCP 的队头阻塞问题,一旦有某个 ACK 包丢了,就会导致整条连接上窗口无法向右滑动,很快就会出现“零窗口”的情况,此时数据无法再进行发送。
QUIC 采用两级流量控制,连接和流都进行流量控制。两级流量控制并不是 QUIC 协议的专属,HTTP/2 也同时提供流级和连接级别的流量控制。
流级流量控制就是 QUIC 某一条流接收端告诉另外一端可以接受多少这种流多少数据。针对的是特定流号的流,而不是整个链接。本质来说,就是接收端告诉对端最多能发到偏移到多少的流数据。例如,某一条流 N 告诉接收到可以到偏移200字节的位置。但是发送端已经发送150字节,那么发送端最多就只能发送50字节。等发送端把150字节处理完毕,又重新发送 WINDOW_UPDATE 到400字节的偏移。发送收到后,已经发送150,那么就再能发送250字节。
流级别的流量控制虽然能起到控制流量的效果,但是不够充分,数据发送端可以在同一个连接创建多条流来发送数据,每条流都达到最大值的攻击方法。因此还需要连接级别的流量控制。
连接级别流量控制和流级别的一样,但是消耗字节,最大接收偏移都是穿插所有流,是所有流的最大值或者总和。
2.5 流的多路复用
TCP 的有序性带来了队头阻塞问题,一条连接上,其中一个 packet 丢失了之后,该后续 packet 必须等到丢失的 packet 重传之后,把完整有序的数据交给应用层。这在多并发请求时候带来的影响很大,假设我们在一条连接上需要发送多个请求,Request 1 其中一个 packet 丢了之后,在其被重传成功之前,这条连接后面所有的所有的数据都不能被正常交付给应用层,即使 request 2 的所有数据都能按序到达,也即是请求之间会互相影响。
QUIC 协议由于基于 UDP,不存在这样的问题,假设 Request 1 的某个 packet丢了,它只会影响到 Request 1,不会影响并发的其他请求。
2.6 QUIC/TCP 多路竞速
据业内统计,全球有7%地区的运营商对 UDP 有限速或者禁闭,除了运营商还有很多企业、公共场合也会限制UDP流量甚至禁用 UDP。这对使用 UDP 来承载 QUIC 协议的场景会带来致命的伤害。
对此,OPPO 的 QUIC 协议采用多路竞速的方式使用 TCP 和 QUIC 同时建连。除了在建连进行竞速以外,还可以对网络 QUIC 和 TCP 的传输延时进行实时监控和对比,如果有链路对 UDP 进行了限速,可以动态从 QUIC 切换到 TCP。
2.7 QUIC 的 PING 帧
为了实时探测 QUIC 连接的“活性”,防止使用“坏死”连接导致请求失败,OPPO 的 QUIC 实现了自己的 PING 帧机制。不同于 HTTP/2 的 PING Request 和 PING Response 机制,QUIC 的 PING 帧的接收方只需要应答( ACK )包含该帧的包。
当连接建立后,就开始发送 PING 帧,PING帧间隔时间为5s、10s、15s。如果连续三次 PING 帧都无 ACK,就主动断开连接,并且发送 CC 帧( Connection Close )给服务端,服务端收到 CC 帧后释放连接资源。
通过弱网实验测试,QUIC 在开启 0-RTT 时,其延迟要比 HTTP 降低20%,比 HTTPS 要降低50%以上。现在主要在海外商店、小布助手等多个业务上线使用 QUIC。
在海外软件商店大规模灰度上线后,接口成功率提升3%~13%,秒开率提升2%~19%。
OPPO 的 QUIC 协议从2020年开始进行研究,然后经过持续两年的迭代优化,现在与Google、华为、腾讯等大厂的 QUIC 协议的性能水平基本一致。经过在海外软件商店等业务长达2年的灰度验证,稳定性得到了严格的验证,目前也有多个业务决定全量接入 QUIC 协议。
我们 QUIC 团队也在继续致力于网络传输优化领域的研究,希望能为 OPPO 的更多业务继续贡献自己的力量。
Longyan LI OPPO 高级工程师
2020年加入 OPPO 后, 从0-1建设了 OPPO 的 QUIC 协议,并在多个业务落地,取得良好效果。曾供职于华为,拥有多年网络协议栈的开发经验。
OPPO 安第斯智能云(AndesBrain)是服务个人、家庭与开发者的泛终端智能云,致力于“让终端更智能”。作为 OPPO 三大核心技术之一,安第斯智能云提供端云协同的数据存储与智能计算服务,是万物互融的“数智大脑”。