作者简介
Logan,携程移动开发专家,关注大前端技术领域,对APP网络、性能、稳定性有深入研究。
Trip.com APP(携程国际版)主要服务于海外用户,这些用户请求大多需要回源至国内,具有链路长、网络不稳定、丢包率高等特性。为了解决用户请求耗时长、成功率低的痛点,在2021年初我们尝试引入QUIC来提升网络质量。经过近一年的优化实践,取得了显著的成果:网络耗时降低20%,成功率提升至99.5%,极大地改善了用户体验。本文将从客户端的视角详细介绍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直连方式接入。
3.3 支持单域名多链接
Cronet对一个域名仅支持建立一个QUIC链接,但在大多数APP的使用场景中域名固定,需要建立基于该域名下多个IP的QUIC链接,择优使用,于是我们做了进一步的改造。
Cronet建立链接是用域名作为session_key的,所以一个域名只能同时建立一个链接,如果存在同域名的session直接复用,代码如下:
这显然不能满足我们的需求:
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链接。
改造核心代码如下:
改造后的代码支持了单域名多链接,形成了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加密握手,如下图所示。
多数情况下,在整个APP的生命周期内首次建链只会发生一次,之后客户端再需要建立链接会节省一个RTT,这时候QUIC能以0 RTT的方式直接发送请求(Early Data)如下图所示:
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并没减少。
GET请求的0 RTT如下图所示,客户端同时向服务发送Initial和两个0 RTT包,其中第二个0 RTT包中携带了early_data,即真正的请求数据。
深究其原因会发现这是0 RTT不具备前向安全性和容易受到重放攻击导致的。这里重点说一下重放攻击,如下图所示,如果用户被诱导往某个账户里转账0.1元,该请求正好是发生在0 RTT阶段,即early_data里携带的正好是一个转账类的请求,并且该请求如果被攻击者监听到,攻击者不断的向服务发送同样的0 RTT包重放这个请求,会导致客户的银行卡余额被掏空!
对于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还是端口变化),由于链接标识的唯一性,无需创建新的链接,继续用原有链接发送请求。这种用户无感的网络切换就是链接迁移。下图是链接迁移的工作流程:
名词解释:
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中解析出具体的机器,找到对应的机器后进行转发,如下图所示:
改造细节也可参照服务端的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。
当QUIC链接建立后,会初始化两个单向stream,Encoder Stream 和 Decoder Stream。一旦建立,这两个stream是不能关闭的,之后HTTP header动态表的更新就在这两个stream上进行。我们以发送request为例,客户端维护了一张动态表,并通过指令通知服务端进行更新,以保持客户端和服务端的动态表完全一致,如图所示:
我们可以看到encoder和decoder共享一张静态表,这张表是由ietf标准规定的,服务和客户端写死在代码里永远不会变的,表的内容固定为99个字段,截取部分示例:
而动态表初始为空,有需要才会插入。
假如我们连续发送三个请求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。具体压缩代码如下:
其中SecondPassEncode会对所有的field再次进行处理,不同类型字段处理方式不同,比如对string类型进行huffman压缩。
二次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压缩处理如下(代码较长不做完整展示):
改造后的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的使用方式也进行了优化。
上图是目前Trip.com 客户端的网络框架,APP启动或网络变化时会通过一定的权重计算,选择最优的协议(TCP或QUIC)进行使用,并且进一步选择最优的Server IP预建立链接池,当有业务请求需要发送时,从链接池里选择最优链接进行发送。
改造后的使用方式充分利用了TCP和QUIC在不同网络环境下的优势,保证了用户请求的成功率,并能在各种复杂的网络环境下取得最佳的发送速度。目前Trip.com APP 80%以上的网络请求通过QUIC进行发送,私有TCP协议和Http2.0作为补充,整体的成功率和性能得到了很大的提升。
四、总结和规划
由于QUIC具有精细的流量控制,可插拔式的拥塞算法,0 RTT,链接迁移,无队头阻塞的多路复用等诸多优点,已经被越来越多的厂商应用到生产环境,并取得了非常显著的成果。
我们也通过一年多的实践,深入了解了QUIC的优点和适用场景,并通过定制化的改造使得网络性能得到了极大的提升。但由于配套不完善,目前市面上所有的QUIC都无法达到开箱即用的效果。所以我们也希望贡献自己的力量,尽快开源改造后的整套网络方案,能让开发者可以不进行任何改动就能体验到QUIC带来的提升,实现真正的开箱即用。请大家持续关注我们。
【推荐阅读】
“携程技术”公众号
分享,交流,成长