客户端网络优化(一) - 原理篇

3,394 阅读16分钟

0x01 前言

网络优化是客户端技术方向中公认的一个深度领域,对于 App 性能和用户体验至关重要。本文除了 DNS 、连接和带宽方面的优化技术外,会结合着优化的一些实践,以及在成本和收益的衡量,会有区别于市面上其他的分享,希望对大家有所帮助。

为什么优化

肯定有同学会有疑问,网络请求不就是通过网络框架 SDK 去服务端请求数据吗,尤其在这个性能过剩的年代,时间都不会差多少,我们还有没有必要再去抠细节做优化了,废话不多说咱们直接看数据,来证明优化的价值

  • BBC 发现网站加载时间每延长 1 秒 用户便会流失 10%

  • Google 发现页面加载时间超过 353% 的用户将停止访问

  • Amazon 发现加载时间每延长 1 秒一年就会减少 16 亿美元的营收

如何优化

想知道如何优化,首先我们需要先确定优化方向:

  • 提高成功率
  • 减少请求耗时
  • 减少网络带宽

接下来我们了解下 https 网络连接流程,如下图:

connection.png

从上图我们能清晰的看出 https 连接经历了以下几个流程:

  • DNS Query: 1 个 RTT
  • TCP 需要经历 SYNSYN/ACKACK 三次握手1.5个RTT,不过 ACKClientHello 合并了: 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流程如下图所示:

local_dns.png

HttpDNS 优先通过 HTTP 协议与自建 DNS 服务器交互,如果有问题再降级到运营商的 LocalDNS 方案,既有效防止域名劫持,提高域名解析效率又保证了稳定可靠,HttpDNS 的原理如下图所示:

httpdns.png

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/2HTTP/1.1 到底快了多少。

http1_2.png

这是 Akamai 公司建立的一个官方的演示,用以说明 HTTP/2 相比于之前的 HTTP/1.1 在性能上的大幅度提升。同时请求 379 张图片,从加载时间 的对比可以看出HTTP/2在速度上的优势。

HTTP/2的多路复用技术代替了原来的序列和阻塞机制。 HTTP/1.x 中,如果想并发多个请求,必须使用多个 TCP 链接,且浏览器为了控制资源,还会对单个域名有连接数量限制,有了二进制分帧之后,HTTP/2不再依赖 TCP 链接去实现多流并行了,所有请求都是通过一个 TCP 连接并发送完成,利用请求优先级解决关键请求阻塞问题,使得重要的请求优先得到响应。这样更容易实现全速传输,减少 TCP 慢启动时间,提高传输的速度。传输流程如下图:

multiplexing.png

说了这么多优点,那多路复用有缺点么,主要是受限TCP的限制,一般来说同一域名下只需要使用一个 TCP 连接。但当连接中出现频繁丢包情况,就会有队头阻塞问题,所有的包都会等待丢包重传成功后传输,这样HTTP/2的表现情况反倒不如 HTTP/1.1了,HTTP/1.1还可以通过多个 TCP 连接并行传输数据。

域名合并

随着开发规模逐渐扩大,各业务团队出于独立性和稳定性的考虑,纷纷申请了自己的接口域名,域名会越来越多。我们知道 HTTP 属于应用层协议,在传输层使用 TCP 协议,在网络层使用IP协议,所以 HTTP 的长连接本质上是 TCP 长连接,是对一个域名( IP )来说的,如果域名过多面临下面几个问题:

  • 长连接的复用效率降低
  • 每个域名都需要经过 DNS 服务来解析服务器 IP。
  • HTTP 请求需要跟不同服务器同时保持长连接,增加了客户端和服务端的资源消耗。

具体方案如下图所示

domain_merge.png

TLS-v1.2 会话恢复

会话恢复主要是通过减少 TLS 密钥协商交换的过程,在第二次建连时提高连接效率 2-RTT -> 1-RTT 。具体是如何实现的呢?包含两种方式,一种是 Sesssion ID,一种是 Session Ticket。下面讲解了 Session Ticket 的原理:

img

  • 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.3TLS/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

  • 首次握手流程

    img

  • 会话恢复-0RTT

    TLS/1.3 中更改了会话恢复机制,废除了原有的 Session IDSession 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 data0-RTT 会话恢复模式如下:

    img

HTTP/3

QUIC 首次在2013年被提出,互联网工程任务组在 2018 年的时候将基于 QUIC 协议的 HTTP (HTTP over QUIC) 重命名为 HTTP/3。不过目前 HTTP/3 还没有最终定稿,最新我看已经到了第 34 版,应该很快就会发布正式版本。某些意义上说 HTTP/3 协议实际上就是IETF QUICQUICQuick UDP Internet Connections,快速 UDP 网络连接) 基于 UDP,利用 UDP 的速度与效率。同时 QUIC 也整合了 TCPTLSHTTP/2 的优点,并加以优化。用一张图可以清晰地表示他们之间的关系。

