干货 | Trip.com APP QUIC 应用和优化实践

一、背景

Trip.com APP 原网络框架是基于 TCP 的,经过一系列优化后,成功率和耗时均已到达瓶颈。主要的失败原因集中在请求超时和链接断开。这是 TCP 协议本身的限制导致:


1)TCP 是基于链接的,用户网络发生切换,或者 NAT rebinding 都会导致链接断开请求失败,同时每次重新建立链接均需要握手耗时。


2)TCP 内置了 CUBIC 拥塞控制算法,这种基于丢包的拥塞控制在 Trip.com 的长肥管道场景(请求大多是海外回源国内)及弱网环境下更容易超时失败。


3)TCP 的头部阻塞场景进一步增加请求耗时。


而 QUIC(quick udp internet connection)是一种基于 UDP 的可靠传输协议,有 0 RTT,链接迁移,无队头阻塞,可插拔的拥塞控制算法等优秀特性,非常契合我们的用户请求场景。


二、配套

1)Trip.com QUIC 客户端的实现采用了 Google 开源的 Cronet,并在此基础上做了进一步的 size 精简和订制性的优化。


2)Trip.com QUIC 服务端使用了 Nginx 的 QUIC 分支,目前还没有 release 版本,使用中修复了很多 bug,并做了适配性的改造。


三、应用和优化实践

引入 QUIC 过程中最大的难点就是 Cronet 库体积过大,需要经过裁剪后才能在 APP 中使用。使用后经过对比实验发现 QUIC 并没有达到预期的效果,这是因为 QUIC 的 0 RTT,链接迁移等诸多优秀特性并不是开箱即用的,需要做定制化的改造才能享受到这些特性带来的性能提升。于是我们又进一步做了 IP 直连,支持单域名多 IP 链接,0 RTT 和链接迁移改造,QPACK 优化,拥塞控制算法选择,QUIC 使用方式优化等许多改造。经过改造后的整套网络方案极大的提升了网络质量,改善了用户体验,接下来详细介绍下我们优化过程中踩过的坑和相关经验成果。


3.1 Cronet 代码裁剪


业内有很多客户端 QUIC 的实现方案,Cronet 是最成熟的,但是 5M 的 size 让很多 APP 望而却步,所以我们做的第一件事就是对 Cronet 进行裁剪。


Cronet 是 chrome 的核心网络库,内部集成了 HTTP1/HTTP2/SPDY/QUIC,QPCK,BoringSSL,LOG,缓存,DNS 解析等很多模块,我们仅保留了 QUIC/QPACK/BoringSSL 等必须的核心功能,将 Cronet 库 size 减少 60%以上。针对 Cronet 的环境搭建、ninja 编译、如何修改.gn 文件来剔除无用模块,具体的逻辑代码删减等细节后续会推出专门的文章来介绍,同时也会争取将裁剪后的代码开源供大家使用。


3.2 IP 直连


我们通过修改 Cronet 源码,直接指定最优 QUIC IP,实现了 IP 直连,减少 DNS 解析耗时。


DNS 解析是需要耗时的,并且可能出现解析失败,DNS 拦截等问题。有时还会受网络运营商的影响,DNS 解析出的不是最优 ServerIP。


Cronet 对 DNS 解析做了很多优化,UDP 请求,TCP 补偿,支持 Https 解析以防止 DNS 拦截等,但这是浏览器需要的通用方案。


对于企业自己的 APP 来说,域名固定,服务入口 IP 变化较少,所以 Trip.com APP 内置了 QUIC Server IP 列表(支持 IP 列表动态更新),根据用户位置和网络状况指定最优 IP。使得 DNS 解析的耗时和失败率均达到了 0 。


以下为目前的 QUIC 接入方案,海外用户可以灵活的选择通过虫洞方式接入或者海外运营商提供的 IPA 加速(UDP 转发)节点接入,大陆用户可以通过普通的 IP 直连方式接入。

null


3.3 支持单域名多链接


Cronet 对一个域名仅支持建立一个 QUIC 链接,但在大多数 APP 的使用场景中域名固定,需要建立基于该域名下多个 IP 的 QUIC 链接,择优使用,于是我们做了进一步的改造。


Cronet 建立链接是用域名作为 session_key 的,所以一个域名只能同时建立一个链接,如果存在同域名的 session 直接复用,代码如下:

