其他
Android客户端网络预连接优化机制探究
作者:vivo互联网客户端团队-Cao Junlin
一、背景
二、连接复用
Duration 175 ms
DNS 6 ms
Connect 50 ms
TLS 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.1版本开始,http连接默认就是持久连接,可以复用,通过在报文头部中加上Connection:Close来关闭连接 。如果并行有多个请求,可能还是需要建立多个连接,当然我们也可以在同一个TCP连接上传输,这种情况下,服务端必须按照客户端请求的先后顺序依次回送结果。
注:http1.1默认所有的连接都进行了复用。然而空闲的持久连接也可以随时被客户端与服务端关闭。不发送Connection:Close不意味着服务器承诺连接永远保持打开。
http2 更进一步,支持二进制分帧,实现TCP连接的多路复用,不再需要与服务端建立多个TCP连接了,同域名的多个请求可以并行进行。
还有个容易被忽视的是,TCP有拥塞控制,建立连接后有慢启动过程(根据网络情况一点一点的提高发送数据包的数量,前面是指数级增长,后面变成线性),复用连接可以避免这个慢启动过程,快速发包。
三、预连接实现
具体如何实现?
第一反应,我们可以简单的对域名链接提前发起一个HEAD请求(没有body可以省流量),这样就能提前建立好连接,下次同域名的请求就可以直接复用,实现起来也是简单方便。于是写了个demo,试了个简单接口,完美,粗略统计首次请求速度可以提升40%以上。
于是在游戏中心App启动Activity中加入了预连接相关逻辑,跑起来试了下,竟然没效果...
抓包分析,发现连接并没有复用,每次进去详情页后都重新创建了连接,预连接可能只是省掉了DNS解析时间,demo上的效果无法复现。看样子分析OkHttp连接复用相关源码是跑不掉了。
四、源码分析
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版本开始这些逻辑有修改。
/**
* 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了。
五、优化
重写ConnectionPool,将连接池改为根据域名来限定数量,这样可以完美解决问题。然而OkHttp的ConnectionPool是final类型的,无法直接重写里面逻辑,另外OkHttp不同版本上,ConnectionPool逻辑也有区别,如果考虑在编译过程中使用ASM等字节码编写技术来实现,成本很大,风险很高。
直接调大连接池数量和超时时间。这个简单有效,可以根据自己业务情况适当调大这个连接池最大数量,在构建OkHttpClient的时候就可以传入这个自定义的ConnectionPool对象。
我们直接选定了方案2。
六、问答
这个数量值有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/32.TLS1.3VSTLS1.2,让你明白TLS1.3的强大3.https://www.cnblogs.com
END
猜你喜欢