Разработка
Как Discord сократил Websocket трафик на 40%
Вооружившись этими знаниями, мы надели лабораторные халаты, нацепили очки и начали экспериментировать.
В Discord мы постоянно думаем о том, как улучшить наши сервисы и повысить производительность. Ведь чем быстрее работает наше приложение, тем быстрее вы сможете вернуться к своим друзьям и разговорам!
В течение последних шести месяцев мы работали над снижением трафика в каналах связи, используемых нашими клиентами, особенно на iOS и Android, надеясь, что уменьшение передаваемых данных приведет к более быстрому отклику.
Что было до этого
Когда ваш клиент подключается к Discord, он получает обновления о происходящем в режиме реального времени через сервис, который мы называем «шлюзом». С конца 2017 года соединение клиента со шлюзом сжимается с помощью zlib, что позволяет уменьшить размер сообщений от 2 до 10 раз.
С тех пор zstandard (первоначально выпущенный в 2015 году) набрал достаточную популярность, чтобы стать полноценной заменой zlib. Zstandard предлагает более высокую степень сжатия и меньшее время сжатия, а также поддерживает словари: способ упреждающего обмена информацией о сжатом контенте, что еще больше увеличивает степень сжатия и снижает общее использование полосы пропускания.
Мы уже пытались использовать zstandard в прошлом, но тогда преимущества не оправдали затрат. Наше тестирование в 2019 году проводилось только на десктопах и использовало слишком много оперативной памяти. Однако за пять лет многое изменилось! Мы хотели попробовать еще раз, и поддержка словарей нам понравилась, тем более что большинство полезных нагрузок нашего шлюза небольшие и имеют четко определенную форму.
Мы считали, что предсказуемость этих полезных нагрузок — идеальное применение словарей для дальнейшего сокращения использования полосы пропускания.
Вооружившись этими знаниями, мы надели лабораторные халаты, нацепили очки и начали экспериментировать. На бумаге мы считали, что zstandard будет лучше, чем zlib, но мы хотели проверить эту теорию на текущей рабочей нагрузке.
Мы решили провести «темный запуск» простого zstandard: план состоял в том, чтобы сжать небольшой процент производственного трафика как с помощью zlib, так и с помощью zstandard, собрать кучу метрик, а затем получить данные zstandard. Это позволило бы нам поэкспериментировать с zstandard и быстро сравнить его результаты с zlib. Без этого эксперимента нам пришлось бы добавить поддержку zstandard для всех наших клиентов — десктопов, iOS и Android, — что потребовало бы около месяца времени, прежде чем мы смогли бы полностью определить влияние нового сжатия. Мы не знали, насколько хорошо будет работать zstandard, и не хотели ждать целый месяц, но «темный запуск» позволял нам проводить итерации в течение нескольких дней, а не недель.
После того как мы подготовили и развернули наш эксперимент на кластере шлюза, мы настроили дашборд, чтобы посмотреть, как работает zstandard. Мы щелкнули переключателем, чтобы начать отправлять небольшое количество трафика через код «темного запуска», и первые результаты оказались… неутешительными. Zstandard работал хуже, чем zlib.
Чтобы сравнить производительность этих двух алгоритмов сжатия, мы использовали их «коэффициент сжатия». Коэффициент сжатия измеряется путем взятия размера полезной нагрузки без сжатия и деления его на сжатый размер — большее число лучше.
Если посмотреть на изображения выше, где измеряется коэффициент сжатия для различных типов диспетчеризации (op 0), то при использовании zlib коэффициент сжатия user_guild_settings_update составляет 13.95, а при использовании zstandard — 12.26.
График ниже еще раз показывает, что zstandard работает хуже, чем zlib: средний размер полезной нагрузки MESSAGE_CREATE, сжатой с помощью zlib, составил около 250 байт, в то время как с помощью zstandard эта же нагрузка составила более 750!
Такая же тенденция наблюдалась и в большинстве других диспетчеров: zstandard не опережал zlib, как мы предполагали. Что же здесь происходит?
Потоковая передача Zstandard
Оказывается, одно из ключевых различий между нашими реализациями zlib и zstandard заключалось в том, что zlib использовал потоковое сжатие, а zstandard — нет.
Как уже говорилось, большинство наших полезных нагрузок сравнительно очень малы, всего несколько сотен байт, что не дает zstandard большого исторического контекста для работы с целью дальнейшей оптимизации сжатия будущих полезных нагрузок. При потоковом сжатии поток zlib создается при открытии соединения и существует до закрытия веб-сокета. Вместо того чтобы начинать все сначала для каждого сообщения в websocket, zlib может использовать свои знания о ранее сжатых данных для принятия решений о том, как обрабатывать новые данные. В конечном итоге это приводит к уменьшению размера полезной нагрузки.
Тогда встал вопрос: «Можем ли мы заставить zstandard работать так же?». Ответ на него был… «вроде того». Наш шлюзовой сервис написан на языке Elixir, и хотя zstandard поддерживает потоковое сжатие, различные связки zstandard для Elixir/Erlang, которые мы рассматривали, не поддерживали его.
В итоге мы остановились на ezstd, поскольку он поддерживает словари (подробнее об этом позже). Хотя в то время он не поддерживал потоковую передачу, в духе открытого исходного кода мы форкнули ezstd, чтобы добавить поддержку потоковой передачи, которую мы позже выложили в главный поток.
Затем мы повторили эксперимент с «темным запуском», но с потоковой передачей zstandard, и получили следующие результаты:
Как видно из приведенных данных, zstandard streaming увеличил коэффициент сжатия с 6 до почти 10 и уменьшил размер полезной нагрузки с 270 байт до 166.
Эта тенденция сохранилась и для большинства других диспетчеров: стриминг zstandard значительно превосходит zlib как по времени сжатия, так и по степени сжатия.
Если еще раз посмотреть на MESSAGE_CREATE, то время сжатия одного байта данных для потоковой передачи zstandard значительно меньше, чем для zlib: zlib требует около 100 микросекунд на байт, а zstandard — 45 микросекунд.
Дальнейшее развитие
Хотя наши первоначальные эксперименты доказали, что потоковая передача zstandard превосходит потоковую передачу zlib, у нас остался вопрос: «Как далеко мы можем зайти?». В наших первоначальных экспериментах использовались настройки по умолчанию для zstandard, и мы хотели узнать, как далеко мы можем продвинуться в степени сжатия, играя с настройками сжатия.
И как далеко мы зашли?
Настройка
Zstandard обладает широкими возможностями настройки и позволяет нам изменять различные параметры сжатия. Мы сосредоточили свои усилия на трех параметрах, которые, по нашему мнению, окажут наибольшее влияние на сжатие: chainlog, hashlog и windowlog. Эти параметры обеспечивают компромисс между скоростью сжатия, использованием памяти и степенью сжатия. Например, увеличение значения параметра chainlog обычно улучшает степень сжатия, но за счет увеличения объема памяти и времени сжатия.
Мы также хотели убедиться, что при выбранных нами настройках контексты сжатия будут умещаться в памяти на наших хостах. Хотя можно просто добавить больше хостов, чтобы компенсировать дополнительное использование памяти, дополнительные хосты стоят денег и в определенный момент отдача от них уменьшается.
Мы остановились на общем уровне сжатия 6, chainlog и hashlog 16 и windowlog 18. Эти цифры немного превышают настройки по умолчанию, которые вы можете увидеть здесь, и вполне могут уместиться в памяти узла шлюза.
Словари Zstandard
Кроме того, мы хотели выяснить, можно ли воспользоваться поддержкой словарей в zstandard для еще большего сжатия данных. Предварительно загрузив в zstandard некоторую информацию, можно добиться более эффективного сжатия первых нескольких килобайт данных.
Однако это создает дополнительную сложность, поскольку и компрессор (в данном случае узел шлюза), и декомпрессор (клиент Discord) должны иметь одну и ту же копию словаря для успешного взаимодействия друг с другом.
Чтобы сгенерировать словарь для использования, нам нужны данные… и много данных. В Zstandard есть встроенный способ генерировать словари (zstd --train
) на основе выборки данных, так что нам просто нужно было собрать кучу образцов.
Примечательно, что шлюз поддерживает два метода кодирования полезной нагрузки: JSON и ETF, и словарь JSON не будет работать так же хорошо на ETF (и наоборот), поэтому нам пришлось создать два словаря: по одному для каждого метода кодирования.
Поскольку словари содержат часть обучающих данных и нам придется отправлять словари нашим клиентам, нам нужно было убедиться, что образцы, на основе которых мы будем генерировать словари, не содержат никаких личных данных пользователей. Мы собрали данные о 120,000 сообщений, разделили их по ETF и JSON-кодировке, анонимизировали их, а затем создали словари.
После создания словарей мы могли использовать собранные данные для быстрой оценки их эффективности без необходимости развертывания кластера шлюза.
Первой полезной нагрузкой, которую мы попробовали сжать, была READY. Будучи одним из первых (и самых больших) полезных файлов, отправляемых пользователю, READY содержит большую часть информации о подключающемся пользователе, такую как членство в гильдии, настройки и состояния чтения (какие каналы должны быть отмечены как прочитанные/непрочитанные). Мы сжали одну полезную нагрузку READY размером 2,517 ,725 байт до 306,745, используя стандартные настройки zstandard, что позволило установить базовый уровень. При использовании словаря, который мы только что обучили, та же самая полезная нагрузка была сжата до 306,098 байт — выигрыш составил около 600 байт.
Сначала эти результаты показались нам обескураживающими, но затем мы попробовали сжать меньшую полезную нагрузку под названием TYPING_START, отправляемую клиенту, чтобы он мог показать уведомление «XXX набирает текст…». В этой ситуации полезная нагрузка размером 636 байт сжимается до 466 байт без словаря и 187 байт со словарем. Мы увидели гораздо лучшие результаты при использовании словарей на меньших объемах полезной нагрузки просто из-за того, как работает zstandard.
Большинство алгоритмов сжатия «учатся» на данных, которые уже были сжаты, но в случае с небольшими полезными нагрузками у них нет никаких данных, на которых они могли бы учиться. Заранее сообщив zstandard, как будет выглядеть полезная нагрузка, он может принять более взвешенное решение о том, как сжать первые несколько килобайт данных, пока его буферы еще не полностью заполнены.
Удовлетворенные этими выводами, мы развернули поддержку словаря на нашем шлюзовом кластере и начали экспериментировать с ним. Используя фреймворк «темного запуска», мы сравнили zstandard с zstandard со словарями.
Производственное тестирование дало следующие результаты:
Мы специально рассматривали размер полезной нагрузки READY, так как это одно из первых сообщений, отправляемых через websocket, и оно, скорее всего, выиграет от использования словаря. Как показано в таблице выше, выигрыш в сжатии для READY был минимальным, поэтому мы посмотрели результаты для других типов отправлений, надеясь, что словари дадут больше преимуществ для меньшей полезной нагрузки.
К сожалению, результаты оказались неоднозначными. Например, если посмотреть на размер полезной нагрузки сообщения Create, который мы сравнивали на протяжении всего этого поста, то можно увидеть, что словарь фактически ухудшил ситуацию.
В конечном итоге мы решили не продолжать эксперименты со словарями. Небольшое улучшение сжатия, которое обеспечили бы словари, было перевешено дополнительной сложностью, которую они добавили бы нашему шлюзовому сервису и клиентам. Данные — важный фактор инженерной работы в Discord, и они говорят сами за себя: не стоило вкладывать в это больше усилий.
Улучшение буфера
Наконец, мы рассмотрели возможность увеличения буферов zstandard в непиковые часы. Трафик Discord имеет суточный характер, и память, необходимая нам для обработки пикового спроса, значительно больше той, что требуется в остальное время суток.
На первый взгляд, автомасштабирование нашего шлюзового кластера позволит нам не тратить вычислительные ресурсы в непиковые часы. Однако из-за того, что шлюзовые соединения являются долгоживущими, традиционные методы автомасштабирования не подходят для нашей рабочей нагрузки. Поэтому в непиковые часы у нас много лишней памяти и вычислительных ресурсов. Наличие всех этих дополнительных вычислительных ресурсов поставило перед нами вопрос: Можем ли мы воспользоваться этими ресурсами, чтобы предложить большее сжатие?
Чтобы выяснить это, мы встроили в кластер шлюза цикл обратной связи. Этот цикл запускался на каждом узле шлюза и отслеживал использование памяти клиентами, подключенными к нему. Затем он определял процент новых подключающихся клиентов, которым следует обновить буфер zstandard. Обновленный буфер увеличивает значения windowlog, hashlog и chainlog на единицу, а поскольку эти параметры выражаются как степень двух, увеличение этих значений на единицу примерно удвоит объем памяти, используемой буфером.
После развертывания и некоторого времени работы цикла обратной связи результаты оказались не такими хорошими, как мы изначально надеялись. Как показано на графике ниже, в течение 24 часов коэффициент обновления узлов шлюза был относительно низким (до 30%) и значительно ниже, чем мы ожидали: около 70%.
Немного покопавшись, мы обнаружили, что одной из основных проблем, вызывавших неоптимальное поведение контура обратной связи, была фрагментация памяти: контур обратной связи смотрел на реальное использование системной памяти, но BEAM выделял из системы значительно больше памяти, чем требовалось для работы с подключенными клиентами. Это заставляло цикл обратной связи думать, что у него меньше памяти для работы, чем доступно.
Чтобы попытаться смягчить эту проблему, мы провели небольшой эксперимент по изменению настроек аллокатора BEAM, а точнее, аллокатора driver_alloc, который отвечает за (как ни странно) выделение данных драйвера. Основную часть памяти, используемой процессом шлюза, занимает потоковый контекст zstandard, который реализован на языке C с помощью NIF. Память NIF выделяется командой driver_alloc. Наша гипотеза заключалась в том, что если мы сможем настроить аллокатор driver_alloc на более эффективное выделение и освобождение памяти для наших контекстов zstandard, мы сможем уменьшить фрагментацию и увеличить коэффициент обновления в целом.
Однако, немного повозившись с настройками аллокатора, мы решили изменить цикл обратной связи. Хотя в конечном итоге мы, вероятно, нашли бы правильные настройки аллокатора, количество усилий, необходимых для настройки аллокаторов, в сочетании с общей дополнительной сложностью, которую это привнесло в кластер шлюза, перевесило любые преимущества, которые мы могли бы увидеть в случае успеха.
Внедрение и развертывание
Хотя изначально планировалось использовать zstandard только для мобильных пользователей, улучшение пропускной способности оказалось достаточно значительным, чтобы мы решили внедрить его и для пользователей настольных компьютеров! Поскольку zstandard поставляется в виде библиотеки на языке C, было просто найти привязки к целевому языку — Java для Android, Objective-C для iOS и Rust для десктопов — и подключить их к каждому клиенту. Реализация была простой для Java (zstd-jni) и десктопов (zstd-safe), поскольку биндинги уже существовали, однако для iOS нам пришлось написать собственные.
Это было рискованное изменение, которое могло сделать Discord полностью непригодным для использования, если бы что-то пошло не так, поэтому развертывание было ограничено экспериментом. Этот эксперимент преследовал три цели: обеспечить быстрый откат изменений, если что-то пойдет не так, подтвердить результаты, которые мы увидели в «лаборатории», и определить, не повлияло ли это изменение на какие-либо базовые показатели.
В течение нескольких месяцев мы смогли успешно внедрить zstandard для всех наших пользователей на всех платформах.
Еще одна победа: пассивные сессии V2
Хотя эта следующая часть не имеет прямого отношения к работе над zstandard, метрики, которыми мы руководствовались во время «темного запуска» этого проекта, выявили удивительное поведение. Если посмотреть на фактический размер сообщений, которые отправлялись клиенту, то особенно выделялся PASSIVE_UPDATE_V1. Эта рассылка составляла более 30% трафика нашего шлюза, в то время как фактическое количество отправляемых сообщений было сравнительно небольшим — около 2%.
Мы используем пассивные сессии, чтобы избежать отправки большинства сообщений, генерируемых сервером, клиентам, которые могут даже не открывать сервер. Например, сервер Discord может быть очень активным, отправляя тысячи сообщений в минуту, но если пользователь на самом деле не читает эти сообщения, то нет смысла отправлять их и тратить пропускную способность. Как только вы войдете на сервер, пассивная сессия будет «преобразована» в обычную сессию и получит полный поток сообщений от этой гильдии.
Однако пассивным сессиям по-прежнему необходимо периодически отправлять ограниченное количество информации, для чего и существует PASSIVE_UPDATE_V1. Периодически все пассивные сессии будут получать обновление со списком каналов, членов и активных членов, чтобы ваш клиент мог синхронизироваться с сервером.
Если вникнуть в фактическое содержимое одной из таких отправок PASSIVE_UPDATE_V1, то мы отправляли все каналы, члены или активные члены, даже если изменился только один элемент. Пассивные сессии были реализованы как средство масштабирования серверов Discord до сотен тысяч пользователей, и в то время это хорошо работало.
Однако по мере дальнейшего масштабирования отправка снепшотов, состоящих в основном из избыточных данных, перестала быть нормальной. Чтобы исправить это, мы ввели новую рассылку, которая отправляет только дельту того, что изменилось с момента последнего обновления. Эта рассылка, получившая меткое название PASSIVE_UPDATE_V2, значительно сократила общую пропускную способность шлюза с 35% до 5%, что соответствует 20%-ному сокращению в масштабах всего кластера.
БОЛЬШАЯ экономия
Благодаря совместному использованию PPASSIVE_UPDATE_V2 и zstandard нам удалось снизить нагрузку на шлюз, используемую нашими клиентами, почти на 40%. Это очень много данных!
На графике показана относительный исходящий трафик кластера шлюза с 1 января 2024 года по 12 августа 2024 года, причем две границы — это развертывание zstandard в апреле, а затем пассивных сессий v2 в конце мая.
Хотя оптимизация пассивных сессий была непреднамеренным побочным эффектом экспериментов с zstandard, она показывает, что при наличии правильного инструментария и критическом взгляде на графики можно добиться большой экономии при разумных усилиях.