关于http:Android客户端网络预连接优化机制探究

109次阅读

共计 9440 个字符,预计需要花费 24 分钟才能阅读完成。

一、背景

个别状况下,咱们都是用一些封装好的网络框架去申请网络,对底层实现不甚关注,而大部分状况下也不须要特地关注解决。得益于因特网的协定,网络分层,咱们能够只在应用层去解决业务就行。然而理解底层的一些实现,有益于咱们对网络加载进行优化。本文就是对于依据 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                                               Server
​
ClientHello                  -------->
                                                ServerHello
                                               Certificate*
                                         ServerKeyExchange*
                                        CertificateRequest*
                             <--------      ServerHelloDone
Certificate*
ClientKeyExchange
CertificateVerify*
[ChangeCipherSpec]
Finished                     -------->
                                         [ChangeCipherSpec]
                             <--------             Finished
Application 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

正文完
 0