Сжатие

OxPHP сжимает HTTP-ответы с помощью Brotli, Zstandard или gzip — в зависимости от того, что принимает клиент. Сжатие применяется автоматически к фиксированному списку типов контента (текст, JSON, XML, SVG, WebAssembly и несколько форматов шрифтов), поэтому объём передаваемых данных снижается без каких-либо изменений в коде вашего приложения.

Как это работает

Заголовок клиента Accept-Encoding читается один раз, до того как запрос попадёт в конвейер маршрутизации, и сопоставляется с кодировками, которые предлагает сервер. Что происходит дальше, зависит от того, откуда берутся байты.

Кешированный статический файл, для которого уже есть сохранённая сжатая копия, отдаётся прямо из этой копии. Во время запроса ничего не сжимается, и ни одна из проверок ниже не применяется. См. Кешированные статические файлы.

Любой другой ответ (вывод PHP, пользовательская страница ошибки, статический файл, для которого сохранённой копии ещё нет) проходит одни и те же проверки, в указанном порядке, после того как ответ сформирован:

  1. Частичные ответы пропускаются. Ответ 206 никогда не кодируется, потому что смещения в его Content-Range описывают некодированное представление.
  2. Проверка типа контента. MIME-тип ответа ищется в списке сжимаемых типов.
  3. Проверка уже применённой кодировки. Ответ, у которого уже есть заголовок Content-Encoding, не трогается.
  4. Проверка размера. Сжимаются только тела размером от 256 байт до 3 MiB (3 145 728 байт) включительно с обеих сторон. Если у ответа есть заголовок Content-Length, используется он; у PHP-ответов его обычно нет, и тогда измеряется буферизованное тело.
  5. Сжатие. Применяется согласованная кодировка с настроенным для неё уровнем. Если сжатый результат не меньше оригинала, вместо него отправляется несжатый ответ.
Note

Сжатие происходит после выполнения PHP, после отдачи статических файлов и после подстановки пользовательских страниц ошибок, поэтому пользовательское тело 404 больше 256 байт сжимается как любой другой ответ. Встроенные тела ошибок (404 Not Found, 500 Internal Server Error) занимают несколько десятков байт и не дотягивают до нижней границы размера. В остальном код статуса никак не учитывается.

Маленькие тела сжимаются прямо на месте; выше порога работа переносится в блокирующий поток, чтобы не останавливать асинхронный рантайм. Порог — 64 KiB, и он опускается до 4 KiB, когда уровень становится достаточно высоким, чтобы кривая затрат пошла круто вверх: выше качества 4 для Brotli, уровня 6 для gzip и уровня 9 для Zstandard. При поставляемых значениях по умолчанию Brotli уже находится в крутой половине, поэтому тела больше 4 KiB в Brotli сжимаются вне асинхронного потока.

flowchart TD
    AE["Accept-Encoding, read once per request"]
    W["Ranked by weight. A coding the server<br/>does not offer scores zero and drops out"]
    Q{"Is a stored copy<br/>being served?"}
    A["Tie broken br, then zstd, then gzip.<br/>Built once at br 11, zstd 19, gzip 9"]
    P["Tie broken zstd, then br, then gzip.<br/>Compressed now at the configured<br/>zstd 6, br 5, gzip 6"]
    H["Content-Encoding, recomputed Content-Length,<br/>Vary, weakened ETag, no Accept-Ranges"]
    AE --> W
    W --> Q
    Q -->|yes| A
    Q -->|no| P
    A --> H
    P --> H

Выбор кодировки

