MAATRIX / Блог / Почему сжатие на лету иногда обходится дороже, чем лишний мегабайт трафика

Почему сжатие на лету иногда обходится дороже, чем лишний мегабайт трафика

MAATRIX

Включить gzip on; в конфиге nginx — дело одной строки, и кажется, что это чистая выгода: трафик меньше, страницы грузятся быстрее, а цена вроде бы нулевая. На деле сжатие ответа не бесплатно — это работа процессора, которую сервер делает заново на каждый запрос, если вы не подумали об этом заранее. Разберёмся, куда уходит CPU при сжатии на лету, когда оно того стоит, а когда только греет воздух, и как вынести эту работу за пределы горячего пути запроса.

Что на самом деле происходит с CPU при сжатии ответа

Когда клиент присылает заголовок Accept-Encoding: gzip, br, и сервер решает сжать ответ, происходит не «упаковка файла в архив» в бытовом смысле, а вполне конкретная вычислительная работа, которая выполняется в теле обработчика запроса, на том же воркере, что формирует и отдаёт ответ.

Для gzip это алгоритм DEFLATE: скользящее окно ищет повторяющиеся последовательности байт (LZ77), а затем результат кодируется по Хаффману, чтобы часто встречающиеся последовательности занимали меньше бит. Поиск повторов — самая тяжёлая часть: чем шире окно поиска и чем тщательнее алгоритм ищет совпадения, тем больше сравнений байт он делает на каждый килобайт входных данных.

Brotli устроен похоже, но добавляет статический словарь частых последовательностей (заточенный под веб — HTML-теги, JS-конструкции, CSS-свойства) и более агрессивный поиск совпадений на высоких уровнях качества. Из-за этого brotli на сравнимом уровне обычно сжимает чуть лучше gzip, но и процессорного времени на это просит больше — это прямое следствие более сложного алгоритма поиска, а не случайность реализации.

Важный момент: если ответ динамический (сгенерирован бэкендом на этот конкретный запрос — JSON от API, отрендеренный HTML), сжимать его приходится каждый раз заново. Кешировать нечего — контент один раз в жизни. Каждый такой запрос — это отдельный проход алгоритма сжатия на процессоре сервера или прокси, который его отдаёт.

Похожая картина есть и в другом месте стека: шифрование TLS тоже раньше было ощутимой нагрузкой на CPU, пока аппаратные инструкции AES-NI не сняли основную часть этой работы с процессора. У сжатия такого повсеместного аппаратного ускорения нет — это по-прежнему в основном программная работа CPU, и её объём растёт линейно с числом сжимаемых запросов.

Уровень сжатия — это явный обмен CPU на мегабайты

И gzip, и brotli позволяют выбрать уровень сжатия: у gzip это диапазон 1–9 (директива gzip_comp_level), у brotli — 0–11 (brotli_comp_level в связке с модулем ngx_brotli). Смысл один и тот же: чем выше уровень, тем алгоритм тщательнее ищет совпадения и тем меньше становится результат — но и тем больше CPU-времени тратится на каждый мегабайт входных данных.

Зависимость не линейная. На низких уровнях (1–3 у gzip) сервер получает основную часть выгоды почти бесплатно: алгоритм делает быстрый неглубокий поиск, ужимает текст в разы просто за счёт устранения очевидной избыточности HTML/JSON/CSS. На верхних уровнях (7–9) он тратит заметно больше времени на поиск, а выигрыш в размере по сравнению с уровнем 5–6 обычно небольшой — сервер платит непропорционально много CPU за последние проценты экономии трафика. Точные цифры зависят от типа контента и от вашего железа, поэтому это качественная закономерность, а не гарантированные проценты — проверяйте на своём трафике, сравнивая CPU-нагрузку воркеров при разных уровнях на одинаковом потоке запросов.

nginx по умолчанию использует gzip_comp_level 1 — минимальный уровень. Это осознанный выбор разработчиков: дефолт рассчитан на то, что сервер отдаёт много запросов и не должен тратить на каждый из них избыточное время. Поднимать уровень до максимума «для лучшего сжатия» без замера последствий — частая ошибка: сервер начинает честно жать сильнее, но ценой того, что часть воркеров становится занята сжатием вместо приёма новых соединений.

# типичный компромисс для динамических text/json ответов
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;

Похожая идея — про экономию за счёт сжатия там, где это дёшево и предсказуемо, — разбирается в статье про сжатие заголовков HTTP/2 по алгоритму HPACK: экономия трафика там достигается почти без нагрузки на CPU, потому что заголовки сжимаются относительно статического и динамического словаря, а не полным перебором совпадений — это принципиально другой профиль затрат по сравнению с DEFLATE или brotli на теле ответа.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Почему сжимать уже сжатые файлы — это чистые потери

