【原创声明】:本文系原创作品,谢绝任何形式的未经授权的转载。如需转载,请先私信联系我获取许可,谢谢合作!
引言
在浩瀚的互联网世界中,几乎所有的Client/Server架构网络通信都依赖于HTTP请求,可以说,HTTP是现代互联网的基石。而在移动互联网应用中,HTTP请求往往是用户“点击” → “发起请求” → “展示数据”过程中耗时最长、失败率最高的步骤。提升HTTP请求的性能,对于提升用户体验和ROI转化率都至关重要。本文将从HTTP请求的成功率和响应时间两个维度,结合我维护的2000万+ DAU、亿级日请求量Android应用弱网优化经验,介绍如何逐步优化HTTP请求的性能。
一、明确方向
在讨论具体优化措施前我们先看两个简单的公式:
- 网络请求成功率
网络请求是否成功,我们可以理解为超时时间内,能否完成数据的传输。虽然“网络速度”无法改变,且我们面临的是各种弱网环境(如电梯、地铁或地下车库),但我们可以通过减小HTTP数据size或增加超时时间从而提高网络请求成功率。
- 网络请求响应时间
上面的公式展示了从发起HTTPS请求到最终接收到响应过程中,每个阶段可能涉及的时间消耗,我们可以通过分治的思想分别优化每阶段1的时间,从而做到减小整体网络请求响应时间。
二、准备工作
孔子曰:“工欲善其事,必先利其器。”——《论语·卫灵公》
本文使用OkHttp2作为基础的网络请求库。OkHttp是一个高效、可靠且功能强大的HTTP客户端,通过采用 责任链模式,为HTTP请求提供了一套灵活且可扩展的架构。
三、打点探测
在优化之前,我们首先需要进行埋点,以尽可能细的粒度记录网络请求各个阶段耗时。我们遵循“数据采集” → “根因分析” → “策略制定” → “优化实施”的闭环流程,借助OkHttp的EventListener精准定位性能瓶颈,进而实现针对性的HTTP请求优化。EventListener通过观察者模式同时结合了模版方法模式“Hook”了HTTP请求的各个阶段,从而更加精确统计请求各个阶段的耗时3。
我们可以将不同的HTTP URL为“关键Key”进行统计,从而分接口API统计,方便对比优化前后效果:
class TraceEventListenerDelegate(private val delegate: EventListener?) : EventListener() {
override fun callStart(call: Call) {
delegate?.callStart(call)
}
override fun callEnd(call: Call) {
delegate?.callEnd(call)
}
}
class TraceEventListener : EventListener() {
private var startTime = 0L
override fun callStart(call: Call) {
startTime = SystemClock.elapsedRealtime()
}
override fun callEnd(call: Call) {
val elapsedTime = SystemClock.elapsedRealtime() - startTime
}
}
class TraceEventListenerFactory : EventListener.Factory {
override fun create(call: Call): EventListener {
return if (Random.nextInt(0, 100) < 1) {
TraceEventListenerDelegate(TraceEventListener())
} else {
TraceEventListenerDelegate(null)
}
}
}
四、最佳实践
1. 握手时间优化
当发起全新的HTTPS请求时需要先进行TCP的三次握手,然后还要进行TLS的4次握手,也就是我们常说的HTTPS 7次握手,这个过程需要至少三个RTT,其中TCP三次握手1个RTT,TLS四次握手2个RTT(TLS1.1或TLS1.2),这个过程在弱网环境下耗时尤为明显。因此我们应当想办法避免或减少这个过程。
TCP Handshake
TLS Handshake
- 连接复用
HTTP的传输层使用的是TCP,对于已经建立连接的服务器,只需要传输数据即可;因此,针对同一个域名同一端口的多个HTTP请求,只需要在首次建立连接时进行HTTPS 7次握手,后续请求可以进行连接复用,从而节省了3个RTT的握手时间。OkHttp的ConnectionPool以及相关类负责实现连接复用的功能。
Kotlin /** * maxIdleConnections 最大空闲连接数 * keepAliveDuration 空闲连接最大存活时间 * timeUnit 时间单位 */ constructor( maxIdleConnections: Int, keepAliveDuration: Long, timeUnit: TimeUnit )
值得注意的是,我们每次创建OkHttpClient时默认会新建一个ConnectionPool,因此我们应该只创建一个全局唯一的ConnectionPool实例对象,在创建新OkHttpClient时传入这个全局唯一实例对象即可。当网络发生切换时(蜂窝网络和Wi-Fi相互切换),由于DNS结果可能会更新,旧的连接不一定是最佳路由路径,因此我们需要调用ConnectionPool的evictAll()方法手动清理连接池,并通过预连接机制创建新连接等待后续使用。
val client = OkHttpClient.Builder().connectionPool(connectionPool).build();
- 预连接机制
我们可以在Activity的onResume回调内通过ConnectionPool.connectionCount()判断当前可用的连接数量,如果可用连接数量为0则说明连接已经被超时回收。这时我们可以向服务器发送一个HEAD请求4,从而快速完成HTTPS的7次握手,当用户真正点击界面发起业务HTTP请求时,即可复用已经建立的连接,从而提升用户体验,这条优化措施在应用从后台回到前台的场景优化尤为明显。且由于HEAD请求一般由Nginx服务器处理,因此对服务器的影响几乎可以忽略。
- 收敛域名
对于一些复杂且庞大的业务场景,服务端也许会通过不同的域名切分不同的业务模块,这对于OkHttp的连接复用是不友好的,所以我们应尽量将不同的域名进行合并,从而最大程度的进行连接复用提升性能。如果需要对业务场景进行区分,可以通过增加业务命名的路径段(Path Segment)的方式区分不同业务,例如:www.example.com/v1/config 或 www.example.com/v1/feed
2. 消除无意义重定向
当客户端请求到一个重定向URL时,客户端需要根据服务器返回的Location字段解析出URL并再次发起HTTP请求,这个过程,这意味着客户端得到想要的结果需要至少进行两次HTTP请求,这样会极其耗时,如果重定向的URL是一个全新的域名,大概率需要进行DNS查询和HTTPS的七次握手。因此,我们应该尽量消除无意义的重定向,直接请求重定向后的URL。
3. Accept-Encoding添加Brotli
Brotli是Google推出的一种用于替代传统GZip的无损压缩算法,基于LZ77算法的一个现代变体、霍夫曼编码和二阶上下文建模。从下表我们可以看到,对于相同的压缩级别5,brotli在压缩率比gzip高8%的前提下,端侧解压缩速度快26%。需要注意的是,Nginx需要添加Brotli的相关插件才能与客户端Content negotiation协商成功。
| Codec6 | Level | Compression Ratio | Compression Speed | Decompression Speed |
|---|---|---|---|---|
| brotli | 2 | 2.77 | 78.93 MiB/s | 256.88 MiB/s |
| brotli | 4 | 2.84 | 55.03 MiB/s | 255.38 MiB/s |
| brotli | 6 | 3.15 | 16.34 MiB/s | 262.37 MiB/s |
| brotli | 8 | 3.21 | 6 MiB/s | 264.09 MiB/s |
| gzip | 2 | 2.53 | 57.59 MiB/s | 200.3 MiB/s |
| gzip | 4 | 2.62 | 42.89 MiB/s | 202.84 MiB/s |
| gzip | 6 | 2.68 | 21.68 MiB/s | 208.9 MiB/s |
| gzip | 8 | 2.69 | 9.59 MiB/s | 210.25 MiB/s |
| deflate | 2 | 1.90 | 37.06 MiB/s | 143.87 MiB/s |
| deflate | 4 | 1.95 | 28.17 MiB/s | 150.01 MiB/s |
| deflate | 6 | 1.99 | 14.59 MiB/s | 154.48 MiB/s |
| deflate | 8 | 1.99 | 9.83 MiB/s | 155.73 MiB/s |
好消息是OkHttp已经为我们提供了Brotli的Interceptor,详情请参考:github.com/square/okht…
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(BrotliInterceptor.INSTANCE)
.build();
4. 使用HTTP2
HTTP/2 的主要优势
- 二进制传输:
HTTP/1.1 是基于文本协议,解析复杂。HTTP/2 将所有消息转为二进制帧,传输和解析更高效、可靠。 - 多路复用:
同一个 TCP 连接中可同时发送多个请求和响应,互不阻塞。解决了 HTTP/1.1 的「队头阻塞」(Head-of-Line Blocking)问题。这个特性在应用同时发送多个网络请求(例如:冷启动场景)表现尤为明显。 - 头部压缩:
使用 HPACK 算法压缩请求与响应头,显著减少重复字段带来的冗余开销。
5. 适当增加超时时间
我们回到网络请求成功率这个公式,我们可以看出,在数据量不变的前提下,超时时间越大,对预期网络传输速度的大小要求也就越小。同时,需要澄清一点,增大超时时间并不会影响现有可以请求成功的请求完成时间,就行龟兔赛跑,兔子快速跑到终点后,我们宣布比赛结束,这时的请求成功率为50%。但是我们可以等乌龟跑完再宣布比赛结束(增加超时时间),这时兔子的比赛成绩不会改变,但是请求成功率却增加到了100%。
6. HttpDNS
HTTPDNS是面向多端应用(移动端APP,PC客户端应用)的基于HTTP/HTTPS协议的域名解析服务,可以有效解决传统域名解析容易被劫持、解析不准确、更新不及时、服务不稳定等问题,从而实现精准调度,解决移动互联网服务中域名解析异常带来的困扰。
-
安全防劫持:
HTTP/HTTPS 查询结果不易被篡改。 -
更精准的调度:
HttpDNS 服务器可以根据用户真实IP做智能解析,让用户访问离自己最优的节点,RTT更低,请求响应更快。 -
支持自定义策略:
比如失败切换、优先级路由、流量调度等。 -
跨平台一致性:
移动端 App、IoT 设备等都能自己做域名解析,不依赖操作系统。
7. TLS优化
客户端与服务器在通过TLS 交换数据之前,必须协商建立加密信道。协商内容包括TLS 版本、加密套件,必要时还会验证证书。然而,协商过程的每一步都需要一个分组在客户端和服务器之间往返一次,因而所有TLS 连接启动时都要经历一定的延迟。弱网环境下每增加一次往返都会增加较高的网络延迟,从而导致网络请求过慢甚至超时。如果舍弃TLS层又会增加数据被截获的风险,如何保证“安全”和“速度”兼得呢?
-
减少证书链的长度
确保证书链的长度最小,也就是确保信任链中不包含不必要的证书。由于服务器证书是在握手期间发送的,而服务端发送证书时,TCP连接大概率是一个处于“慢启动”算法阶段的新TCP连接。如果证书size超过了TCP的初始拥塞窗口,那么证书发送就会让握手多了一个RTT,从而导致服务器停下来等待客户端的ACK消息。 -
使用Session Ticket
完整TLS握手会带来额外的延迟和计算量,从而给所有依赖安全通信的应用造成。我们可以使用“Session Ticked”机制让客户端和服务器在重连时跳过完整握手过程,从而减少 RTT(往返时延)、提升性能。工作过程如下:
-
第一次握手时:
服务端生成一份“会话状态”(包含主密钥、算法等)。用一个只有服务端自己知道的加密密钥(Session Ticket Key) 加密这份状态。把加密结果发送给客户端(称为 Session Ticket)7 客户端保存这个Ticket(在Android BoringSSL中) -
下次重连时:
客户端在ClientHello中携带这个 Ticket。服务端解密Ticket,恢复出之前的会话参数。双方跳过完整握手,直接恢复加密通信。
-
-
启用TLSv1.3
相比于TLSv1.2 TLSv1.3是一次性能飞跃。-
Full Handshake
从TLSv1.2的2-RTT降低到1-RTT -
0-RTT 握手
在介绍0-RTT握手前,需要先声明0-RTT不是第一次连接时就能用的。它需要依赖之前的一次成功的"Full Handshake"连接。当客户端再次访问该服务器时: 客户端在ClientHello阶段就发送上次的Session Ticket、提前加密好的0-RTT 数据(Early Data) 服务端验证这个Session Ticket是否有效,如果有效服务端立即解密并处理0-RTT 数据(Early Data)
-
算法协商更快、密钥派生更安全
-
8. 业务逻辑优化
- 动态Feed流size
动态计算当前手机分辨率可以展示的Feed流数据条数,防止出现完整显示一屏幕数据需要请求两次Feed流或一次请求大于1.5屏(根据埋点确定用户习惯动态调整)数据的情况,从而造成多次请求,或数据请求冗余的情况。 - 接口数据合并
在保证业务逻辑解耦的前提下,尽量将一个页面的多个HTTP请求按业务逻辑进行合并,尤其是多个小数据量的HTTP请求,防止碎片化的HTTP请求增加传输时间。 - 接口数据拆分
如果对于单条数据量特别大的HTTP请求,我们也应该进行分页加载,甚至可以按照优先级拆分成多个接口分段刷新。 - 分段刷新
对于一个页面的多个HTTP请求,尽量不要等待所有HTTP请求成功后再刷新页面,建议进行分段刷新,减少空白页面给用户带来的"焦虑感"。 - 减少HTTP请求
任何网络请求都不如没有请求更快,因此去掉没有必要的请求,或者进行本地缓存也是一个不错的选择。
9. 其他优化
- 增加机房
对于有条件的公司可以选择在不同的地理位置适当布设机房(多点部署 / CDN 边缘节点),减少传输距离,从而降低RTT和丢包率。 - HTTP over CDN
对于一些短时间变化很小、QPS很高的接口API,我们可以将Response(例如:JSON)静态存入CDN,通过CDN来加速请求和响应。
五、总结
弱网场景是移动开发中不可避免的,我们要做的是直面困难勇于挑战,通过一次次不断的迭代,挖掘潜在的性能瓶颈,使我们的应用拥有更好的用户体验。
参考资料
- [1] 《Web性能权威指南》