Pull to refresh

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

Reading time 6 min
Views 31K

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

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

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

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

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

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

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

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

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

Протокол HTTP, помимо сжатия ответов (о котором знают все, кто когда-либо оптимизировал скорость работы сайтов), позволяет использовать аналогичный механизм для сжатия тела POST/PUT-запросов, объявив об этом в заголовке Content-Encoding. В качестве входящего обратного прокси и балансировщика нагрузки FunCorp использует 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 килобайт. Это, конечно, уже не приведёт к такому впечатляющему результату, но всё равно будет приятно и полезно.

Tags:
Hubs:
+135
Comments 53
Comments Comments 53

Articles