Есть категория данных, для которых сжатие на лету почти всегда бессмысленно: JPEG, PNG (с приличным уровнем оптимизации), WebP, AVIF, MP4, MP3, уже собранные ZIP-архивы, PDF с внутренним сжатием потока. Все эти форматы уже прошли через собственные алгоритмы сжатия на этапе создания файла — фото пережато кодеком с учётом особенностей изображения, видео — специализированным видеокодеком, архив — тем же DEFLATE или более сильным алгоритмом.

Энтропия таких данных уже близка к максимальной: байты выглядят почти случайными, повторяющихся последовательностей, на которых работает LZ77, там просто не осталось. Gzip или brotli, запущенные поверх такого файла, честно перебирают скользящее окно в поисках совпадений — и почти ничего не находят. В лучшем случае итоговый размер уменьшается на доли процента, в отдельных случаях сжатый повторно файл оказывается даже чуть больше исходного из-за служебных заголовков формата сжатия.

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

Похожая по духу ошибка — включать сжатие там, где оно уже произошло на другом уровне стека, и получать на выходе двойную работу без всякой выгоды, а иногда и битый контент на стороне клиента: это разобрано в статье «Сжатие включили дважды, и браузеры получали мусор». Мораль та же: сжатие — не операция, которую можно бездумно навешивать на весь трафик подряд.

Как ограничить сжатие на лету осмысленным списком типов

Правильная практика — не полагаться на «включить gzip и забыть», а явно перечислить, какие MIME-типы стоит сжимать. В nginx это директива gzip_types (для brotli — аналогично brotli_types), причём text/html сжимается всегда при gzip on, независимо от списка.

gzip_types
    text/plain
    text/css
    text/xml
    application/json
    application/javascript
    application/xml
    application/rss+xml
    image/svg+xml;

Обратите внимание — в этом списке нет image/jpeg, image/png, video/mp4, application/zip, application/pdf, application/octet-stream. Их туда добавлять не нужно: это как раз те форматы, что уже сжаты на этапе создания файла. image/svg+xml в списке есть — SVG это текстовый XML-формат, и в нём часто много избыточности (повторяющиеся атрибуты, пробелы), сжатие даёт реальный эффект.

Если у вас есть API, отдающее большие бинарные blob'ы (например, экспорт в собственном бинарном формате), стоит явно проверить, что содержимое действительно текстовое или слабо энтропийное, а не полагаться на предположения. Быстрый способ: сравнить размер файла до и после ручного gzip -9 — если выигрыш минимальный, включать сжатие для этого типа ответов не имеет смысла.

Предварительное сжатие статики: заплатить один раз на этапе сборки

Здесь кроется главная практическая идея: для статических файлов — CSS, JS, шрифтов, заранее известного HTML — совершенно не обязательно сжимать их на каждый запрос. Файл не меняется между запросами, значит и его сжатая версия не меняется. Логично посчитать её один раз — на этапе сборки или деплоя — и просто отдавать готовый результат.

nginx умеет это делать «из коробки» через модуль gzip_static (обычно уже собран в стандартных пакетах nginx на большинстве дистрибутивов) и через brotli_static из модуля ngx_brotli (для brotli модуль часто приходится подключать отдельно — либо собирать nginx с ним, либо использовать сборку, где он уже включён, например через официальный образ с этим модулем или через OpenResty). Логика одна: рядом с style.css лежит заранее сжатый style.css.gz и/или style.css.br, и nginx отдаёт готовый файл напрямую, если клиент поддерживает соответствующее сжатие.

# nginx.conf — отдаём заранее сжатые файлы, если они есть
gzip_static on;
brotli_static on;

Сжатие в сборочном пайплайне делается максимально сильным уровнем — раз работа выполняется один раз при деплое, а не на каждый запрос, время алгоритма перестаёт быть узким местом. Здесь как раз уместны gzip -9 и высокие уровни brotli: разница в несколько секунд на этапе сборки CI/CD ничего не стоит по сравнению с постоянной нагрузкой на прод.

# пример шага в пайплайне сборки статики
find dist -type f \( -name '*.css' -o -name '*.js' -o -name '*.svg' -o -name '*.html' \) -print0 |
  xargs -0 -I{} gzip -9 -k -f {}

find dist -type f \( -name '*.css' -o -name '*.js' -o -name '*.svg' -o -name '*.html' \) -print0 |
  xargs -0 -I{} brotli -q 11 -k -f {}

Флаг -k сохраняет исходный файл рядом со сжатым — он нужен как резервный вариант для клиентов, которые не прислали Accept-Encoding (редко, но случается — некоторые прокси и старые клиенты его срезают).

Этот подход хорошо ложится в общую логику раздачи статики — если вы разворачиваете отдельный статический сайт или SPA, шаг предварительного сжатия стоит сразу заложить в процесс сборки и деплоя, а не пытаться компенсировать его настройками nginx постфактум; базовые шаги настройки такого сервера описаны в статье про установку статического сайта на VPS.

