Android客戶端網絡預連接優化機制探究


一、背景

一般情況下,我們都是用一些封裝好的網絡框架去請求網絡,對底層實現不甚關注,而大部分情況下也不需要特別關注處理。得益於因特網的協議,網絡分層,我們可以只在應用層去處理業務就行。但是了解底層的一些實現,有益於我們對網絡加載進行優化。本文就是關於根據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/xiaolincoding/p/12732052.html

作者:vivo互聯網客戶端團隊-Cao Junlin


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM