通常狀況下,咱們都是用一些封裝好的網絡框架去請求網絡,對底層實現不甚關注,而大部分狀況下也不須要特別關注處理。得益於因特網的協議,網絡分層,咱們能夠只在應用層去處理業務就行。可是瞭解底層的一些實現,有益於咱們對網絡加載進行優化。本文就是關於根據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