Важная оговорка: предварительно сжатые файлы нужно инвалидировать вместе с исходником. Если сборка меняет style.css, но забывает пересобрать style.css.gz, nginx с gzip_static on продолжит отдавать устаревшую сжатую версию — она физически лежит на диске и имеет приоритет. Практика показывает, что этот шаг проще всего не забывать, если генерация .gz/.br — часть того же скрипта сборки, что создаёт сам файл, а не отдельный шаг, который легко пропустить.

Что сжимать на лету, а что — заранее или никогда

Сводная логика получается такой:

Тип контентаЧто делатьПочему
Динамический HTML/JSON/XML от бэкендаСжимать на лету, уровень 4–6Контент каждый раз новый, кешировать нечего, но текст хорошо сжимается
Статические CSS/JS/SVG/HTMLСжать один раз при сборке, отдавать через gzip_static/brotli_staticФайл не меняется — вычислять сжатие на каждый запрос бессмысленно
Изображения (JPEG/PNG/WebP/AVIF)Не сжимать повторноУже сжаты кодеком, повторное сжатие почти не уменьшает размер
Видео и аудиоНе сжимать повторноТо же самое — кодек уже устранил избыточность
Готовые архивы, PDF с компрессиейНе сжимать повторноФормат уже несёт внутреннее сжатие
Большие разовые экспорты/логи (динамические, но объёмные)Сжимать, но пониженным уровнем и с оглядкой на очередь запросовЭкономия трафика заметна, но нельзя занимать воркер надолго

Отдельный практический момент — на каком именно узле стека вы включаете сжатие. Если у вас балансировщик или обратный прокси перед несколькими бэкендами, разумно сжимать именно на этом уровне (один раз, централизованно), а не на каждом бэкенд-инстансе — так вы не размножаете одинаковую работу по всем узлам. Но это же означает, что весь CPU-бюджет на сжатие сосредоточен на одной машине, и на высоком трафике она может упереться в процессор раньше, чем в сеть — это стоит учитывать при выборе конфигурации сервера, который выполняет роль такой точки терминации.

Как заметить, что сжатие стало узким местом

Признак того, что сжатие на лету начало вредить больше, чем помогает — это когда CPU воркеров nginx (или другого прокси) стабильно высок именно на тех ядрах, что обслуживают выдачу ответов, а не на бэкенде, который формирует данные. Проверить это можно top/htop с сортировкой по потокам, или через nginx_status/stub_status, сопоставляя число активных соединений с загрузкой CPU в тот же момент.

Практичный способ разобраться — временно понизить gzip_comp_level (например, с 6 до 1) на копии конфигурации в тестовом окружении и сравнить загрузку CPU при одинаковом потоке запросов. Если загрузка процессора заметно падает, а размер ответов растёт незначительно — уровень был выбран избыточно высоким для вашего профиля трафика.

Также стоит смотреть на это в контексте всей машины: на небольшом VPS с 1–2 ядрами сжатие на высоком уровне может конкурировать за CPU с самим приложением или базой данных рядом. На выделенном сервере с достаточным числом ядер та же нагрузка может быть попросту незаметна на общем фоне. Если сжатие регулярно упирается в процессор именно потому, что ядер не хватает — это уже вопрос не настройки, а ресурсов сервера.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Стоит ли вообще отключать gzip ради экономии CPU?

Обычно нет — для текстовых динамических ответов выгода в трафике и времени загрузки страницы у пользователя, как правило, перевешивает умеренную нагрузку на CPU при разумном уровне сжатия (низком-среднем). Отключать стоит точечно — для типов контента, где сжатие не даёт эффекта, а не для всего сайта целиком.

Чем отличается brotli от gzip с точки зрения CPU, если коротко?

Brotli на сопоставимом уровне сжатия обычно требует больше вычислений из-за более сложного поиска совпадений и статического словаря, но и сжимает несколько плотнее. Для статики, сжимаемой заранее, эта разница в CPU не имеет значения — платите за неё один раз на сборке. Для динамики стоит проверить на своём трафике, оправдывает ли выигрыш в размере более высокую цену в CPU по сравнению с gzip.

Можно ли включить и gzip_static, и обычный gzip одновременно?

Да, это стандартная практика: gzip_static on отдаёт заранее сжатый файл, если он существует рядом с исходным, а обычный gzip on продолжает работать «на лету» для всего остального — динамических ответов или статики, для которой почему-то не был сгенерирован .gz-файл.

Что делать, если CDN уже сжимает контент на своей стороне?

Тогда сжатие на исходном сервере может быть избыточным для того трафика, что идёт через CDN — проверьте, не пересжимает ли CDN то, что вы уже сжали (или наоборот), и не тратит ли ваш сервер CPU на работу, которую потом всё равно переделает CDN.

Как понять, какой уровень сжатия оптимален именно для моего сайта?

Универсального числа нет — оно зависит от структуры контента, объёма трафика и запаса CPU на сервере. Практический путь — включить логирование размера ответов и CPU-метрики, сравнить пару уровней на реальном или синтетическом трафике и выбрать тот, при котором прирост CPU ещё не создаёт заметной задержки в обработке запросов.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →