Как мы просто сократили объем входящего в дата-центр трафика на 70%

b571e3c49bcb01567695af60aa6ace1e

Хочу рассказать о том, как довольно простым лайфхаком мы радикально сократили объем входящего в дата-центр трафика, одновременно сделав жизнь пользователей нашего мобильного приложения чуть лучше и даже уменьшив расход заряда их батареи.

Единственное, о чем мы пожалели — что не применили это решение раньше.

Наша команда придерживается принципов Data-Driven, то есть решения о развитии продуктов принимаются на основе метрик, а внедрение любых новых фич начинается с проверки гипотез. Для этого мы собираем и агрегируем колоссальное количество данных о том, что происходит внутри.

Два года назад, когда мы переходили с RedShift на ClickHouse, количество собираемых аналитических событий («приложение открылось», «приложение запросило ленту контента», «пользователь просмотрел контент», «пользователь поставил смайл (лайк)» и так далее) составляло около 5 млрд в сутки. Сегодня это число приближается к 14 млрд. 

Это огромный объём данных, для хранения и обработки которого мы постоянно придумываем решения и лайфхаки, а сервис, их агрегирующий — один из самых сложных среди всех наших инфраструктурных сервисов.

Но перед тем, как агрегировать, сохранить и обработать столько данных, их надо сначала принять — и с этим есть свои проблемы. Часть описана в статье о переходе на ClickHouse (ссылка на неё была выше), но есть и другие.

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

Во время брейншторма по поводу одного из таких инцидентов прозвучала идея: раз мы создаём этими событиями такой колоссальный объём трафика, может быть нам начать его сжимать на клиентских устройствах? Отличная идея, но она была отвергнута как неконструктивная. Ведь это не избавит нас от катастрофы, а лишь оттянет её на какое-то время. Поэтому идея отправилась в специальный ящичек для неплохих идей.

Но ближе к лету непростого 2020 года ей нашлось применение.

Протокол HTTP, помимо сжатия ответов (о котором знают все, кто когда-либо оптимизировал скорость работы сайтов), позволяет использовать аналогичный механизм для сжатия тела POST/PUT-запросов, объявив об этом в заголовке Content-Encoding. В качестве входящего обратного прокси и балансировщика нагрузки мы используем nginx, проверенное и надёжное решение. Мы настолько были уверены, что он сумеет ко всему прочему ещё и на лету распаковать тело POST-запроса, что поначалу даже не поверили, что из коробки он этого не умеет. И нет, готовых модулей для этого тоже нет, надо было как-то решать проблему самостоятельно или использовать скрипт на Lua. Идея с Lua нам особенно не понравилась, зато это знание развязало руки в части выбора алгоритма компрессии.

Дело в том, что давно стандартизированные алгоритмы сжатия типа gzip, deflate или LZW были изобретены в 70-х годах XX века, когда каналы связи и носители были узким горлышком, и коэффициент сжатия был важнее, чем потраченное на сжатие время. Сегодня же в кармане каждого из нас лежит универсальный микрокомпьютер первой четверти XXI века, оборудованный подчас четырёх- и более ядерным процессором, способный на куда большее, а значит алгоритм можно выбрать более современный.

Выбор алгоритма

Требования к алгоритму были простыми:

  1. Высокая скорость сжатия. Мы не хотим, чтобы приложения тормозили из-за второстепенной функции.

  2. Небольшое потребление процессорной мощности. Не хотим, чтобы телефоны грелись в руках пользователей.

  3. Хорошая поддержка, доступность для основных языков программирования.

  4. Permissive лицензия.

Сразу отбросили Brotli, предназначенный для одноразового сжатия статического контента на стороне сервера. По схожей причине решили отказаться от bzip, также ориентированного на статику и большие архивы.

В итоге остановились на алгоритме Zstandard, по следующим причинам:

  • Высокая скорость сжатия (на порядок больше, чем у zlib), заточенность на небольшие объёмы данных.

  • Хороший коэффициент сжатия при щадящем уровне потребления CPU.

  • За алгоритмом стоит Facebook, разрабатывавший его для себя.

  • Открытый исходный код, двойная лицензия GPLv2/BSD.

Когда мы увидели первым же в списке поддерживаемых языков JNI, интерфейс вызова нативного кода для JVM, доступный из Kotlin — мы поняли, что это судьба. Ведь Kotlin является у нас основным языком разработки как на Android, так и бэкенде. Обёртка для Swift (наш основной язык разработки на iOS) завершила процесс выбора.

Решение на бэкенде

На стороне бэкенда задача была тривиальная: увидев заголовок Content-encoding: zstd, сервис должен получить поток, содержащий сжатое тело запроса, отправить его в декомпрессор zstd, и получить в ответ поток с распакованными данными. То есть буквально (используя JAX-RS container):