Accept-Encoding ранжирует кодировки по весу (RFC 9110 §12.5.3), и OxPHP читает его именно так:

  • Имена кодировок сравниваются без учёта регистра, неизвестные кодировки игнорируются.
  • br;q=0 отказывается от Brotli. Это не то же самое, что просто не указать кодировку: * может подхватить неназванную кодировку, но не ту, от которой явно отказались.
  • * покрывает любую кодировку, которую заголовок явно не называет, с указанным для него весом.
  • Вес, который не удаётся разобрать, оставляет кодировке вес по умолчанию; некорректный параметр не выбрасывает её. Это важно для q=0. Значение qvalue содержит не больше трёх знаков после запятой, поэтому q=0.000 означает отказ от кодировки, а q=0.0000 вообще не является допустимым весом и читается как полное согласие. Если кодировка названа дважды, учитывается её первый вес.
  • Среди кодировок, которые клиент принимает и которые предлагает сервер, побеждает та, у которой наибольший вес. Кодировка, отключённая в конфигурации, получает нулевой вес ещё до ранжирования, поэтому клиент, чью любимую кодировку сервер не предлагает, переходит к следующему своему варианту, а не к несжатому ответу.
  • Заголовок, который ничего не принимает (пустой или *;q=0), даёт некодированный ответ. OxPHP никогда не отвечает 406 из-за кодировки содержимого и никогда не воспринимает identity как токен.

В обычном случае веса равны, поскольку браузеры отправляют все поддерживаемые кодировки без весов. OxPHP разрешает эту ничью в зависимости от того, что будет со сжатыми байтами дальше:

Ответ Предпочтительная кодировка Почему
Отдаётся из сохранённой копии Brotli, затем Zstandard, затем gzip Байты сжимаются один раз с максимальным качеством и дальше отдаются из памяти, поэтому значение имеет только размер, а на верхней границе своего диапазона Brotli даёт наименьший результат из трёх
Всё остальное Zstandard, затем Brotli, затем gzip Байты сжимаются, пока клиент ждёт, и затем выбрасываются, поэтому цена платится на каждом запросе. На уровнях, которые может себе позволить запрос, Zstandard по размеру отстаёт от Brotli на считанные проценты, тратя заметно меньше половины CPU

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

Предпочтение лишь разрешает ничьи. Клиент, который пишет br;q=0.2, gzip;q=0.9, получает gzip на обоих путях, и сохранённая копия для него тоже строится в gzip.

Клиент, который не принимает ни одну из трёх кодировок, получает ответ без кодирования. На практике запасным вариантом служит gzip: его принимает любой HTTP-клиент последних двадцати лет, тогда как для Zstandard нужен браузер, выпущенный в 2024 году или позже.

Обычный HTTP — это сайт на gzip

Браузеры включают новые кодировки в зависимости от соединения, а не только от собственного возраста: br и zstd объявляются по HTTPS и не объявляются по обычному HTTP. Сайт, работающий без TLS, — это сайт на gzip, какими бы браузерами ни пользовались его посетители.

Vary: Accept-Encoding

Поскольку ответ зависит от заголовка запроса, каждый ответ внутри окна сжатия несёт Vary: Accept-Encoding — сжатый или нет, статический или динамический. Представление, которое сервер закодировал бы для какого-то клиента, зависит от этого заголовка, даже когда текущему клиенту отдаются байты identity, и разделяемый кеш, которому сказали бы обратное, передал бы эти байты клиенту, запросившему кодировку.

Под это правило не подпадают два случая:

  • Динамический ответ, который сервер никогда не держит целиком (всё, что передаётся потоком по мере вывода PHP), не несёт Vary: на этом пути сжатие невозможно ни при каком заголовке, так что ответ действительно не варьируется. Статический файл, достаточно большой для потоковой передачи, в эту группу не входит и заголовок несёт, потому что тот же файл читается целиком и сжимается для клиента, согласовавшего кодировку.
  • Ответ 304 не несёт ни Vary, ни Content-Encoding: он обновляет свежесть представления, которое уже есть у клиента, а не описывает новое.

Vary добавляется отдельной строкой заголовка, а не сливается с тем, что уже отправило приложение, поэтому ответ, установивший Vary: Cookie, уходит с двумя строками Vary. Это допустимо, и OxPHP читает их все перед добавлением, так что Accept-Encoding никогда не дублируется, но промежуточный узел, читающий только первую строку, его не увидит.

Кешированные статические файлы

Статический файл, который помещается в кеш контента (1 MiB или меньше) и подлежит сжатию (тип из списка, не меньше 256 байт), сжимается один раз, а не на каждом запросе.

  1. 1
    Первый запрос: промах кеша. Файл читается с диска, сохраняется в кеше и сжимается для этого запроса как любой другой ответ.
  2. 2
    Следующий запрос: запуск построения. Файл уже в кеше, и если для согласованной в этом запросе кодировки сохранённой копии ещё нет, сервер запрашивает её построение в фоновом потоке. Запрос, запустивший построение, обслуживается обычным способом со сжатием на запросе — как и все запросы, пришедшие, пока оно идёт.
  3. 3
    Копия готова. Последующие запросы, согласовавшие ту же кодировку, отдаются из сохранённой копии.

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

Сохранённые копии строятся на максимальном уровне каждой кодировки (Brotli 11, Zstandard 19, gzip 9) независимо от настроенных уровней для запросов — сохранённая копия их никогда не использует. Отдельной настройки для них нет: единственный способ не строить копию — полностью отключить кодировку. Максимальное качество обычно даёт на 8–12% меньше байт, чем Brotli на своём уровне для запросов.

Каждая кодировка получает собственную сохранённую копию, строящуюся по требованию, поэтому файл, который отдаётся только gzip-клиентам, никогда не тратит ресурсы на сжатие Brotli. Все копии разделяют валидатор кешированного файла и выбрасываются вместе с кешированными байтами: когда запись вытесняется, чтобы уложиться в бюджет кеша в 64 MiB (копии учитываются в нём), или когда включён STATIC_REVALIDATE и замечает, что файл на диске изменился. По умолчанию эта настройка выключена, так что в развёртывании по умолчанию сохранённая копия выбрасывается только при вытеснении и перезапуске. Байты, которые не сжимаются, помечаются как таковые и повторно не обрабатываются, а ответ 304 не запускает построение и не отдаёт сохранённую копию.

Кодировка меняется один раз для каждого файла

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

Файлы, слишком большие для кеша

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

Tip

Поставьте CDN перед ресурсами размером от 1 до 3 MiB или удерживайте их под лимитом кеша в 1 MiB — и работа будет выполнена один раз.

Чтение такого файла удерживает его в памяти, пока ответ не будет записан, поэтому одновременно читается лишь ограниченное число таких файлов. Запрос, пришедший сверх этого лимита, получает файл потоком и без кодирования, а не ждёт освобождения места. Поэтому под устойчивой нагрузкой на большой ресурс одни клиенты получают его сжатым, а другие нет; оба ответа корректны для этого URL, и Vary: Accept-Encoding есть в обоих.

Вне этого случая файл больше 1 MiB передаётся потоком с диска без изменений: несжимаемый тип, клиент, не принимающий ни одной кодировки, или всё, что больше 3 MiB.

Во что обходится статический файл в зависимости от размера:

flowchart TD
    F["Static file"]
    C1{"256 B or larger?"}
    C2{"1 MiB or smaller?"}
    C3{"3 MiB or smaller?"}
    N1["Sent as-is, and cached.<br/>The framing would cost more than it saves"]
    N2["Cached, and a stored copy is built once<br/>in the background on the first cache hit"]
    N3["Read whole and compressed on every request.<br/>Too large to cache, so no copy is kept"]
    N4["Streamed from disk, never compressed.<br/>Past the compression window"]
    F --> C1
    C1 -->|no| N1
    C1 -->|yes| C2
    C2 -->|yes| N2
    C2 -->|no| C3
    C3 -->|yes| N3
    C3 -->|no| N4

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

Конфигурация

Переменная По умолчанию Описание
COMPRESSION_ENCODINGS br,zstd,gzip Какие кодировки предлагает сервер, через запятую. Принимает br (или brotli), zstd и gzip без учёта регистра и с любыми пробелами вокруг. Порядок в списке игнорируется; сервер выбирает для каждого ответа сам (см. Выбор кодировки). Установите всё значение в off (или none), чтобы полностью отключить сжатие. off не является элементом списка, поэтому br,off отклоняется как ошибка
COMPRESSION_BROTLI_LEVEL 5 Качество Brotli (от 0 до 11)
COMPRESSION_ZSTD_LEVEL 6 Уровень Zstandard (от 0 до 19)
COMPRESSION_GZIP_LEVEL 6 Уровень gzip (от 0 до 9)
COMPRESSION_LEVEL (не задана) Устаревшее имя для COMPRESSION_BROTLI_LEVEL. Если она задана, при запуске в лог пишется предупреждение. Явно заданная COMPRESSION_BROTLI_LEVEL переопределяет ненулевое значение, но не COMPRESSION_LEVEL=0, которое отключает всё сжатие, что бы ещё ни было задано

Кодировка предлагается, когда она указана в COMPRESSION_ENCODINGS и её уровень не равен 0; любого из двух условий достаточно, чтобы её отключить. Отключённая кодировка исчезает из всей функциональности: она никогда не согласуется, никогда не применяется на пути запроса и никогда не используется для построения сохранённой копии.

Значения проверяются, а не исправляются

Неизвестное имя кодировки, уровень вне допустимого диапазона или уровень, не являющийся числом, прерывают запуск с ошибкой, в которой названа переменная. Ничего не обрезается до границ диапазона и ничего молча не отбрасывается. Пустое присваивание (COMPRESSION_ENCODINGS= — именно так выглядит ${VAR:-}, когда переменной на хосте нет) считается незаданным значением и восстанавливает значение по умолчанию. Оно не означает off.

Переход с COMPRESSION_LEVEL

В ранних релизах была одна кодировка и одна настройка: COMPRESSION_LEVEL — качество Brotli со значением по умолчанию 4, где 0 отключал сжатие. Переменная по-прежнему читается, с обоими значениями, так что существующие развёртывания продолжают работать, но при этом в лог пишется предупреждение об устаревании.

  • COMPRESSION_LEVEL=N (ненулевое) превращается в COMPRESSION_BROTLI_LEVEL=N.
  • COMPRESSION_LEVEL=0 превращается в COMPRESSION_ENCODINGS=off.

Если оставить старое значение, Zstandard и gzip тоже остаются включёнными со своими значениями по умолчанию, и обычно это именно то, что нужно: клиенты, не принимавшие Brotli, раньше получали несжатые ответы, а теперь получают gzip.

Почему именно такие значения по умолчанию

  • Brotli 5, а не 4. Точка перелома качества Brotli (смена хешера) лежит между ними. На уровне 4 он давал больше байт, чем gzip на своём уровне по умолчанию, — на JSON больше 4 KB и на реальных минифицированных ресурсах, — и тратил при этом больше CPU, так что причин предпочесть его gzip не остаётся вовсе. На уровне 5 он даёт меньший результат на каждом измеренном теле, примерно за удвоенные по сравнению с gzip затраты CPU.
  • Gzip 6. Собственное значение по умолчанию zlib, близкое к точке убывающей отдачи. Уровень 9 обходится примерно вдвое дороже ради одного-двух процентов.
  • Zstandard 6, а не его собственное значение по умолчанию 3. На телах больше нескольких килобайт уровень 6 тратит меньше времени, чем качество Brotli, с которым всё сжимали ранние релизы, и при этом даёт меньше байт, так что ничего не ухудшается по сравнению с тем, что отправляли те релизы.

Уровни 9–11 лучше подходят для оффлайн-сжатия или сжатия на этапе сборки, чем для работы на каждом запросе; кешированные статические файлы всё равно используют их, потому что эта цена платится один раз.

Сжимаемые типы контента

Список закрытый: никаких правил по префиксу или шаблону нет, так что тип, отсутствующий в списке, никогда не сжимается, даже если сжимался бы хорошо. Внутри списка сопоставление снисходительное. Параметры и окружающие пробелы отбрасываются, а сам тип сравнивается без учёта регистра, поэтому и text/html; charset=utf-8, и Application/JSON совпадают.

Текстовые типы:

  • text/html
  • text/css
  • text/plain
  • text/xml
  • text/javascript

