通常状况下,咱们都是用一些封装好的网络框架去请求网络,对底层实现不甚关注,而大部分状况下也不须要特别关注处理。得益于因特网的协议,网络分层,咱们能够只在应用层去处理业务就行。可是了解底层的一些实现,有益于咱们对网络加载进行优化。本文就是关于根据http的链接复用机制来优化网络加载速度的原理与细节。html
对于一个普通的接口请求,经过charles抓包,查看网络请求Timing栏信息,咱们能够看到相似以下请求时长信息:java
一样的请求,再来一次,时长信息以下所示:nginx
咱们发现,总体网络请求时间从175ms下降到了39ms。其中DNS,Connect,TLS Handshake 后面是个横线,表示没有时长信息,因而总体请求时长极大的下降了。这就是Http(s)的链接复用的效果。那么问题来了,什么是链接复用,为何它能下降请求时间?浏览器
在解决这个疑问以前,咱们先来看看一个网络请求发起,到收到返回的数据,这中间发生了什么?服务器
上面的链接复用直接让上面2,3,4步都不须要走了。这中间省掉的时长应该怎么算?若是咱们定义网络请求一次发起与收到响应的一个来回(一次通讯来回)做为一个RTT(Round-trip delay time)。网络
1)DNS默认基于UDP协议,解析最少须要1-RTT;并发
2)创建TCP链接,3次握手,须要2-RTT;app
(图片来源自网络)框架
3)创建TLS链接,根据TLS版本不一样有区别,常见的TLS1.2须要2-RTT。dom
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。
一、如何确认链接池最大数量值?
这个数量值有2个参数做为参考:页面最大同时请求数,App总的域名数。也能够简单设定一个很大的值,而后进入APP后,将各个主要页面都点一遍,看看当前ConnectionPool中留存的connection数量,适当作一下调整便可。
二、调大了链接池会不会致使内存占用过多?
经测试:将connectionPool最大值调成50,在一个页面上,用了13个域名连接,总共重复4次,也就是一次发起52个请求以后,ConnectionPool中留存的空闲connection平均22.5个,占用内存为97Kb,ConnectionPool中平均每多一个connection会占用4.3Kb内存。
三、调大了链接池会影响到服务器吗?
理论上是不会的。链接是双向的,即便客户端将connection一直保留,服务端也会根据实际链接数量和时长调整,自动关闭链接的。好比服务端经常使用的nginx就能够自行设定最大保留的connection数量,超时也会自动关闭旧链接。所以若是服务器定义的最大链接数和超时时间比较小,可能咱们的预链接会无效,由于链接被服务端关闭了。
用charles能够看到这种链接被服务端关闭的效果:TLS大类中Session Resumed里面看到复用信息。
这种状况下,客户端会从新创建链接,会有tcp和tls链接时长信息。
四、预链接会不会致使服务器压力过大?
因为进入启动页就发起了网络请求进行预链接,接口请求数增多了,服务器确定会有影响,具体须要根据本身业务以及服务器压力来判断是否进行预链接。
五、如何最大化预链接效果?
由上面第3点问题可知,咱们的效果实际是和服务器配置息息相关,此问题涉及到服务器的调优。
服务器若是将链接超时设置的很小或关闭,那可能每次请求都须要从新创建链接,这样服务器在高并发的时候会由于不断建立和销毁TCP链接而消耗不少资源,形成大量资源浪费。
服务器若是将链接超时设置的很大,那会因为链接长时间未释放,致使服务器服务的并发数受到影响,若是超过最大链接数,新的请求可能会失败。
能够考虑根据客户端用户访问到预链接接口平均用时来调节。好比游戏中心详情页接口预链接,那能够统计一下用户从首页平均浏览多长时间才会进入到详情页,根据这个时长和服务器负载状况来适当调节。
2.TLS1.3VSTLS1.2,让你明白TLS1.3的强大
3.http://www.javashuo.com/article/p-dxdvghhh-kn.html
做者:vivo互联网客户端团队-Cao Junlin