HTTP/3主要带来了零 RTT 建立连接、连接迁移、队头阻塞/多路复用等诸多优势,具体细节暂不介绍了。推出HTTP/3(QUIC)的原理与实践,敬请期待。

0x04 带宽优化

HTTP/2 头部压缩

HTTP1.xheader 中的字段很多时候都是重复的,例如 method:getscheme:https 等。随着请求增长,这些请求中的冗余标头字段不必要地消耗带宽,从而显著增加了延迟,因此,HTTP/2 头部压缩技术应时而生,使用的压缩算法为 HPACK。借用 Google 的性能专家 Ilya Grigorik 在 Velocity 2015 • SC 会议中分享的一张图,让我们了解下头部压缩的原理:

head_compress1.png

上述图主要涉及两个点

  • 静态表中定义了61个 header 字段与 Index,可以通过传输 Index 进而获取 header,极大减少了报文大小。静态表中的字段和值固定,而且是只读的。详见静态表
  • 动态表接在静态表之后,结构与静态表相同,以先进先出的顺序维护的 header 字段列表,可随时更新。同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好。所以尽量上面提到的域名合并,不仅提升连接性能也能提高带宽优化效果

简单描述一下 HPACK 算法的过程:

  • 发送方和接受方共同维护一份静态表和动态表
  • 发送方根据字典表的内容,编码压缩消息头部
  • 接收方根据字典表进行解码,并且根据指令来判断是否需要更新动态表

看完了 HTTP/2 头部压缩的介绍,那在没有头部压缩技术的 HTTP/1 时代,当处理一些通用参数时,我们当时只能把参数压缩后放入 body 传输,因为 header 只支持 ascii 码,压缩导致乱码,如果放入 header 还得 encode 或者 base64 编码,不仅增大了体积还要浪费解码的性能。

数据表明通用参数通过 HTTP/2header 传输,由于长连通道大概在 90%+ 复用比例,压缩率可以达到惊人的 90%,同样比压缩后放在 body 中减少约 50% 的 size,如果遇到一些无规则的文本数据,zip 压缩率也会随着变低,这时候提升将会更明显。说了这么多,最后让我们通过抓包看下,HTTP/2 头部压缩的效果吧:

head_compress2.png

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 的序列化逻辑吧,Personage 字段取值为 1 的话,类型为 int32 则对应的编码是:0x08 0x01age 的类型是 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 ProtobufferJson 小 30%左右,压缩后 Size 差距 10%+

0x05 总结

上面说了这么多技术的原理,接下来分享下我们的技术选型以及思考,希望能给大家带来帮助

  • HttpDNS

    我们的应用主要在国内使用,这个功能只对首次建连有提升,目前首次建连的比例较小,也就在5%左右,所以对性能提升较小;安全问题再全栈切了 https 后也没有那么突出;并且 HttpDNS 也会遇到一些技术难点,比如自建 DNS 需要维护一套聪明的服务端IP调度策略;客户端需要注意 IP 缓存 以及 IPV4 IPV6 的双栈探测和选取策略等,综合考虑下目前我们没有使用HttpDNS

  • 长连接&域名合并

    长连接和域名合并,这两个放在一起说下吧,因为他们是相辅相成的,域名合并后,不同业务线使用一个长连接通道,减少了TCP 慢启动时间,更容易实现全速传输,优势还是很明显的。

    长连接方案还是有很多的,涉及到 HTTP/1.1HTTP/2HTTP/3、自建长链等方式,最终我们选择了HTTP/2,并不是因为这个方案性能最优,而是综合来看这个方案最有优势,HTTP/1.1 就不用说了,这个方案早就淘汰了,而 HTTP/3 虽然性能好,但是目前阶段并没有完整稳定的前后端框架,所以只适合尝鲜,是我们接下来的一个目标,自建长链虽然能结合业务定制逻辑,达到最优的性能,但是比较复杂,也正是由于特殊的定制导致没办法方便的切换官方的协议(如HTTP/3

  • TLS 协议

    TLS 协议我们积极跟进了官方最新稳定版 TLS/1.3 版本的升级,客户端在 Android 10iOS 12.2 后已经开始默认支持 TLS/1.3,想在低系统版本上支持需要接入三方扩展库,由于支持的系统版本占比已经很高,后面也会越来越高,并且 TLS 协议是兼容式的升级,允许客户端同时存在TLS/1.2TLS/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,因为关联到数据格式的变动,需要前后端一起调整,所以改造历史业务成本会比较高有阻力,比较适合在新的业务上尝鲜,总结出一套最佳实践后,再去尝试慢慢改造旧的业务