一、背景
一般情況下,我們都是用一些封裝好的網路框架去請求網路,對底層實現不甚關注,而大部分情況下也不需要特別關注處理。得益於因特網的協議,網路分層,我們可以只在應用層去處理業務就行。但是瞭解底層的一些實現,有益於我們對網路載入進行優化。本文就是關於根據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的強大
作者:vivo網際網路客戶端團隊-Cao Junlin