// Обёртка над Zstd JNI
import org.apache.commons.compress.compressors.zstandard.ZstdCompressorInputStream;

// ...

if (
  containerRequestContext
    .getHeaders()
    .getFirst("Content-Encoding")
    .equals("zstd")
) {
  containerRequestContext
    .setEntityStream(ZstdCompressorInputStream(
      containerRequestContext.getEntityStream()
    ))
}

Решение на iOS

Решили сначала попробовать сжатие аналитических событий на iOS. Команда разработки была свободнее, ну и клиентов на iOS у нас несколько меньше. На всякий случай закрыли этот функционал при помощи feature toggle с возможностью плавной раскатки.

import Foundation
import ZSTD

final class ZSTDRequestSerializer {
    private let compressionLevel: Int32

    init(compressionLevel: Int32) {
        self.compressionLevel = compressionLevel
    }

    func requestBySerializing(request: URLRequest, parameters: [String: Any]?) throws -> URLRequest? {
        guard let mutableRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else {
            return nil
        }

        // ...

        mutableRequest.addValue("zstd", forHTTPHeaderField: "Content-Encoding")

        if let parameters = parameters {
            let jsonData = try JSONSerialization.data(withJSONObject: parameters, options: [])

            let processor = ZSTDProcessor(useContext: true)
            let compressedData = try processor.compressBuffer(jsonData, compressionLevel: compressionLevel)
            mutableRequest.httpBody = compressedData
        }

        return mutableRequest as URLRequest
    }
}

Осторожно включив фичу на 10% клиентов прямо в процессе раскатки очередной версии, затаив дыхание, стали смотреть в логи, метрики, индикатор крашей и прочие инструменты. Проблем не обнаружилось, полёт был нормальный.

Впрочем, и снижение объёма трафика было не сильно заметно. Дождавшись, пока новая версия клиента раскатится пошире, мы врубили сжатие на 100% аудитории.

Результат нас, мягко говоря, удовлетворил:

График падения трафика на iOSГрафик падения трафика на iOS

Входящий трафик упал аж на 25%. На графике представлен весь входящий в наш дата-центр трафик, включающий и штатные запросы клиентов, и закачиваемые ими картинки и видео.

То есть мы на четверть сократили весь объём.

Решение на Android

Воодушевлённые, мы запилили сжатие для второй платформы.

// Тут перехватываем отправку события через interceptor и подменяем оригинальный body на сжатый если это запрос к events
override fun intercept(chain: Interceptor.Chain): Response {
   val originalRequest = chain.request()
   return if (originalRequest.url.toString()
               .endsWith("/events")) {
      val compressed = originalRequest.newBuilder()
            .header("Content-Encoding", "zstd")
            .method(originalRequest.method, zstd(originalRequest.body))
            .build()
      chain.proceed(compressed)
   } else {
      chain.proceed(chain.request())
   }
}

// Метод сжатия, берет requestBody и возвращает сжатый
private fun zstd(requestBody: RequestBody?): RequestBody {
   return object : RequestBody() {
      override fun contentType(): MediaType? = requestBody?.contentType()
      override fun contentLength(): Long = -1 //We don't know the compressed length in advance!
      override fun writeTo(sink: BufferedSink) {
         val buffer = Buffer()
         requestBody?.writeTo(buffer)
         sink.write(Zstd.compress(buffer.readByteArray(), compressLevel))
      }
   }
}

И тут нас ждал шок:

График падения на AndroidГрафик падения на Android

Так как доля Android среди нашей аудитории больше, чем iOS, падение составило ещё 45%. Итого, если считать от исходного уровня, мы выиграли суммарно 70% от, напомню, всего входящего трафика в ДЦ.

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

В этот момент мы только могли пожалеть, что не нашли время на внедрение такой рационализации раньше.

Также стало видно, что наши опасения относительно батарейки не оправдались. Наоборот, потратив немного процессорной мощности телефона на сжатие данных, мы экономим намного больше электричества на передаче этих данных в эфир, как на Wi-Fi, так и по сотовой сети.

Два слова, что ещё можно улучшить

У Zstandard есть очень интересная возможность использования предобученного словаря. Если взять достаточно большой объём типичных данных и прогнать по нему сжатие, то можно получить на выходе словарь наиболее высокочастотных вхождений, который затем использовать и при сжатии, и при распаковке.

При этом коэффициент сжатия увеличивается от 10–15% на текстах до 50% на однообразных наборах строк, как у нас. А скорость сжатия даже несколько увеличивается при размере словаря порядка 16 килобайт. Это, конечно, уже не приведёт к такому впечатляющему результату, но всё равно будет приятно и полезно.

© Habrahabr.ru