null

这显然不能满足我们的需求:


1)用户的网络变化会导致最优 IP 的变化,比如用户的网络从电信的 Wi-Fi 切换到联通的 4G,此时最优 IP 也会从电信切换到联通。

2)某个 IP 对应的机房发生故障需要立马切换到其他 IP。

3)某些情况下,需要同时建立不同 IP 的多条链接来发送请求,对比不同链接的表现(成功率,耗时等),动态调整 IP 权重。


所以我们对链接的管理进行了订制化的改造:


1)对 HTTP request 增加 IP 参数,支持对不同请求指定任意 IP。

2)修改 Cronet QuicSessionKey 的重载方法,将指定的 IP+Host 做为 Session Key 以支持单域名多 IP 链接。


改造核心代码如下:

null


改造后的代码支持了单域名多链接,形成了 QUIC 链接池,能让开发者灵活的选取最优的链接进行使用,进一步降低了请求耗时,提升了请求成功率。


3.4 0 RTT 优化


0 RTT 是 QUIC 最让人心动的一个特性,没有握手延迟,直接发送请求数据。但由于负载均衡的存在和重放攻击的威胁使得我们必须对 Cronet 和 Nginx 进行定制化的改造才能完整的体验 0 RTT 带来的巨大的性能提升。


目前 Trip.com 的多数请求是回源到国内的,以一个纽约用户访问为例,纽约到上海的直线距离是 14000km,假设两地直连光纤,光的传输速度为 300000km/s,考虑折射率,光纤中的传输速度为 200000km/s,那么 1 个 RTT 则需要 14000/200000 *2 = 140ms。而实际上的传输链路很复杂,要远大于这个数字,所以 RTT 的减少对我们来说是至关重要的。首先让我们了解一下 0 RTT 的工作原理。


使用 TLS1.3 的情况下,首次建立链接,在发送真正的请求数据前 TCP 需要经过两个完整的 RTT(TLS1.2 需要 3 个 RTT),一次用于 TCP 握手,一次用于 TLS 加密握手。而 QUIC 由于 UDP 不需要建立链接,仅需要一次 TLS 加密握手,如下图所示。

null

多数情况下,在整个 APP 的生命周期内首次建链只会发生一次,之后客户端再需要建立链接会节省一个 RTT,这时候 QUIC 能以 0 RTT 的方式直接发送请求(Early Data)如下图所示:

null

QUIC 使用了 DH 加密算法,DH 加密算法比较复杂,在这里不做详细解释,有兴趣的可以参考这篇 wiki:《迪菲-赫尔曼密钥交换》。大概的原理是客户端和服务端各自生成自己的非对称公私钥对,并将公钥发给对方,利用对方的公钥和自己的私钥可以运算出同一个对称密钥。同时客户端可以缓存服务的公钥(以 SCFG 的方式),用于下次建立链接时使用,这个就是达成 0-RTT 握手的关键。客户端可以选择内存缓存或者磁盘缓存 SCFG。内存缓存在 APP 本次生命周期内有效,磁盘缓存可以长期有效。


但是 SCFG 中的 ticket 有时效性(比如设置为 24 小时),过了有效期,Client 发起 0 RTT 请求会收到 Server 的 reject,然后重新握手,这反而增加了建立链接开销。Trip.com 是旅游类的低频 APP,所以使用了内存缓存,对于社交/视频/本地生活等高频类 APP 可以考虑使用磁盘缓存。


0 RTT 开启后我们实验观察请求耗时并没有明显降低。通过 Wireshark 抓包发现 GET 请求和 POST 请求的 0 RTT 方式并不一致。


POST 请求的 0 RTT 如下图所示,客户端会同时向服务发送 Initial 和 0 RTT 包,但是并没有发送真正的应用请求数据(Early Data),而是等服务返回后再同时发送 Handshake 完成包及数据请求包。这说明 POST 是在 TLS 加密完成后才开始发送请求数据,依然经历了一次完整的 RTT 握手,虽然握手包大小和数量相对于首次建立链接有所减少,但是 RTT 并没减少。

null


GET 请求的 0 RTT 如下图所示,客户端同时向服务发送 Initial 和两个 0 RTT 包,其中第二个 0 RTT 包中携带了 early_data,即真正的请求数据。

