Переиспользуем соединения OkHttp по-максимуму Журнал

Введение

Мы на Booking.com знаем, как важна для наших пользователей производительность, в том числе сетевая. Недавно мы исследовали производительность сетевого стека нашего приложения для Android и нашли некоторые области, где можно ради удобства пользователей улучшить как производительность, так и само приложение. Мы хотим поделиться некоторыми советами о том, как оптимизировать повторное использование соединения OkHttp, а также осветить процесс отладки сторонней библиотеки.

На Booking.com мы используем OkHttp, библиотеку HTTP-клиента для Java/JVM, которая из коробки эффективна, проста в тестировании и позволяет определять общее поведение сетевых запросов с помощью составления перехватчиков (Interceptors).

Исследование проблемы

Узкое место производительности

Нам хочется знать продолжительность промежутка времени с момента готовности отправить сетевой запрос до момента получения пригодного к использованию результата (включая подготовку запроса, его выполнение, обработку ответа и синтаксический анализ). Посмотрев, сколько занимает каждый этап, можно понять, где стоит внести улучшения. Мы использовали небольшую утилиту-логгер, чтобы избежать влияния инструментов профилирования на время выполнения (для кода, который выполняется часто, лучше подходит Android Benchmark), и увидели, что самой заметной проблемой однозначно стало выполнение сетевых запросов, особенно задержка в них (ниже можете увидеть различные выполнения с использованием Stetho):

Network: 1.51s: Latency 1.32 s - Download 198 ms
Network: 1.43s: Latency 1.26 s - Download 197 ms
Network: 1.24s: Latency 1.16 s - Download 76 ms

Мы обнаружили расхождение между временем, отмеченным “на часах” на бэкенде и на клиенте. Наверняка мы можем что-нибудь сделать, чтобы сократить этот разрыв.

Как OkHttp выполняет запросы

В OkHttp есть расширение Logging Interceptor  —  механизм, который подключается к выполнению запроса с обратными вызовами и регистрирует информацию о выполнении запроса. Заглянув для начала в документацию, давайте посмотрим лог, полученный с помощью HttpLoggingInterceptor:

1582304879.418 D/OkHttp: <-- 200 OK https://iphone-xml.booking.com/... (1066ms)
1582304879.418 D/OkHttp: Server: nginx
1582304879.418 D/OkHttp: Date: Fri, 21 Feb 2020 17:07:55 GMT
1582304879.418 D/OkHttp: Content-Type: application/json; charset=utf-8
1582304879.419 D/OkHttp: Transfer-Encoding: chunked
1582304879.419 D/OkHttp: Vary: Accept-Encoding
1582304879.419 D/OkHttp: X-XSS-Protection: 1; mode=block
1582304879.440 D/OkHttp: {"review_recommendation":"", ... 66}
1582304879.446 D/OkHttp: <-- END HTTP (107725-byte body)

И с помощью LoggingEventListener:

[0 ms] callStart: Request{method=GET, url=https://iphone-xml.booking.com/json/mobile.searchResults?reas...[16 ms] connectionAcquired: Connection{iphone-xml.booking.com:443, proxy=DIRECT hostAddress=iphone-xml.booking.com/185.28.222.15:443 cipherSuite=TLS_AES_128_GCM_SHA256 protocol=http/1.1}
[17 ms] requestHeadersStart
[18 ms] requestHeadersEnd
[18 ms] responseHeadersStart
[155 ms] secureConnectEnd: Handshake{tlsVersion=TLS_1_3 cipherSuite=TLS_AES_128_GCM_SHA256 peerCertificates=[CN=secure-iphone-xml.booking.com, O=Booking.com BV, L=Amsterdam, C=NL, CN=DigiCert SHA2 Secure Server CA, O=DigiCert Inc, C=US] localCertificates=[]}
[155 ms] connectEnd: http/1.1
[156 ms] connectionAcquired: Connection{secure-iphone-xml.booking.com:443, proxy=DIRECT hostAddress=secure-iphone-xml.booking.com/185.28.222.26:443 cipherSuite=TLS_AES_128_GCM_SHA256 protocol=http/1.1}[158 ms] requestHeadersStart
[158 ms] requestHeadersEnd...
[35 ms] secureConnectEnd: Handshake{tlsVersion=TLS_1_2 cipherSuite=TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 peerCertificates=[CN=*.booking.com, O=Booking.com BV, L=Amsterdam, C=NL, CN=DigiCert ECC Secure Server CA, O=DigiCert Inc, C=US] localCertificates=[]}...

LoggingEventListener предоставляет интересную информацию: похоже, приложение настраивает соединение повторно с различными версиями TLS. OkHttp стремится уменьшить количество соединений сокетов, переиспользуя их в HTTP-запросах, но поскольку это возможно не всегда, здесь есть потенциал для повышения производительности. К сожалению, рассматривая код для переиспользования соединений, мы видим, что при создании нового RealConnection не происходит никакого отдельного обратного вызова.

Отладка OkHttp

Хотя отсутствие обратного вызова для конкретного события усложняет его отслеживание, оно всё еще возможно, учитывая, что зависимость идет в комплекте с источниками (а если нет, IntelliJ включает в себя Java-декомпилятор). Это значит, что у нас есть полный доступ ко всему стеку выполнения кода, включая все переменные и свойства. Мы можем установить отладчик в точке, где создаётся соединение, и таким образом увидеть текущее состояние вызова метода:

Создается новый RealConnection

К примеру, проверяем параметр Route:

Свойства Route

И поскольку мы хотим сосредоточиться на конечных точках наших мобильных приложений, настраиваем точку останова отладчика с условием:

Остановка в точке останова при выполнении условия

Мы знаем, что соединение сокета с хостом можно переиспользовать, но в действительности оно не переиспользуется. Поэтому мы можем перейти к методу, который проверяет условие для переиспользования RealConnection:

Экземпляр, в котором создается RealConnection

Можно проверить с помощью отладчика, что именно transmitterAcquirePooledConnection приводит к несоблюдению условия:

Проверка предположения в диалоге Evaluate Expression

Заглянув внутрь метода, видим вот что:

boolean transmitterAcquirePooledConnection(Address address, Transmitter transmitter,
    @Nullable List<Route> routes, boolean requireMultiplexed) {
  assert (Thread.holdsLock(this));
  for (RealConnection connection : connections) {
    if (requireMultiplexed && !connection.isMultiplexed()) continue;
    if (!connection.isEligible(address, routes)) continue;
    transmitter.acquireConnectionNoEvents(connection);
    return true;
  }
  return false;
}

Либо RealConnection поддерживает мультиплекс (HTTP/2, в настоящее время не поддерживается), либо isEligible имеет значение false. Глядя на isEligible, видим:

boolean isEligible(Address address, @Nullable List<Route> routes) {
  // Если это соединение не принимает новых обменов, мы закончили.
  if (transmitters.size() >= allocationLimit || noNewExchanges) return false;

  // Если поля адреса, не относящиеся к хосту, не перекрывают друг друга, мы закончили.
  if (!Internal.instance.equalsNonHost(this.route.address(), address)) return false;

  // Если хост в точности совпадает, мы закончили: это соединение может передавать адрес.
  if (address.url().host().equals(this.route().address().url().host())) {
    return true; // Это соединение демонстрирует идеальное совпадение.
  }

  // В этот момент у нас нет совпадающих имен хостов, однако мы по-прежнему можем передать запрос, если
  // выполняются требования по объединению соединений. См. также:
  // https://hpbn.co/optimizing-application-delivery/#eliminate-domain-sharding
  // https://daniel.haxx.se/blog/2016/08/18/http2-connection-coalescing/

  // 1. Это соединение должно быть HTTP/2.
  if (http2Connection == null) return false;
...Условие повторного использования соединения до HTTP/2 кажется ясным: allocationLimit всегда равен 1, поэтому для повторного использования соединения адрес конечной точки Address (кроме хоста) и хост host() должны быть одинаковыми. Выходит, Address не совпадает ни с одним из существующих? Давайте выясним почему.

Мы можем взглянуть на пул существующих RealConnection:

Активные соединения в пуле соединений

И сравним Address одного из них с тем же хостом, чтобы найти первопричину проблемы.

Не удается найти RealConnection, где равенство основано на Address

Вот как выглядит метод сравнения:

boolean equalsNonHost(Address that) {
  return this.dns.equals(that.dns)
    ...
      && Objects.equals(this.sslSocketFactory, that.sslSocketFactory)
      && Objects.equals(this.hostnameVerifier, that.hostnameVerifier)
      && Objects.equals(this.certificatePinner, that.certificatePinner)
      && this.url().port() == that.url().port();
}

С помощью отладчика можно увидеть, что все свойства равны, кроме sslsocket Factory:

Тот же тип, другой экземпляр

Как видим, здесь у нас есть пользовательский тип SSLSocketFactory, который не переиспользуется и не реализует equals, препятствуя эффективному повторному использованию соединения. Отладчик Java позволяет нам не только проверять код, но и вызывать свойства и методы сущностей в текущей области видимости.

Решение проблемы

Для максимизации переиспользования соединения были приняты следующие две меры, которые также стоит рассмотреть и вам при настройке OkHttp:

Если вам просто нужен внешний поставщик безопасности, то используйте предложенный OkHttp подход:

Security.insertProviderAt(Conscrypt.newProvider(), 1)

Эти изменения были проведены в эксперименте, который показывает показавшим хорошие результаты, сокращая TTI, когда требуется сетевой запрос: результаты варьируются от разницы в несколько миллисекунд до двадцатипроцентного улучшения, в зависимости от количества требуемых соединений. Как только основное переиспользование соединения между запросами оптимизировано, мы можем выполнить дальнейшие оптимизации, такие как тонкая настройка пользовательского пула соединений, чтобы дополнительно ускорить время выполнения сетевых запросов.

Надеюсь, вы узнали что-то новое и полезное о сетевом стеке OkHttp и возможностях отладки с помощью сторонней библиотеки!

Спасибо за чтение!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи: Diego Gómez Olvera, “Maximizing OkHttp connection reuse”

Предыдущая статьяСтратегии обнаружения изменений в Angular  -  «onPush» и «Default»
Следующая статьяПростыми словами о рекурсии