写点什么

Android 客户端网络预连接优化机制探究

发布于: 2021 年 06 月 17 日

一、背景


一般情况下,我们都是用一些封装好的网络框架去请求网络,对底层实现不甚关注,而大部分情况下也不需要特别关注处理。得益于因特网的协议,网络分层,我们可以只在应用层去处理业务就行。但是了解底层的一些实现,有益于我们对网络加载进行优化。本文就是关于根据 http 的连接复用机制来优化网络加载速度的原理与细节。


二、连接复用


对于一个普通的接口请求,通过 charles 抓包,查看网络请求 Timing 栏信息,我们可以看到类似如下请求时长信息:


  • Duration 175 ms

  • DNS 6 ms

  • Connect 50 msTLS Handshake 75 ms

  • Request 1 ms

  • Response 1 ms

  • Latency 42 ms


同样的请求,再来一次,时长信息如下所示:


  • Duration 39 ms

  • DNS -

  • Connect -

  • TLS Handshake -

  • Request 0 ms

  • Response 0 ms

  • Latency 39 ms


我们发现,整体网络请求时间从 175ms 降低到了 39ms。其中 DNS,Connect,TLS Handshake 后面是个横线,表示没有时长信息,于是整体请求时长极大的降低了。这就是 Http(s)的连接复用的效果。那么问题来了,什么是连接复用,为什么它能降低请求时间?


在解决这个疑问之前,我们先来看看一个网络请求发起,到收到返回的数据,这中间发生了什么?


  • 客户端发起网络请求

  • 通过 DNS 服务解析域名,获取服务器 IP (基于 UDP 协议的 DNS 解析)

  • 建立 TCP 连接(3 次握手)

  • 建立 TLS 连接(https 才会用到)

  • 发送网络请求 request

  • 服务器接收 request,构造并返回 response

  • TCP 连接关闭(4 次挥手)


上面的连接复用直接让上面 2,3,4 步都不需要走了。这中间省掉的时长应该怎么算?如果我们定义网络请求一次发起与收到响应的一个来回(一次通信来回)作为一个 RTT(Round-trip delay time)。


1)DNS 默认基于 UDP 协议,解析最少需要 1-RTT;


2)建立 TCP 连接,3 次握手,需要 2-RTT;



(图片来源自网络)


3)建立 TLS 连接,根据 TLS 版本不同有区别,常见的 TLS1.2 需要 2-RTT。


 Client                                               ServerClientHello                  -------->                                                ServerHello                                               Certificate*                                         ServerKeyExchange*                                        CertificateRequest*                             <--------      ServerHelloDoneCertificate*ClientKeyExchangeCertificateVerify*[ChangeCipherSpec]Finished                     -------->                                         [ChangeCipherSpec]                             <--------             FinishedApplication Data             <------->     Application Data                   TLS 1.2握手流程(来自 RFC 5246)
复制代码


注:TLS1.3 版本相比 TLS1.2,支持 0-RTT 数据传输(可选,一般是 1-RTT),但目前支持率比较低,用的很少。


http1.0 版本,每次 http 请求都需要建立一个 tcp socket 连接,请求完成后关闭连接。前置建立连接过程可能就会额外花费 4-RTT,性能低下。


http1.1 版本开始,http 连接默认就是持久连接,可以复用,通过在报文头部中加上 Connection:Close 来关闭连接 。如果并行有多个请求,可能还是需要建立多个连接,当然我们也可以在同一个 TCP 连接上传输,这种情况下,服务端必须按照客户端请求的先后顺序依次回送结果。


注:http1.1 默认所有的连接都进行了复用。然而空闲的持久连接也可以随时被客户端与服务端关闭。不发送 Connection:Close 不意味着服务器承诺连接永远保持打开。


http2 更进一步,支持二进制分帧,实现 TCP 连接的多路复用,不再需要与服务端建立多个 TCP 连接了,同域名的多个请求可以并行进行。



(图片来源自网络)


还有个容易被忽视的是,TCP 有拥塞控制,建立连接后有慢启动过程(根据网络情况一点一点的提高发送数据包的数量,前面是指数级增长,后面变成线性),复用连接可以避免这个慢启动过程,快速发包。


三、预连接实现


客户端常用的网络请求框架如 OkHttp 等,都能完整支持 http1.1 与 HTTP2 的功能,也就支持连接复用。了解了这个连接复用机制优势,那我们就可以利用起来,比如在 APP 闪屏等待的时候,就预先建立首页详情页等关键页面多个域名的连接,这样我们进入相应页面后可以更快的获取到网络请求结果,给予用户更好体验。在网络环境偏差的情况下,这种预连接理论上会有更好的效果。


具体如何实现?


第一反应,我们可以简单的对域名链接提前发起一个 HEAD 请求(没有 body 可以省流量),这样就能提前建立好连接,下次同域名的请求就可以直接复用,实现起来也是简单方便。于是写了个 demo,试了个简单接口,完美,粗略统计首次请求速度可以提升 40%以上。


于是在游戏中心 App 启动 Activity 中加入了预连接相关逻辑,跑起来试了下,竟然没效果...


抓包分析,发现连接并没有复用,每次进去详情页后都重新创建了连接,预连接可能只是省掉了 DNS 解析时间,demo 上的效果无法复现。看样子分析 OkHttp 连接复用相关源码是跑不掉了。


四、源码分析


OKHttp 通过几个默认的 Interceptor 用于处理网络请求相关逻辑,建立连接在 ConnectInterceptor 类中;


public final class ConnectInterceptor implements Interceptor {  @Override public Response intercept(Chain chain) throws IOException {    RealInterceptorChain realChain = (RealInterceptorChain) chain;    Request request = realChain.request();    StreamAllocation streamAllocation = realChain.streamAllocation();    // We need the network to satisfy this request. Possibly for validating a conditional GET.    boolean doExtensiveHealthChecks = !request.method().equals("GET");    HttpCodec httpCodec = streamAllocation.newStream(client, chain, doExtensiveHealthChecks);    RealConnection connection = streamAllocation.connection();    return realChain.proceed(request, streamAllocation, httpCodec, connection);  }}
复制代码


RealConnection 即为后面使用的 connection,connection 生成相关逻辑在 StreamAllocation 类中;


public HttpCodec newStream(      OkHttpClient client, Interceptor.Chain chain, boolean doExtensiveHealthChecks) {  ...     RealConnection resultConnection = findHealthyConnection(connectTimeout, readTimeout,        writeTimeout, pingIntervalMillis, connectionRetryEnabled, doExtensiveHealthChecks);    HttpCodec resultCodec = resultConnection.newCodec(client, chain, this);  ...}private RealConnection findHealthyConnection(int connectTimeout, int readTimeout,      int writeTimeout, int pingIntervalMillis, boolean connectionRetryEnabled,      boolean doExtensiveHealthChecks) throws IOException {    while (true) {      RealConnection candidate = findConnection(connectTimeout, readTimeout, writeTimeout,          pingIntervalMillis, connectionRetryEnabled);    ...      return candidate;    }}    /**   * Returns a connection to host a new stream. This prefers the existing connection if it exists,   * then the pool, finally building a new connection.   */  private RealConnection findConnection(int connectTimeout, int readTimeout, int writeTimeout,      int pingIntervalMillis, boolean connectionRetryEnabled) throws IOException {    ...        // 尝试从connectionPool中获取可用connection    Internal.instance.acquire(connectionPool, address, this, null);    if (connection != null) {    foundPooledConnection = true;    result = connection;    } else {    selectedRoute = route;    }       ...       if (!foundPooledConnection) {      ...       // 如果最终没有可复用的connection,则创建一个新的        result = new RealConnection(connectionPool, selectedRoute);    }  ...}
复制代码


这些源码都是基于 okhttp3.13 版本的代码,3.14 版本开始这些逻辑有修改。


StreamAllocation 类中最终获取 connection 是在 findConnection 方法中,优先复用已有连接,没可用的才新建立连接。获取可复用的连接是在 ConnectionPool 类中;


/** * Manages reuse of HTTP and HTTP/2 connections for reduced network latency. HTTP requests that * share the same {@link Address} may share a {@link Connection}. This class implements the policy * of which connections to keep open for future use. */public final class ConnectionPool {
private final Runnable cleanupRunnable = () -> { while (true) { long waitNanos = cleanup(System.nanoTime()); if (waitNanos == -1) return; if (waitNanos > 0) { long waitMillis = waitNanos / 1000000L; waitNanos -= (waitMillis * 1000000L); synchronized (ConnectionPool.this) { try { ConnectionPool.this.wait(waitMillis, (int) waitNanos); } catch (InterruptedException ignored) { } } } } };
// 用一个队列保存当前的连接 private final Deque<RealConnection> connections = new ArrayDeque<>(); /** * Create a new connection pool with tuning parameters appropriate for a single-user application. * The tuning parameters in this pool are subject to change in future OkHttp releases. Currently * this pool holds up to 5 idle connections which will be evicted after 5 minutes of inactivity. */ public ConnectionPool() { this(5, 5, TimeUnit.MINUTES); }
public ConnectionPool(int maxIdleConnections, long keepAliveDuration, TimeUnit timeUnit) { ... } void acquire(Address address, StreamAllocation streamAllocation, @Nullable Route route) { assert (Thread.holdsLock(this)); for (RealConnection connection : connections) { if (connection.isEligible(address, route)) { streamAllocation.acquire(connection, true); return; } } }
复制代码


由上面源码可知,ConnectionPool 默认最大维持 5 个空闲的 connection,每个空闲 connection5 分钟后自动释放。如果 connection 数量超过最大数 5 个,则会移除最旧的空闲 connection。


最终判断空闲的 connection 是否匹配,是在 RealConnection 的 isEligible 方法中;


/**   * Returns true if this connection can carry a stream allocation to {@code address}. If non-null   * {@code route} is the resolved route for a connection.   */  public boolean isEligible(Address address, @Nullable Route route) {    // If this connection is not accepting new streams, we're done.    if (allocations.size() >= allocationLimit || noNewStreams) return false;
// If the non-host fields of the address don't overlap, we're done. if (!Internal.instance.equalsNonHost(this.route.address(), address)) return false;
// If the host exactly matches, we're done: this connection can carry the address. if (address.url().host().equals(this.route().address().url().host())) { return true; // This connection is a perfect match. }
// At this point we don't have a hostname match. But we still be able to carry the request if // our connection coalescing requirements are met. See also: // https://hpbn.co/optimizing-application-delivery/#eliminate-domain-sharding // https://daniel.haxx.se/blog/2016/08/18/http2-connection-coalescing/
// 1. This connection must be HTTP/2. if (http2Connection == null) return false;
// 2. The routes must share an IP address. This requires us to have a DNS address for both // hosts, which only happens after route planning. We can't coalesce connections that use a // proxy, since proxies don't tell us the origin server's IP address. if (route == null) return false; if (route.proxy().type() != Proxy.Type.DIRECT) return false; if (this.route.proxy().type() != Proxy.Type.DIRECT) return false; if (!this.route.socketAddress().equals(route.socketAddress())) return false;
// 3. This connection's server certificate's must cover the new host. if (route.address().hostnameVerifier() != OkHostnameVerifier.INSTANCE) return false; if (!supportsUrl(address.url())) return false;
// 4. Certificate pinning must match the host. try { address.certificatePinner().check(address.url().host(), handshake().peerCertificates()); } catch (SSLPeerUnverifiedException e) { return false; }
return true; // The caller's address can be carried by this connection. }
复制代码


这块代码比较直白,简单解释下比较条件:


如果该 connection 已达到承载的流上限(即一个 connection 可以承载几个请求,http1 默认是 1 个,http2 默认是 Int 最大值)则不符合;


如果 2 个 Address 除 Host 之外的属性有不匹配,则不符合(如果 2 个请求用的 okhttpClient 不同,复写了某些重要属性,或者服务端端口等属性不一样,那都不允许复用);


如果 host 相同,则符合,直接返回 true(其它字段已经在上一条比较了);


如果是 http2,则判断无代理、服务器 IP 相同、证书相同等条件,如果都符合也返回 true;


整体看下来,出问题的地方应该就是 ConnectionPool 的队列容量太小导致的。游戏中心业务复杂,进入首页后,触发了很多接口请求,导致连接池直接被占满,于是在启动页做好的预连接被释放了。通过调试验证了下,进入详情页时,ConnectionPool 中的确已经没有之前预连接的 connection 了。


五、优化


在 http1.1 中,浏览器一般都是限定一个域名最多保留 5 个左右的空闲连接。然而 okhttp 的连接池并没有区分域名,整体只做了默认最大 5 个空闲连接,如果 APP 中不同功能模块涉及到了多个域名,那这默认的 5 个空闲连接肯定是不够用的。有 2 个修改思路:


重写 ConnectionPool,将连接池改为根据域名来限定数量,这样可以完美解决问题。然而 OkHttp 的 ConnectionPool 是 final 类型的,无法直接重写里面逻辑,另外 OkHttp 不同版本上,ConnectionPool 逻辑也有区别,如果考虑在编译过程中使用 ASM 等字节码编写技术来实现,成本很大,风险很高。


直接调大连接池数量和超时时间。这个简单有效,可以根据自己业务情况适当调大这个连接池最大数量,在构建 OkHttpClient 的时候就可以传入这个自定义的 ConnectionPool 对象。


我们直接选定了方案 2。


六、问答


1、如何确认连接池最大数量值?


这个数量值有 2 个参数作为参考:页面最大同时请求数,App 总的域名数。也可以简单设定一个很大的值,然后进入 APP 后,将各个主要页面都点一遍,看看当前 ConnectionPool 中留存的 connection 数量,适当做一下调整即可。


2、调大了连接池会不会导致内存占用过多?


经测试:将 connectionPool 最大值调成 50,在一个页面上,用了 13 个域名链接,总共重复 4 次,也就是一次发起 52 个请求之后,ConnectionPool 中留存的空闲 connection 平均 22.5 个,占用内存为 97Kb,ConnectionPool 中平均每多一个 connection 会占用 4.3Kb 内存。


3、调大了连接池会影响到服务器吗?


理论上是不会的。连接是双向的,即使客户端将 connection 一直保留,服务端也会根据实际连接数量和时长调整,自动关闭连接的。比如服务端常用的 nginx 就可以自行设定最大保留的 connection 数量,超时也会自动关闭旧连接。因此如果服务器定义的最大连接数和超时时间比较小,可能我们的预连接会无效,因为连接被服务端关闭了。



用 charles 可以看到这种连接被服务端关闭的效果:TLS 大类中 Session Resumed 里面看到复用信息。


这种情况下,客户端会重新建立连接,会有 tcp 和 tls 连接时长信息。


4、预连接会不会导致服务器压力过大?


由于进入启动页就发起了网络请求进行预连接,接口请求数增多了,服务器肯定会有影响,具体需要根据自己业务以及服务器压力来判断是否进行预连接。


5、如何最大化预连接效果?


由上面第 3 点问题可知,我们的效果实际是和服务器配置息息相关,此问题涉及到服务器的调优。


服务器如果将连接超时设置的很小或关闭,那可能每次请求都需要重新建立连接,这样服务器在高并发的时候会因为不断创建和销毁 TCP 连接而消耗很多资源,造成大量资源浪费。


服务器如果将连接超时设置的很大,那会由于连接长时间未释放,导致服务器服务的并发数受到影响,如果超过最大连接数,新的请求可能会失败。


可以考虑根据客户端用户访问到预连接接口平均用时来调节。比如游戏中心详情页接口预连接,那可以统计一下用户从首页平均浏览多长时间才会进入到详情页,根据这个时长和服务器负载情况来适当调节。


七、参考资料


1.一文读懂 HTTP/1HTTP/2HTTP/3

2.TLS1.3VSTLS1.2,让你明白TLS1.3的强大

3.https://www.cnblogs.com


作者:vivo 互联网客户端团队-Cao Junlin

发布于: 2021 年 06 月 17 日阅读数: 47
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
Android客户端网络预连接优化机制探究