null


深究其原因会发现这是 0 RTT 不具备前向安全性和容易受到重放攻击导致的。这里重点说一下重放攻击,如下图所示,如果用户被诱导往某个账户里转账 0.1 元,该请求正好是发生在 0 RTT 阶段,即 early_data 里携带的正好是一个转账类的请求,并且该请求如果被攻击者监听到,攻击者不断的向服务发送同样的 0 RTT 包重放这个请求,会导致客户的银行卡余额被掏空!

null

对于 Cronet 来说无法细化哪个请求使用 Early Data 是安全的,只能按照类型划分,POST,PUT 请求均是等握手结束后再发请求数据,而 GET 请求则可以使用 Early Data。注意,握手结束后的数据是前向安全的, 此时会再生成一个临时密钥用于后续会话。


但对 Trip.com APP 而言我们可以做更加细分的处理,能较好的区分出是否为幂等请求,对幂等类请求放开 Early Data,非幂等类则禁止。在 APP 中,大多数请求为信息获取类的幂等请求,因此可以充分利用 0 RTT 来减少建立链接耗时,提升网络性能。


同时我们也对 Nginx 做了 0 RTT 改造。现实情况下服务是多机部署,通过负载均衡设备进行请求转发的。由于每台机器生成的 SCFG 并不一致(即生成的公私钥对不唯一),当客户端 IP 地址发生变化,重新建立链接时,请求会随机打到任意一台机器上,如果与首次建立链接的机器不一致则校验失败,nginx 会返回 reject,然后客户端会重新发起完整的握手请求建立链接。具体的改造方式参照我们在服务端的 QUIC 应用和优化实践一文。


简单的来说,通过改造,保证所有机器的 SCFG 一致。目前 Trip.com 0 RTT 成功率在 99.9%以上。


上面的两条完成后,再对比一下:

1)正常的 Http2.0 请求在发送请求前,需要经过 DNS 解析+TCP 三次握手(1 个 RTT)+TLS 加密握手(TLS1.2 需要 2 个 RTT,TLS1.3 需要 1 个 RTT),共 2 个 RTT(TLS1.2 共 3 个 RTT)。


2)自研的 TCP 框架需要经历 TCP 三次握手共 1 个 RTT。


3)经过我们优化后的 QUIC 大多数情况下发送请求前只需要 0 RTT。


使用改造后的 QUIC,在 Trip.com APP 中,用户建立链接的耗时约等于 0,极大的降低了请求耗时。


3.5 链接迁移改造


QUIC 的链接迁移能让用户在网络变化(NAT rebinding 或者网络切换)时保持链接不断开,但因为负载均衡的存在会使用户网络变化时请求转发到不同的服务器上导致迁移失败,因此需要做一些定制化的改造才能体验这一特性带来的用户体验提升。


TCP 链接是基于五元组的,客户端 IP 或者端口号发生变化都会导致链接断开请求失败。大家生活中的网络情况日趋复杂,经常在不同的 Wi-Fi、蜂窝网之间来回切换,如果每次切换都出现失败必然是非常影响体验的。


而 QUIC 的链接标识是一个 20 字节的 connection id。用户网络发生变化时(无论是 IP 还是端口变化),由于链接标识的唯一性,无需创建新的链接,继续用原有链接发送请求。这种用户无感的网络切换就是链接迁移。下图是链接迁移的工作流程:

null


名词解释:


Probing Frame 是指具有探测功能的 Frame,比如 PATH_CHALLENGE, PATH_RESPONSE, NEW_CONNECTION_ID, PADDING 均为 Probing Frame。


一个 Packet 中只包含 Probing Frame 则称为 Probing packet,其他 Packet 则称为 Non Probing Packet。


如上图所示,开始时用户和服务正常通信。某个时间点用户的网络从 WI-FI 切换到 4G,并继续正常向服务发送请求,服务检测到该链接上客户端地址发生变化,开始进行地址验证。即生成一个随机数并加密发给客户端(Path_Challenge),如果客户端能解密并将该随机数发回给服务(Path_Response),则验证成功,通信恢复。