Типы application:

  • application/javascript
  • application/json
  • application/xml
  • application/xhtml+xml
  • application/rss+xml
  • application/atom+xml
  • application/manifest+json
  • application/ld+json
  • application/wasm

Прочие типы:

  • image/svg+xml
  • font/ttf
  • font/otf
  • application/x-font-ttf
  • application/x-font-opentype
  • application/vnd.ms-fontobject

Не сжимается

Ответы отправляются без сжатия при выполнении любого из следующих условий:

  • Клиент не принимает ни br, ни zstd, ни gzip в заголовке Accept-Encoding, принимает их только с нулевым весом (br;q=0, zstd;q=0, gzip;q=0) или вовсе не отправляет такой заголовок
  • Все кодировки, которые принимает клиент, отключены через COMPRESSION_ENCODINGS или уровнем 0
  • Ответ уже содержит заголовок Content-Encoding (например, предварительно сжатый контент, сформированный приложением)
  • Тело ответа меньше 256 байт или больше 3 MiB
  • Ответ — 206 Partial Content: смещения диапазона описывают некодированное представление, поэтому частичное тело никогда не кодируется
  • Типа контента нет в списке выше. image/png, image/jpeg, font/woff2 и application/zip исключены, потому что эти форматы уже используют собственное сжатие, а text/event-stream — потому что поток событий должен доходить до клиента событие за событием. text/csv, application/pdf, image/x-icon и ответ вообще без Content-Type тоже исключены, потому что их нет в списке
  • Сервер не держит тело целиком: PHP-скрипты, использующие oxphp_stream_flush(), Server-Sent Events и статические файлы, читаемые с диска по мере отправки. OxPHP сжимает ответ одним куском. Brotli, Zstandard и gzip все умеют кодировать инкрементально, так что это ограничение сервера, а не кодировок
  • Ответ пришёл от плагина, который ответил до запуска конвейера, — например, 429 от ограничителя частоты запросов, который возвращается до шага сжатия
  • Ответ пришёл от внутреннего слушателя (INTERNAL_ADDR), который отдаёт /metrics и эндпоинты проверки здоровья без кодирования, что бы ни принимал сборщик метрик

Заголовки ответа

Ответ, который сервер кодирует, переписывается следующим образом — независимо от того, был ли он сжат для этого запроса или отдан из сохранённой копии:

Заголовок Значение
Content-Encoding br, zstd или gzip — какая была согласована
Content-Length Обновляется до размера сжатого тела
Vary Добавляется Accept-Encoding, благодаря чему HTTP-кеши хранят отдельные версии для каждой кодировки
ETag Сильный тег ослабляется до W/"…". Закодированные байты — это другое представление, и сильный тег, общий с байтами identity, позволил бы клиенту дописать возобновлённую закодированную загрузку фрагментами identity
Accept-Ranges Удаляется. Байтовые смещения не имеют смысла для закодированного тела

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

Диапазоны и сжатие

Обработка диапазонов отключается ещё до формирования ответа для каждого ответа, который был бы сжат для данного клиента. На сжимаемый статический файл внутри окна сжатия клиент, отправляющий и Range, и Accept-Encoding, получает полный 200, а невыполнимый диапазон от такого клиента получает этот же 200 вместо 416. Клиенты, не принимающие ни одной кодировки, сохраняют диапазоны — именно там и живут возобновляемые загрузки.

Наблюдаемость

Два счётчика на эндпоинте метрик показывают, что на самом деле сделало сжатие:

  • oxphp_compressed_responses_total: ответы, отправленные с кодировкой содержимого
  • oxphp_compression_bytes_saved_total: размер identity минус сжатый размер, суммарно

Оба учитывают как попадания в сохранённые копии, так и сжатие на запросе, поэтому отношение к oxphp_requests_total отвечает на вопрос «доходит ли сжатие до моего трафика» надёжнее, чем одиночный curl -I.

Устранение неполадок

Ответы не сжимаются

