客户端网络优化(一)-原理篇
0x01 前言
网络优化是客户端技术方向中公认的一个深度领域,对于 App 性能和用户体验至关重要。本文除了 DNS
、连接和带宽方面的优化技术外,会结合着优化的一些实践,以及在成本和收益的衡量,会有区别于市面上其他的分享,希望对大家有所帮助。
为什么优化
肯定有同学会有疑问,网络请求不就是通过网络框架 SDK 去服务端请求数据吗,尤其在这个性能过剩的年代,时间都不会差多少,我们还有没有必要再去抠细节做优化了,废话不多说咱们直接看数据,来证明优化的价值
-
BBC
发现网站加载时间每延长 1 秒 用户便会流失 10% -
Google
发现页面加载时间超过 3 秒 53% 的用户将停止访问 -
Amazon
发现加载时间每延长 1 秒一年就会减少 16 亿美元的营收
如何优化
想知道如何优化,首先我们需要先确定优化方向:
- 提高成功率
- 减少请求耗时
- 减少网络带宽
接下来我们了解下 https
网络连接流程,如下图:
从上图我们能清晰的看出 https
连接经历了以下几个流程:
DNS Query
: 1 个RTT
TCP
需要经历SYN
,SYN/ACK
,ACK
三次握手1.5个RTT
,不过ACK
和ClientHello
合并了: 1 个RTT
TLS
需要经过握手和密钥交换: 2 个RTT
Application Data
数据传输
综上所述,一个 https
连接需要 4 个 RTT
才到数据传输阶段,如果我们能减少建连的 RTT
次数,或者降低数据传输量,会对网络的稳定和速度带来很大的提升。
0x02 DNS 优化
-
DNS & HttpDNS
DNS(Domain Name System,域名系统),DNS
用于用户在网络请求时,根据域名查出 IP 地址,使用户更方便的访问互联网。传统DNS面临DNS缓存、解析转发、DNS
攻击等问题,具体的DNS流程如下图所示:
HttpDNS
优先通过 HTTP
协议与自建 DNS
服务器交互,如果有问题再降级到运营商的 LocalDNS
方案,既有效防止域名劫持,提高域名解析效率又保证了稳定可靠,HttpDNS
的原理如下图所示:
HttpDNS优势
- 稳定性:绕过运营商
LocalDNS
,避免域名劫持,解决了由于Local DNS
缓存导致的变更域名后无法即时生效的问题 - 精准调度:避免
LocalDNS
调度不准确问题,自己做解析,返回最优服务端 IP 地址 - 缩短解析延迟:通过域名预解析、缓存
DNS
解析结果等方式实现缩短域名解析延迟的效果
综述
先来看看主要的两点收益
- 防止劫持,不过由于客户端大都已经全栈
HTTPS
了,HTTP
时代的中间人攻击已经基本没有了,但是还是有收益的。 - 解析延迟带来的速度提升,目前全栈
HTTP/2
后,大都已经是长连接,数据统计单域名下通过DNS Query
占比 5%+,DNS
解析平均耗时 80ms 左右,如果平摊到全量的网络请求HttpDNS
会带来 1% 左右的提升
上面的收益没有提到精准调度,是因为我们的 APP 流量主要在国内,国内节点相对丰富,服务整体质量也较高,即使出现调度不准确的情况,差值也不会太大,但如果在国外情况可能会差很多。
0x03 连接优化
长连接
长连接指在一个连接上可以连续发送多个数据包,做到连接复用 我们知道从 HTTP/1.1
开始就支持了长连接,但是 HTTP/2
中引入了多路复用的技术。 大家可以通过 该链接 直观感受下 HTTP/2
比 HTTP/1.1
到底快了多少。
这是 Akamai 公司建立的一个官方的演示,用以说明 HTTP/2
相比于之前的 HTTP/1.1
在性能上的大幅度提升。同时请求 379 张图片,从加载时间 的对比可以看出HTTP/2
在速度上的优势。
HTTP/2
的多路复用技术代替了原来的序列和阻塞机制。 HTTP/1.x
中,如果想并发多个请求,必须使用多个 TCP 链接,且浏览器为了控制资源,还会对单个域名有连接数量限制,有了二进制分帧之后,HTTP/2
不再依赖 TCP 链接去实现多流并行了,所有请求都是通过一个 TCP 连接并发送完成,利用请求优先级解决关键请求阻塞问题,使得重要的请求优先得到响应。这样更容易实现全速传输,减少 TCP 慢启动时间,提高传输的速度。传输流程如下图:
说了这么多优点,那多路复用有缺点么,主要是受限TCP的限制,一般来说同一域名下只需要使用一个 TCP 连接。但当连接中出现频繁丢包情况,就会有队头阻塞问题,所有的包都会等待丢包重传成功后传输,这样HTTP/2
的表现情况反倒不如 HTTP/1.1
了,HTTP/1.1
还可以通过多个 TCP 连接并行传输数据。
域名合并
随着开发规模逐渐扩大,各业务团队出于独立性和稳定性的考虑,纷纷申请了自己的接口域名,域名会越来越多。我们知道 HTTP
属于应用层协议,在传输层使用 TCP
协议,在网络层使用IP协议,所以 HTTP
的长连接本质上是 TCP
长连接,是对一个域名( IP )来说的,如果域名过多面临下面几个问题:
- 长连接的复用效率降低
- 每个域名都需要经过
DNS
服务来解析服务器 IP。 HTTP
请求需要跟不同服务器同时保持长连接,增加了客户端和服务端的资源消耗。
具体方案如下图所示
TLS-v1.2 会话恢复
会话恢复主要是通过减少 TLS 密钥协商交换的过程,在第二次建连时提高连接效率 2-RTT
-> 1-RTT
。具体是如何实现的呢?包含两种方式,一种是 Sesssion ID
,一种是 Session Ticket
。下面讲解了 Session Ticket
的原理:
-
Session ID
Session ID
类似我们熟知的Session
的概念。 由服务器端支持,协议中的标准字段,因此基本所有服务器都支持,客户端只缓存会话 ID,服务器端保存会话 ID 以及协商的通信信息,占用服务器资源较多。 -
Session Ticket
Session ID
存在一些弊端,比如分布式系统中的Session Cache
同步问题,如果不能同步则大大限制了会话复用效率,但 Session Ticket 没问题。Session Ticket
更像我们熟知的Cookie
的概念,Session Ticket
用只有服务端知道的安全密钥加密过的会话信息,保存在客户端上。客户端在ClientHello
时带上了Session Ticket
,服务器如果能成功解密就可以完成快速握手。
不管是 Session ID
还是 Session Ticket
都存在时效性问题,不是永久有效的。
TLS-v1.3
首先需要明确的是,同等情况下,TLS/1.3
比 TLS/1.2
少一个 RTT
时间。并且废除了一些不安全的算法,提升了安全性。首次握手,TLS/1.2
完成 TLS
密钥协商需要 2 个 RTT
时间,TLS/1.3
只需要 1 个 RTT
时间。会话恢复 TLS/1.2
需要 1 个 RTT
时间,TLS/1.3
则因为在第一个包中携带数据(early data),只需要 0 个 RTT
,有点类似 TLS
层的 TCP Fast Open
。
-
首次握手流程
-
会话恢复-0RTT
TLS/1.3
中更改了会话恢复机制,废除了原有的Session ID
和Session Ticket
的方式,使用PSK
的机制,并支持了 0RTT模式下的恢复模式(实现0-RTT
的条件比较苛刻,目前不少浏览器虽然支持TLS/1.3
协议,但是还不支持发送early data
,所以它们也没法启用0-RTT
模式的会话恢复)。当 client 和 server 共享一个PSK
(从外部获得或通过一个以前的握手获得)时,TLS/1.3
允许 client 在第一个发送出去的消息中携带数据("early data
")。Client 使用这个 PSK 生成client_early_traffic_secret
并用它加密early data
。Server 收到这个ClientHello
之后,用ClientHello
扩展中的PSK
导出client_early_traffic_secret
并用它解密early data
。0-RTT
会话恢复模式如下:
HTTP/3
QUIC
首次在2013年被提出,互联网工程任务组在 2018 年的时候将基于 QUIC
协议的 HTTP (HTTP over QUIC)
重命名为 HTTP/3
。不过目前 HTTP/3
还没有最终定稿,最新我看已经到了第 34 版,应该很快就会发布正式版本。某些意义上说 HTTP/3
协议实际上就是IETF QUIC
。QUIC
(Quick UDP Internet Connections
,快速 UDP
网络连接) 基于 UDP
,利用 UDP
的速度与效率。同时 QUIC
也整合了 TCP
、TLS
和 HTTP/2
的优点,并加以优化。用一张图可以清晰地表示他们之间的关系。
HTTP/3
主要带来了零 RTT
建立连接、连接迁移、队头阻塞/多路复用等诸多优势,具体细节暂不介绍了。推出HTTP/3(QUIC)
的原理与实践,敬请期待。
0x04 带宽优化
HTTP/2
头部压缩
HTTP1.x
的 header
中的字段很多时候都是重复的,例如 method:get
、scheme:https
等。随着请求增长,这些请求中的冗余标头字段不必要地消耗带宽,从而显著增加了延迟,因此,HTTP/2
头部压缩技术应时而生,使用的压缩算法为 HPACK
。借用 Google 的性能专家 Ilya Grigorik 在 Velocity 2015 ? SC 会议中分享的一张图,让我们了解下头部压缩的原理:
上述图主要涉及两个点
- 静态表中定义了61个
header
字段与Index
,可以通过传输Index
进而获取header
,极大减少了报文大小。静态表中的字段和值固定,而且是只读的。详见静态表 - 动态表接在静态表之后,结构与静态表相同,以先进先出的顺序维护的
header
字段列表,可随时更新。同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好。所以尽量上面提到的域名合并,不仅提升连接性能也能提高带宽优化效果
简单描述一下 HPACK
算法的过程:
- 发送方和接受方共同维护一份静态表和动态表
- 发送方根据字典表的内容,编码压缩消息头部
- 接收方根据字典表进行解码,并且根据指令来判断是否需要更新动态表
看完了 HTTP/2
头部压缩的介绍,那在没有头部压缩技术的 HTTP/1
时代,当处理一些通用参数时,我们当时只能把参数压缩后放入 body
传输,因为 header
只支持 ascii
码,压缩导致乱码,如果放入 header
还得 encode
或者 base64
编码,不仅增大了体积还要浪费解码的性能。
数据表明通用参数通过 HTTP/2
的 header
传输,由于长连通道大概在 90%+ 复用比例,压缩率可以达到惊人的 90%,同样比压缩后放在 body
中减少约 50% 的 size,如果遇到一些无规则的文本数据,zip
压缩率也会随着变低,这时候提升将会更明显。说了这么多,最后让我们通过抓包看下,HTTP/2
头部压缩的效果吧:
Json vs Protobuffer
Protobuffer
是 Google 出品的一种轻量 & 高效的结构化数据存储格式,性能比 Json
好,Size 比 Json
要小
-
Protobuffer
数据格式因为咱们这里说的是带宽,所以咱们详细说下 size 这块的优化,相比 json 来说,
Protobuffer
节省了字段名字和分隔符,具体格式如下:// tag: 字段标识号,不可重复
// type: 字段类型
// length: 字段长度,当data可以用Varint表示的时候不需要 (可选)
// data: 字段数据
<tag> <type> [<length>] <data> -
Protobuffer
数据编码方式Protobuffer
的数据格式不可避免的存在定长数据和变长数据的表示问题,编码方式用到了Varint & Zigzag
。 这里主要介绍下Varint
,因为Zigzag
主要是对于负数时的一个补充(VarInt
不太适合表示负数,有兴趣的同学可以自行查下资料 ) ,Varint
其实是一种变长的编码方式,用字节表示数字,征用了每个字节的最高位 (MSB
),MSB
是 1 话,则表示还有后序字节,一直到MSB
为 0 的字节为止,具体表示如下表:0 ~ 2^07 - 1 0xxxxxxx
2^07 ~ 2^14 - 2 1xxxxxxx 0xxxxxxx
2^14 ~ 2^21 - 3 1xxxxxxx 1xxxxxxx 0xxxxxxx
2^21 ~ 2^28 - 4 1xxxxxxx 1xxxxxxx 1xxxxxxx 0xxxxxxx不难看出值越小的数字,使用越少的字节数表示。如对于
int32
类型的数字,一般需要 4 个字节 表示,但是用了Varint
小于 128 一个字节就够了,但是对于大的int32
数字(大于2^28)会需要 5 个 字节 来表示,但大多数情况下,数据中不会有很大的数字,所以采用Varint
方法总是可以用更少的字节数来表示数字 -
具体示例
首先定义一个实体类
// bean
class Person {
int age = 1;
}Json
的序列化结果// json
{
"age":1
}Protobuffer 的序列化结果
// Protobuffer
00001000 00000001简单说下
Protobuffer
的序列化逻辑吧,Person
的age
字段取值为 1 的话,类型为int32
则对应的编码是:0x08 0x01
。age
的类型是int32
,对应的type
取 0。而它的tag
又是 1,所以第一个字节是(1 << 3) | 0 = 0x08
,第二个字节是数字 1 的VarInt
编码,即0x01
。// bean
class Person {
int age = 1;
}
// Protobuffer 描述文件
message Person {
int32 age = 1;
}
// protobuf值
+-----+---+--------+
|00001|000|00000001|
+-----+---+--------+
tag type data -
优化数据
原始数据 Size
Protobuffer
比Json
小 30%左右,压缩后 Size 差距 10%+
0x05 总结
上面说了这么多技术的原理,接下来分享下我们的技术选型以及思考,希望能给大家带来帮助
-
HttpDNS
我们的应用主要在国内使用,这个功能只对首次建连有提升,目前首次建连的比例较小,也就在5%左右,所以对性能提升较小;安全问题再全栈切了
https
后也没有那么突出;并且HttpDNS
也会遇到一些技术难点,比如自建DNS
需要维护一套聪明的服务端IP调度策略;客户端需要注意 IP 缓存 以及IPV4
IPV6
的双栈探测和选取策略等,综合考虑下目前我们没有使用HttpDNS
-
长连接&域名合并
长连接和域名合并,这两个放在一起说下吧,因为他们是相辅相成的,域名合并后,不同业务线使用一个长连接通道,减少了TCP 慢启动时间,更容易实现全速传输,优势还是很明显的。
长连接方案还是有很多的,涉及到
HTTP/1.1
、HTTP/2
、HTTP/3
、自建长链等方式,最终我们选择了HTTP/2
,并不是因为这个方案性能最优,而是综合来看这个方案最有优势,HTTP/1.1
就不用说了,这个方案早就淘汰了,而HTTP/3
虽然性能好,但是目前阶段并没有完整稳定的前后端框架,所以只适合尝鲜,是我们接下来的一个目标,自建长链虽然能结合业务定制逻辑,达到最优的性能,但是比较复杂,也正是由于特殊的定制导致没办法方便的切换官方的协议(如HTTP/3
) -
TLS 协议
TLS
协议我们积极跟进了官方最新稳定版TLS/1.3
版本的升级,客户端在Android 10
和iOS 12.2
后已经开始默认支持TLS/1.3
,想在低系统版本上支持需要接入三方扩展库,由于支持的系统版本占比已经很高,后面也会越来越高,并且TLS
协议是兼容式的升级,允许客户端同时存在TLS/1.2
和TLS/1.3
两个版本,综合考虑下我们的升级策略就是只升级服务端TLS
协议升级主要带来两个方面的提升,性能和安全。先来说下性能的提升,TLS/1.3
对比TLS/1.2
版本来说,性能的提升体现在减少握手时的一次RTT
时间,由于连接复用率很高,所以性能的提升和HttpDNS
一样效果很小。至于安全前面也有提到,废弃了一些相对不安全的算法,提升了安全性 -
带宽优化
带宽优化对于网络传输的速度和用户的流量消耗都是有帮助的,所以我们要尽量减少数据的传输,那么在框架层来说主要的策略有两种:
一是减少相同数据的传输次数,也就是对应着
HTTP/2
的头部压缩,原理上面也有介绍,这里就不在赘述了,对于目前长连接的通道,header
中不变化的数据只传输一个标识即可,这个的效果还是很明显的,所以框架可以考虑一些通用并且不长变化的参数放在Header
中传输,但是此处需要注意并不是所有的参数都适合在Header
中,因为Header
中的数据只支持ASCII
编码,所以有一些敏感数据放在此处会容易被破解,如果加密后在放进来,还需要在进行Base64
编码处理,这样可能会加大很多传输的 size,所以框架侧要进行取舍 (PS:在补充个Header
字段的小坑:HTTP/2 Header
都会转成小写,而HTTP/1.x
大小写均可,在升级HTTP/2
协议的时候需要注意下)二是减少传输 size,常规的做法如切换更省空间的数据格式
Protobuffer
,因为关联到数据格式的变动,需要前后端一起调整,所以改造历史业务成本会比较高有阻力,比较适合在新的业务上尝鲜,总结出一套最佳实践后,再去尝试慢慢改造旧的业务