但由于负载均衡的存在会使用户网络变化时请求转发到不同机器上导致迁移失败,我们首先想到的是修改负载均衡的转发规则,利用 connection id 的 hash 进行转发似乎就可以解决这个问题,但是 QUIC 的标准规定链接迁移时 connection id 也必须进行更改,同时建立链接前初始化包中的 connection id 以及链接建立完成后的 connection id 也不一致,所以此方案也行不通。最终我们通过两个关键点的改造实现了链接迁移:


1)修改 connection id 的生成规则,将本机的特征信息加入到 connection id 中;

2)增加 QUIC SLB 层,该层仅针对 connection id 进行 UDP 转发,当链接迁移发生时如果本机缓存中不存在则直接从 connection id 中解析出具体的机器,找到对应的机器后进行转发,如下图所示:


null


改造细节也可参照服务端的 QUIC 应用和优化实践一文。


通过链接迁移的改造,Trip.com 用户不会再因为 NAT rebinding 或者网络切换导致请求失败,提升了请求成功率,改善了用户体验。


3.6 QPACK 优化


QPACK 即 QUIC 头部压缩,复用了 HTTP2/HPACK 的核心概念,但是经过重新设计,保证了 UDP 无序传输下的正确性。QPACK 有灵活的实现方式,目标是以更少的头部阻塞来接近 HPACK 的压缩率。而我们的改造能使得 Trip.com APP 的请求压缩率和头部阻塞均达到最优。


nginx 要开启 QPACK 动态表,需要指定两个参数:


1)http3_max_table_capacity 动态表大小,Trip.com 指定为 16K;


2)http3_max_blocked_streams 最大阻塞流数量,如果指定为 0,则禁用了 QPACK 动态表;


伴随着 QPACK 动态表的开启,HTTP3 是会出现头部阻塞的(目前发现的唯一一个 QUIC 中头部阻塞的场景),QPACK 是如何工作,在 Trip.com APP 中如何做才能让 QPACK 既能拥有高压缩率又能避免头部阻塞呢?


我们知道 HTTP header 是由许多 field 组成的,比如下图是一个典型的 HTTP header。:authority: www.trip.com 就是其中一个 field。:authority 为 field name,www.trip.com 是 field value。


null


当 QUIC 链接建立后,会初始化两个单向 stream,Encoder Stream 和 Decoder Stream。一旦建立,这两个 stream 是不能关闭的,之后 HTTP header 动态表的更新就在这两个 stream 上进行。我们以发送 request 为例,客户端维护了一张动态表,并通过指令通知服务端进行更新,以保持客户端和服务端的动态表完全一致,如图所示:


null

我们可以看到 encoder 和 decoder 共享一张静态表,这张表是由 ietf 标准规定的,服务和客户端写死在代码里永远不会变的,表的内容固定为 99 个字段,截取部分示例:

null

而动态表初始为空,有需要才会插入。


假如我们连续发送三个请求 request A,B,C,三个请求的 Http Header 均为:


{ :authority: www.trip.com :method: POST cookie:this is a very large cookie maybe more than 2k x-trip-defined-header-field-name: trip defined headerfield value}


发送 request A 之前客户端会将 header 做第一次压缩,主要是用静态表进行替换,并将某些数据插入动态表。规则为:


1)静态表存在完全匹配(name+value 完全一致),则直接替换为静态表的 index,比如:method: POST 直接替换为 20;


2)动态表存在完全匹配,则直接替换为动态表 index,首次请求动态表为空;


3)静态表存在 name 匹配,则 name 替换为 index,value 插入动态表,比如:authority:www.trip.com 会替换为 0: www.trip.com,其中 www.trip.com 会插入动态表,假设在动态表中的 index 为 1,我们用 d1 表示动态表中的 index 1;


4)动态表存在 name 匹配,则 name 替换为 index,value 插入动态表;


5)均不存在,name 和 value 均插入动态表,比如 x-trip-defined-header-field-name: trip defined header field value 会整条插入;


客户端本地插入动态表后必须要向服务端发送指令同步更新服务端动态表。经过首次压缩,header 变为:


{ 0: d1, 20, 5:d2, d3,}


替换后的 header 已经非常小了,但是 QPACK 还会对替换后的 header 进行二次 encode。具体压缩代码如下:

null

其中 SecondPassEncode 会对所有的 field 再次进行处理,不同类型字段处理方式不同,比如对 string 类型进行 huffman 压缩。

null


null