Убедитесь, что клиент вообще отправляет заголовок Accept-Encoding. Браузеры это делают, но некоторые инструменты для тестирования HTTP по умолчанию его не отправляют, а запрос без него получает некодированный ответ.

Нулевой вес означает отказ от кодировки: Accept-Encoding: br;q=0, zstd;q=0, gzip;q=0 отключает сжатие для этого запроса точно так же, как отсутствие заголовка.

Проверьте с помощью curl:

bash
curl -H "Accept-Encoding: br, zstd, gzip" -I http://localhost/

Ищите Content-Encoding в заголовках ответа. Если он отсутствует, проверьте, что:

  1. COMPRESSION_ENCODINGS по-прежнему содержит запрошенную кодировку, и её уровень не равен 0
  2. Тело ответа не меньше 256 байт и не больше 3 MiB
  3. Content-Type ответа есть в списке сжимаемых выше. Сопоставление не учитывает регистр и параметры
  4. Обработчик не передаёт ответ потоком. Скрипт, который вызывает oxphp_stream_flush(), отправляет Server-Sent Events или иначе начинает писать до того, как известна длина, пропускается без изменений, и в приложении, сбрасывающем вывод, это самая частая причина
Разные клиенты получают разные кодировки

Так и задумано при согласовании, и почти всё объясняется тремя случаями. Браузеры не объявляют br и zstd по обычному HTTP: там они отправляют Accept-Encoding: gzip, deflate и получают gzip, какими бы свежими они ни были. Инструменты командной строки часто вообще не отправляют Accept-Encoding и получают ответ без кодирования. А по HTTPS браузер, достаточно новый, чтобы отправлять zstd, получает на динамический ответ Zstandard там, где более старый получает Brotli. Ни в одном из этих случаев ничего не настроено неправильно, а сжатые ответы несут Vary: Accept-Encoding, так что кеши хранят их раздельно.

Кодировка одного и того же статического файла меняется

Ожидаемо: первые обращения к кешируемому статическому файлу сжимаются в Zstandard под конкретный запрос, а как только в фоне будет построена сохранённая копия в Brotli, последующие обращения обслуживаются из неё. См. Кешированные статические файлы.

Запрос диапазона возвращает весь файл

Ожидаемо для сжимаемого файла внутри окна сжатия, когда клиент также отправляет Accept-Encoding: отдаваемое ему представление закодировано, и байтовые смещения identity его не описывают. Тот же запрос без Accept-Encoding получает свой 206. См. Заголовки ответа.

Сжатие увеличивает размер ответов

Тело, которое не сжимается (base64-блоб, набор случайных идентификаторов, уже сжатое изображение, обёрнутое в image/svg+xml), может выйти из любой из трёх кодировок не меньше, чем вошло. OxPHP сравнивает оба варианта и отправляет оригинал всякий раз, когда сжатие не помогло, так что настраивать ничего не нужно. Проблемой маленьких ответов это быть не может: 256 байт — нижняя граница, ниже которой сжатие даже не пробуется.

Пропускная способность упала после включения сжатия

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

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

Когда асинхронный рантайм узкий, это издержки. Тело меньше порога передачи сжимается в асинхронном рабочем потоке, а рантайм по умолчанию занимает половину CPU, доступных процессу, так что эта работа отнимается у пула, обслуживающего все открытые соединения, в то время как у остальной машины могут оставаться свободные ресурсы. Заполнить его несложно: два потока рантайма, сжимающие JSON-тела по 1,5 KB, насыщаются одной только этой работой. Характерный симптом: пропускная способность падает, а машина и пул PHP-воркеров становятся менее загруженными, потому что ресурс закончился не у них.

Что проверить: не уже ли рантайм, чем машина. TOKIO_WORKERS по умолчанию равен половине доступных ядер, и его увеличение позволяет этой работе использовать больше ресурсов машины. Насколько больше — зависит от машины и от того, что на ней ещё работает, так что измеряйте, а не предполагайте, и заодно измеряйте пул воркеров: его потоки отделены от потоков рантайма, поэтому в развёртывании, которое уже упирается в CPU на PHP, более широкий рантайм отбирает такты у приложения, а не находит новые.

Отправка маленьких тел в блокирующий пул эту проблему не решает. На двухпоточном рантайме JSON-тело в 1,5 KB, сжатое на месте, занимает около 7 мкс потока рантайма; переданное в блокирующий пул и забранное обратно, оно занимает 13–19 мкс, потому что отправка работы, парковка и пробуждение сами по себе являются работой рантайма. Пропускная способность на таком рантайме падает до трети–половины, а время ожидания всего остального на рантайме растёт, а не снижается. Пороги по размеру выбраны с учётом этой цены.

Высокая нагрузка на CPU из-за сжатия

Стоимость сжатия круто растёт на верхней границе диапазона каждой кодировки (Brotli 8–11, gzip 8 и 9, Zstandard 12–19), а значения по умолчанию для запросов находятся заметно ниже. Если вы наблюдаете высокое потребление CPU из-за сжатия:

Решение: понизьте COMPRESSION_ZSTD_LEVEL до 3 и COMPRESSION_GZIP_LEVEL до 4. Brotli — самая дорогая из трёх кодировок, но понижение COMPRESSION_BROTLI_LEVEL до 4 не способ сэкономить этот CPU: на уровне 4 он даёт больше байт, чем gzip на своём уровне по умолчанию, и всё равно стоит дороже gzip. Вместо этого уберите br из COMPRESSION_ENCODINGS. Клиенты, достаточно современные, чтобы отправлять zstd, переходят на Zstandard, а не на gzip, так что для них это изменение ничего не стоит; на gzip переходят только клиенты, принимающие Brotli без Zstandard (Safari, браузеры до 2024 года, большинство промежуточных узлов), а он на реальных телах больше на 2–6% примерно за половину CPU.

Кешированные статические файлы в эту цену не входят: их сжатые копии строятся один раз в фоне, какие бы кодировки ни остались. А вот файлы от 1 MiB до 3 MiB входят. Они сжимаются на каждом запросе и не могут быть закешированы; см. Файлы, слишком большие для кеша.

Предварительно сжатые ресурсы сжимаются повторно

OxPHP не ищет файлы .br, .zst или .gz рядом с оригиналом: запрос /app.js никогда не обслуживается из /app.js.br, а прямой запрос сжатого файла отдаёт его как непрозрачную загрузку, которую браузеры не декодируют. Сжатию статических ресурсов на этапе сборки подключиться некуда; эту роль выполняют собственные сохранённые копии сервера — с тем же максимальным качеством и без шага сборки.

Пропуск повторного сжатия касается ответов, у которых уже есть заголовок Content-Encoding, а на практике это означает PHP-скрипт, который сам закодировал своё тело и сам установил заголовок.

Пример с Docker

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.13.0 ports: - "8080:80" volumes: - ./src:/var/www/html environment: - DOCUMENT_ROOT=/var/www/html/public - ENTRY_FILE=index.php - COMPRESSION_ENCODINGS=br,zstd,gzip - COMPRESSION_BROTLI_LEVEL=5 - COMPRESSION_ZSTD_LEVEL=6 - COMPRESSION_GZIP_LEVEL=6

Промпты для вашего агента

Эти промпты рассчитаны на агента с подключённым MCP-сервером документации OxPHP — нужные страницы он прочитает сам.

prompt
Разберись, что OxPHP на самом деле будет сжимать в моём приложении, и настрой кодировки. Прочитай страницу `features/compression` через MCP-сервер документации OxPHP. Пройдись по типам содержимого и размерам ответов, которые отдаёт этот репозиторий, скажи мне, какие из них выпадают из списка сжимаемых или из окна от 256 B до 3 MiB, отметь статические ресурсы размером от 1 MiB до 3 MiB, которые пересжимаются на каждом запросе, и подбери `COMPRESSION_ENCODINGS` и уровни для каждой кодировки под тот состав трафика, который он обслуживает. Если в конфигурации всё ещё задана устаревшая `COMPRESSION_LEVEL`, перенеси её.

Смотрите также

Нашли ошибку? Сообщите →