二次 encode 后就只有几个字节了。所以我们用 wireshark 抓包会发现 http header 非常小,小到只有一两个字节,这就是 QPACK 压缩的威力。


当压缩后的 header 传到服务端时,服务端找到解析 header 需要的最大的动态表 index,目前是 d3,如果比当前的动态表最大 index 还要大,说明动态表插入请求还没收到,这是 UDP 传输的无序性导致的,需要进行等待。


等待期间 Request B, C 的请求也到了,他们的 header 是一致的,但是都没法解析,因为 requestA 的动态表插入请求还没收到,于是出现了头部阻塞。nginx 有 http3_max_blocked_streams 字段配置允许阻塞的 stream 数量,如果超过了,后续的请求不会等待动态表的插入而是直接将完整的字段 x-trip-defined-header-field-name: trip defined header field value 压缩后发送给服务端。


正常使用 QPACK 只需要做好配置就 ok 了,但是 Trip.com APP 中有些特殊的 Http header 字段,比如 x-xxxx-id: GUID . 这类字段的 value 是每次变化的 GUID,用作请求的唯一标识或用来对请求进行链路追踪等。由于这类字段的 value 每次变化,导致动态表频繁插入很快就会超过阈值,动态表超过阈值后会对老的字段进行清理,删除后如果后续请求又用到了这些字段则还需要再次进行插入。动态表的频繁插入删除则会加重头部阻塞。


所以针对这些 value 一定会变化的字段,我们需要做特殊处理,这类字段的 value 不插入动态表,即不以动态表索引的方式进行替换,只做二次 encode 压缩处理如下(代码较长不做完整展示):


null


改造后的 QPACK 在最佳压缩率和头部阻塞之间取得了平衡,减少了请求 size 的同时加快了请求速度,进一步提升了用户体验。

3.7 拥塞控制算法对比


Cronet 内置了 CUBIC,BBR,BBRV2 三种拥塞控制算法,我们可以根据需求灵活的选择,也可以插拔方式的使用其他拥塞控制算法。经过线上实验对比,在 Trip.com APP 场景中,BBR 性能优于 CUBIC、BBRV2。所以目前 Trip.com 默认使用 BBR。后续也会引入其他拥塞控制算法进行对比,并持续优化。


3.8 使用方式优化


在生产实验中我们发现,QUIC 并不合适所有的网络状况,所以我们不是用 QUIC 完全的替换原有 TCP 框架,而是做了有机融合,择优使用。


以下是两种比较常见的不支持 QUIC 场景:


1)某些办公网 443 端口会直接禁止 UDP 请求;

2)某些网络代理类型不支持 QUIC;


为了能适配各种网络环境,保证请求成功率,我们对 QUIC 的使用方式也进行了优化。


null


上图是目前 Trip.com 客户端的网络框架,APP 启动或网络变化时会通过一定的权重计算,选择最优的协议(TCP 或 QUIC)进行使用,并且进一步选择最优的 Server IP 预建立链接池,当有业务请求需要发送时,从链接池里选择最优链接进行发送。


改造后的使用方式充分利用了 TCP 和 QUIC 在不同网络环境下的优势,保证了用户请求的成功率,并能在各种复杂的网络环境下取得最佳的发送速度。目前 Trip.com APP 80%以上的网络请求通过 QUIC 进行发送,私有 TCP 协议和 Http2.0 作为补充,整体的成功率和性能得到了很大的提升。

四、总结和规划

由于 QUIC 具有精细的流量控制,可插拔式的拥塞算法,0 RTT,链接迁移,无队头阻塞的多路复用等诸多优点,已经被越来越多的厂商应用到生产环境,并取得了非常显著的成果。


我们也通过一年多的实践,深入了解了 QUIC 的优点和适用场景,并通过定制化的改造使得网络性能得到了极大的提升。但由于配套不完善,目前市面上所有的 QUIC 都无法达到开箱即用的效果。所以我们也希望贡献自己的力量,尽快开源改造后的整套网络方案,能让开发者可以不进行任何改动就能体验到 QUIC 带来的提升,实现真正的开箱即用。请大家持续关注我们。

如果文章对你有帮助,别忘记评论、点赞、Get!

文章为作者独立观点,不代表BOSS直聘立场。未经账号授权,禁止随意转载。