Kompresja

OxPHP kompresuje odpowiedzi HTTP za pomocą Brotli, Zstandard lub gzip, zależnie od tego, co akceptuje klient. Kompresja stosowana jest automatycznie do zamkniętej listy typów zawartości (tekst, JSON, XML, SVG, WebAssembly i kilka formatów czcionek), dzięki czemu rozmiar transferu spada bez żadnych zmian w kodzie aplikacji.

Jak to działa

Nagłówek Accept-Encoding klienta jest odczytywany raz, zanim żądanie trafi do potoku routingu, i zestawiany z kodowaniami, które oferuje serwer. Co dzieje się dalej, zależy od tego, skąd pochodzą bajty.

Buforowany plik statyczny, który ma już zapisaną skompresowaną kopię, jest serwowany bezpośrednio z tej kopii. Przy żądaniu nic nie jest kompresowane i żadna z poniższych kontroli nie ma zastosowania. Zobacz Buforowane pliki statyczne.

Każda inna odpowiedź (wynik PHP, własna strona błędu, plik statyczny, który nie ma jeszcze zapisanej kopii) przechodzi przez ten sam zestaw kontroli, w podanej kolejności, po zbudowaniu odpowiedzi:

  1. Odpowiedzi częściowe są pomijane. Odpowiedź 206 nigdy nie jest kodowana, ponieważ offsety w jej Content-Range opisują niezakodowaną reprezentację.
  2. Kontrola typu zawartości. Typ MIME odpowiedzi jest wyszukiwany na liście kompresowalnych typów.
  3. Kontrola istniejącego kodowania. Odpowiedź, która ma już nagłówek Content-Encoding, pozostaje nietknięta.
  4. Kontrola rozmiaru. Kompresowane są tylko ciała o rozmiarze od 256 bajtów do 3 MiB (3 145 728 bajtów), włącznie z obiema granicami. Gdy odpowiedź ma nagłówek Content-Length, używany jest on; odpowiedzi PHP zwykle go nie mają i są mierzone na podstawie zbuforowanego ciała.
  5. Kompresja. Wynegocjowane kodowanie jest stosowane na skonfigurowanym dla niego poziomie. Jeśli skompresowany wynik nie jest mniejszy od oryginału, wysyłana jest odpowiedź nieskompresowana.
Note

Kompresja odbywa się po wykonaniu kodu PHP, po obsłużeniu plików statycznych i po podstawieniu własnych stron błędów, więc własne ciało 404 większe niż 256 bajtów jest kompresowane jak każda inna odpowiedź. Wbudowane ciała błędów (404 Not Found, 500 Internal Server Error) mają kilkadziesiąt bajtów i mieszczą się poniżej dolnego progu rozmiaru. Poza tym kod statusu nigdy nie jest brany pod uwagę.

Małe ciała są kompresowane w miejscu; powyżej progu praca przenosi się na wątek blokujący, żeby nie mogła zatrzymać asynchronicznego runtime'u. Próg wynosi 64 KiB i spada do 4 KiB, gdy poziom jest na tyle wysoki, że krzywa kosztu zaczyna gwałtownie rosnąć: powyżej jakości 4 dla Brotli, poziomu 6 dla gzip i poziomu 9 dla Zstandard. Przy domyślnych ustawieniach Brotli jest już w tej stromej części, więc ciała Brotli większe niż 4 KiB są kompresowane poza wątkiem asynchronicznym.

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

Wybór kodowania

Accept-Encoding szereguje kodowania według wag (RFC 9110 §12.5.3) i OxPHP odczytuje go właśnie w ten sposób:

  • Nazwy kodowań są dopasowywane bez rozróżniania wielkości liter, a nieznane kodowania są ignorowane.
  • br;q=0 odrzuca Brotli. To coś innego niż pominięcie kodowania: * może jeszcze objąć pominięte kodowanie, ale nie takie, które zostało jawnie odrzucone.
  • * obejmuje każde kodowanie, którego nagłówek nie wymienia jawnie, z przypisaną mu wagą.
  • Waga, której nie da się sparsować, pozostawia kodowanie z wagą domyślną; źle sformatowany parametr nie usuwa kodowania. Ma to znaczenie przy q=0. Wartość qvalue ma co najwyżej trzy miejsca po przecinku, więc q=0.000 odrzuca kodowanie, a q=0.0000 w ogóle nie jest poprawną wagą i oznacza pełną akceptację. Kodowanie wymienione dwukrotnie jest odczytywane z pierwszą wagą.
  • Spośród kodowań, które klient akceptuje i które serwer oferuje, wygrywa to z najwyższą wagą. Kodowanie wycofane w konfiguracji dostaje zero przed ustaleniem kolejności, więc klient, którego ulubionego kodowania serwer nie oferuje, przechodzi do swojego kolejnego wyboru, a nie do odpowiedzi nieskompresowanej.
  • Nagłówek, który niczego nie akceptuje (pusty albo *;q=0), daje odpowiedź niezakodowaną. OxPHP nigdy nie odpowiada 406 z powodu kodowania zawartości i nigdy nie traktuje identity jako tokenu.

W typowym przypadku wagi są równe, bo przeglądarki wysyłają wszystkie obsługiwane kodowania bez wag. OxPHP rozstrzyga taki remis według tego, co stanie się ze skompresowanymi bajtami:

Odpowiedź jest Preferowane kodowanie Dlaczego
Serwowana z zapisanej kopii Brotli, potem Zstandard, potem gzip Bajty są kompresowane raz, z maksymalną jakością, a potem serwowane z pamięci, więc liczy się wyłącznie rozmiar, a na szczycie swojego zakresu Brotli daje najmniejszy wynik z całej trójki
Każda inna Zstandard, potem Brotli, potem gzip Bajty są kompresowane, gdy klient czeka, a potem odrzucane, więc koszt jest ponoszony przy każdym żądaniu. Na poziomach, na które stać pojedyncze żądanie, Zstandard w pomiarach mieścił się w kilku procentach rozmiaru Brotli przy znacznie mniej niż połowie zużycia CPU

Pierwszy wiersz ma zastosowanie dopiero wtedy, gdy zapisana kopia już istnieje. Trafienia przed jej powstaniem, a także każdy plik zbyt duży do buforowania, podlegają drugiemu wierszowi, dlatego zasób statyczny jest zwykle serwowany najpierw z Zstandard, a dopiero później z Brotli.

Preferencja rozstrzyga wyłącznie remisy. Klient, który wysyła br;q=0.2, gzip;q=0.9, dostaje gzip na obu ścieżkach, a zapisana kopia zbudowana dla niego jest kopią gzip.

Klient, który nie akceptuje żadnego z trzech kodowań, otrzymuje odpowiedź niezakodowaną. W praktyce rolę zapasowego kodowania pełni gzip: akceptuje go każdy klient HTTP z ostatnich dwudziestu lat, podczas gdy Zstandard wymaga przeglądarki wydanej w 2024 roku lub później.

Zwykłe HTTP oznacza gzip

Przeglądarki uzależniają nowsze kodowania od połączenia, a nie tylko od własnego wieku: br i zstd są deklarowane przez HTTPS i wstrzymywane przez zwykłe HTTP. Witryna serwowana bez TLS dostaje gzip, niezależnie od tego, czego używają jej odwiedzający.

Vary: Accept-Encoding

Ponieważ odpowiedź zależy od nagłówka żądania, każda odpowiedź mieszcząca się w oknie kompresji ma nagłówek Vary: Accept-Encoding, skompresowana czy nie, statyczna czy dynamiczna. Reprezentacja, którą serwer zakodowałby dla jakiegoś klienta, zmienia się względem tego nagłówka nawet wtedy, gdy bieżący klient dostaje bajty identity, a współdzielona pamięć podręczna poinformowana inaczej przekazałaby te bajty klientowi, który prosił o kodowanie.

Poza tą regułą są dwa przypadki:

  • Dynamiczna odpowiedź, której serwer nigdy nie trzyma w całości (wszystko, co jest strumieniowane w miarę generowania przez PHP), nie ma Vary: ta ścieżka nie może kompresować przy żadnym nagłówku, więc odpowiedź faktycznie się nie zmienia. Plik statyczny na tyle duży, że jest strumieniowany, nie należy do tej grupy i ma ten nagłówek, ponieważ ten sam plik jest wczytywany w całości i kompresowany dla klienta, który wynegocjował kodowanie.
  • Odpowiedź 304 nie ma ani Vary, ani Content-Encoding: odświeża reprezentację, którą klient już ma, zamiast opisywać nową.

Vary jest dodawany jako osobna linia nagłówka, a nie scalany z tym, który aplikacja już wysłała, więc odpowiedź, która ustawiła Vary: Cookie, wychodzi z dwiema liniami Vary. Jest to poprawne i OxPHP przed dopisaniem odczytuje je wszystkie, żeby Accept-Encoding nigdy się nie powtórzył, ale pośrednik, który czyta tylko pierwszą linię, go nie zauważy.

Buforowane pliki statyczne

Plik statyczny, który mieści się w pamięci podręcznej zawartości (1 MiB lub mniej) i jest kompresowalny (typ z listy, co najmniej 256 bajtów), jest kompresowany raz, a nie przy każdym żądaniu.

  1. 1
    Pierwsze żądanie: chybienie pamięci podręcznej. Plik jest wczytywany z dysku, zapisywany w pamięci podręcznej i kompresowany na potrzeby tego żądania jak wszystko inne.
  2. 2
    Następne żądanie: uruchomienie budowy. Plik jest w pamięci podręcznej i jeśli nie ma jeszcze zapisanej kopii dla kodowania wynegocjowanego w tym żądaniu, serwer zleca jej zbudowanie w wątku w tle. Żądanie, które uruchomiło budowę, jest obsługiwane w trybie kompresji na żądanie, podobnie jak każde żądanie, które nadejdzie w trakcie budowy.
  3. 3
    Kopia gotowa. Kolejne żądania, które negocjują to samo kodowanie, są obsługiwane z zapisanej kopii.

Nie ma licznika trafień: wyzwalaczem jest pierwsze trafienie w pamięci podręcznej bez zapisanej kopii dla danego kodowania, więc plik serwowany dotąd wyłącznie klientom gzip dostaje kopię Brotli przy pierwszym kliencie Brotli, który na niego trafi.

Zapisane kopie są budowane na maksymalnym poziomie każdego kodowania (Brotli 11, Zstandard 19, gzip 9), niezależnie od skonfigurowanych poziomów dla żądań, z których zapisana kopia nigdy nie korzysta. Nie ma dla nich osobnego ustawienia: jedynym sposobem, żeby kopia nie była budowana, jest całkowite wycofanie kodowania. Maksymalna jakość daje zwykle o 8–12% mniej bajtów niż poziom Brotli używany dla żądań.

Każde kodowanie ma własną zapisaną kopię, budowaną na żądanie, więc plik serwowany wyłącznie klientom gzip nigdy nie kosztuje kompresji Brotli. Wszystkie kopie współdzielą walidator buforowanego pliku i są odrzucane razem z buforowanymi bajtami: gdy wpis zostaje usunięty, by zmieścić się w budżecie pamięci podręcznej 64 MiB (zapisane kopie są wliczane do tego budżetu), albo gdy włączone jest STATIC_REVALIDATE i wykryje zmianę pliku na dysku. To ustawienie jest domyślnie wyłączone, więc w domyślnym wdrożeniu zapisana kopia może zostać odrzucona tylko na dwa sposoby: przez usunięcie z pamięci podręcznej i przez restart. Bajty, które się nie kompresują, są oznaczane i nie są ponownie próbowane, a odpowiedź 304 ani nie uruchamia budowy, ani nie serwuje zapisanej kopii.

Kodowanie zmienia się raz na plik

Ponieważ oba sposoby rozstrzygania remisów się różnią, klient akceptujący zarówno Brotli, jak i Zstandard zwykle widzi jedną zmianę kodowania na plik: pierwsze trafienia dostają Zstandard, skompresowany na potrzeby danego żądania, a trafienia po powstaniu zapisanej kopii dostają Brotli. Obie są poprawnymi reprezentacjami tego samego zasobu i obie mają Vary, więc nic się nie psuje. Współdzielą też jeden słaby ETag, więc pamięć podręczna przechowująca wariant Zstandard pomyślnie go rewaliduje i zachowuje; zmiana dociera do nowych klientów, a nie do tych, którzy już zapisali kopię.

Pliki zbyt duże do buforowania

Między limitem pamięci podręcznej a górną granicą okna kompresji (od 1 MiB do 3 MiB, gdzie mieszczą się bundle frameworków, mapy źródeł i moduły WebAssembly) plik jest wczytywany w całości i kompresowany przy każdym żądaniu, o ile klient wynegocjował kodowanie, a typ jest kompresowalny. Nie jest buforowany i nigdy nie dostaje zapisanej kopii, więc jest to najdroższa kompresja, jaką serwer wykonuje w przeliczeniu na żądanie.

Tip

Postaw CDN przed zasobami z przedziału 1–3 MiB albo utrzymuj je poniżej limitu pamięci podręcznej 1 MiB, a praca zostanie wykonana raz.

Wczytanie takiego pliku trzyma go w pamięci do czasu zapisania odpowiedzi, więc jednocześnie wczytywana jest tylko ograniczona liczba takich plików. Żądanie, które przekroczy ten limit, dostaje plik strumieniowany i niezakodowany, zamiast czekać na zwolnienie miejsca. Przy utrzymującym się obciążeniu dużego zasobu część klientów dostaje go więc skompresowanego, a część nie; obie odpowiedzi są poprawne dla tego URL i w obu przypadkach mają Vary: Accept-Encoding.

Poza tym przypadkiem plik większy niż 1 MiB jest strumieniowany z dysku bez zmian: typ niekompresowalny, klient nieakceptujący żadnego kodowania albo cokolwiek powyżej 3 MiB.

Ile kosztuje plik statyczny, zależnie od rozmiaru:

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

Ta ścieżka zakłada typ kompresowalny i klienta, który wynegocjował kodowanie. Gdy zabraknie któregokolwiek z tych warunków, plik jest buforowany do 1 MiB, powyżej tego strumieniowany, zakresy są obsługiwane w całym przedziale i nic nie jest nigdzie kodowane.

Konfiguracja

Zmienna Wartość domyślna Opis
COMPRESSION_ENCODINGS br,zstd,gzip Kodowania oferowane przez serwer, oddzielone przecinkami. Przyjmuje br (lub brotli), zstd i gzip, bez rozróżniania wielkości liter i z dowolnymi białymi znakami wokół. Kolejność zapisu jest ignorowana; serwer wybiera kodowanie dla każdej odpowiedzi (zobacz Wybór kodowania). Ustaw całą wartość na off (lub none), aby całkowicie wyłączyć kompresję. off nie jest elementem listy, więc br,off jest odrzucane jako błąd
COMPRESSION_BROTLI_LEVEL 5 Jakość Brotli (od 0 do 11)
COMPRESSION_ZSTD_LEVEL 6 Poziom Zstandard (od 0 do 19)
COMPRESSION_GZIP_LEVEL 6 Poziom gzip (od 0 do 9)
COMPRESSION_LEVEL (nieustawiona) Przestarzała nazwa COMPRESSION_BROTLI_LEVEL. Jej ustawienie powoduje ostrzeżenie w logu przy starcie. Jawne COMPRESSION_BROTLI_LEVEL nadpisuje wartość niezerową, ale nie COMPRESSION_LEVEL=0, które wyłącza całą kompresję niezależnie od pozostałych ustawień

Kodowanie jest oferowane, gdy znajduje się w COMPRESSION_ENCODINGS i jego poziom nie wynosi 0; każdy z tych warunków osobno je wycofuje. Wycofane kodowanie znika z całej funkcji: nigdy nie jest negocjowane, nigdy nie jest używane do kompresji przy żądaniu i nigdy nie jest budowana dla niego zapisana kopia.

Wartości są walidowane, nie poprawiane

Nieznana nazwa kodowania, poziom spoza zakresu albo poziom, który nie jest liczbą, przerywa uruchamianie z błędem wskazującym zmienną. Nic nie jest przycinane do zakresu i nic nie jest po cichu pomijane. Puste przypisanie (COMPRESSION_ENCODINGS=, czyli postać, jaką daje ${VAR:-}, gdy brakuje zmiennej hosta) jest traktowane jak brak ustawienia i przywraca wartość domyślną. Nie oznacza off.

Migracja z COMPRESSION_LEVEL

Wcześniejsze wydania miały jedno kodowanie i jedno pokrętło: COMPRESSION_LEVEL, jakość Brotli z wartością domyślną 4, gdzie 0 wyłączało kompresję. Zmienna jest nadal odczytywana, z oboma znaczeniami, więc istniejące wdrożenia działają dalej, ale jej użycie powoduje ostrzeżenie o wycofaniu.

  • COMPRESSION_LEVEL=N (niezerowe) zamień na COMPRESSION_BROTLI_LEVEL=N.
  • COMPRESSION_LEVEL=0 zamień na COMPRESSION_ENCODINGS=off.

Pozostawienie starej wartości pozostawia też włączone Zstandard i gzip z wartościami domyślnymi, co zwykle jest pożądane: klienci, którzy nie akceptowali Brotli, dostawali wcześniej odpowiedzi nieskompresowane, a teraz dostają gzip.

Dlaczego takie wartości domyślne

  • Brotli 5, nie 4. Kolano krzywej jakości Brotli (zmiana hashera) leży między tymi dwoma poziomami. Na poziomie 4 dawał on więcej bajtów niż gzip na własnym poziomie domyślnym dla JSON powyżej 4 KB i dla prawdziwych zminifikowanych zasobów, zużywając przy tym więcej CPU, co nie pozostawia żadnego powodu, by przedkładać go nad gzip. Na poziomie 5 daje mniejszy wynik z tych dwóch na każdym zmierzonym ciele, przy mniej więcej dwukrotnym zużyciu CPU gzip.
  • Gzip 6. Domyślna wartość samego zlib, blisko punktu malejących korzyści. Poziom 9 kosztuje mniej więcej dwa razy więcej za procent lub dwa.
  • Zstandard 6, nie jego własne domyślne 3. Na ciałach większych niż kilka kilobajtów poziom 6 zajmuje mniej czasu niż jakość Brotli, którą wcześniejsze wydania kompresowały wszystko, i daje mniej bajtów, więc nic nie pogarsza się względem tego, co wysyłały tamte wydania.

Poziomy 9–11 lepiej nadają się do kompresji offline lub w czasie budowania niż do pracy przy każdym żądaniu; buforowane pliki statyczne i tak ich używają, ponieważ ten koszt jest ponoszony raz.

Kompresowalne typy zawartości

Lista jest zamknięta: nie ma reguły prefiksu ani wieloznacznika, więc typ, którego na niej nie ma, nigdy nie jest kompresowany, nawet jeśli dobrze by się kompresował. W jej obrębie dopasowanie jest wyrozumiałe. Parametry i otaczające białe znaki są pomijane, a sam typ jest porównywany bez rozróżniania wielkości liter, więc text/html; charset=utf-8 i Application/JSON oba pasują.

Typy tekstowe:

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

Typy application:

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

Inne typy:

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

Bez kompresji

Odpowiedzi są wysyłane bez kompresji, gdy spełniony jest którykolwiek z poniższych warunków:

  • Klient nie akceptuje w nagłówku Accept-Encoding żadnego z br, zstd i gzip, akceptuje je tylko z zerową wagą (br;q=0, zstd;q=0, gzip;q=0) albo w ogóle nie wysyła tego nagłówka
  • Każde kodowanie akceptowane przez klienta zostało wycofane przez COMPRESSION_ENCODINGS lub przez poziom 0
  • Odpowiedź ma już nagłówek Content-Encoding (np. treść wstępnie skompresowana przez aplikację)
  • Ciało odpowiedzi jest mniejsze niż 256 bajtów lub większe niż 3 MiB
  • Odpowiedź to 206 Partial Content: offsety zakresów opisują niezakodowaną reprezentację, więc częściowe ciało nigdy nie jest kodowane
  • Typu zawartości nie ma na powyższej liście. image/png, image/jpeg, font/woff2 i application/zip są wykluczone, bo te formaty mają już własną kompresję, a text/event-stream dlatego, że strumień zdarzeń musi docierać do klienta zdarzenie po zdarzeniu. text/csv, application/pdf, image/x-icon oraz odpowiedź w ogóle bez Content-Type również są wykluczone, bo nie ma ich na liście
  • Serwer nie trzyma ciała w całości: skrypty PHP używające oxphp_stream_flush(), Server-Sent Events i pliki statyczne czytane z dysku w trakcie wysyłania. OxPHP kompresuje odpowiedź w jednym kawałku. Brotli, Zstandard i gzip potrafią kodować przyrostowo, więc jest to ograniczenie serwera, a nie samych kodowań
  • Odpowiedź pochodzi z pluginu, który odpowiedział przed uruchomieniem potoku, np. 429 z limitu żądań, zwracane przed krokiem kompresji
  • Odpowiedź pochodzi z wewnętrznego listenera (INTERNAL_ADDR), który serwuje /metrics i endpointy health niezakodowane, niezależnie od tego, co akceptuje scraper

Nagłówki odpowiedzi

Odpowiedź kodowana przez serwer jest przepisywana następująco, niezależnie od tego, czy została skompresowana na potrzeby tego żądania, czy podana z zapisanej kopii:

Nagłówek Wartość
Content-Encoding br, zstd lub gzip, zależnie od wynegocjowanego kodowania
Content-Length Zaktualizowany do rozmiaru skompresowanego ciała odpowiedzi
Vary Dopisywane jest Accept-Encoding, dzięki czemu pamięci podręczne HTTP przechowują osobne wersje dla każdego kodowania
ETag Silny tag jest osłabiany do W/"…". Zakodowane bajty są inną reprezentacją, a silny tag współdzielony z bajtami identity pozwoliłby klientowi wznowić zakodowane pobieranie fragmentami identity
Accept-Ranges Usuwany. Offsety bajtowe nie mają sensu wobec zakodowanego ciała

Wszystkie kodowania jednego pliku statycznego współdzielą ten słaby tag, wyliczany z rozmiaru identity i czasu modyfikacji. To Vary rozdziela warianty; tag decyduje tylko o świeżości, więc pamięć podręczna rewalidująca jeden wariant nie może powiedzieć serwerowi, który z nich przechowuje.

Zakresy a kompresja

Obsługa zakresów jest wyłączana przed zbudowaniem odpowiedzi dla każdej odpowiedzi, która zostałaby skompresowana dla tego klienta. Kompresowalny plik statyczny mieszczący się w oknie kompresji jest serwowany jako pełne 200 klientowi, który wysyła zarówno Range, jak i Accept-Encoding, a niespełnialny zakres od takiego klienta dostaje to 200 zamiast 416. Klienci, którzy nie akceptują żadnego kodowania, zachowują zakresy i to z nich korzystają wznawiane pobierania.

Obserwowalność

Dwa liczniki na endpoincie metryk pokazują, co kompresja faktycznie zrobiła:

  • oxphp_compressed_responses_total: odpowiedzi wysłane z kodowaniem zawartości
  • oxphp_compression_bytes_saved_total: suma różnic między rozmiarem identity a rozmiarem po kompresji

Oba liczą zarówno trafienia w zapisane kopie, jak i kompresje przy żądaniu, więc stosunek do oxphp_requests_total odpowiada na pytanie „czy kompresja obejmuje mój ruch” pewniej niż pojedyncze curl -I.

Rozwiązywanie problemów

Odpowiedzi nie są kompresowane

Sprawdź, czy klient w ogóle wysyła nagłówek Accept-Encoding. Przeglądarki to robią, ale niektóre narzędzia do testowania HTTP domyślnie go nie wysyłają, a żądanie bez niego dostaje odpowiedź niezakodowaną.

Zerowa waga odrzuca kodowanie: Accept-Encoding: br;q=0, zstd;q=0, gzip;q=0 wyłącza kompresję dla tego żądania, tak samo jak brak nagłówka.

Sprawdź za pomocą curl:

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

Poszukaj Content-Encoding w nagłówkach odpowiedzi. Jeśli go brakuje, sprawdź, czy:

  1. COMPRESSION_ENCODINGS nadal zawiera kodowanie, o które prosisz, a jego poziom nie wynosi 0
  2. Ciało odpowiedzi ma co najmniej 256 bajtów i nie więcej niż 3 MiB
  3. Content-Type odpowiedzi znajduje się na liście kompresowalnych powyżej. Dopasowanie ignoruje wielkość liter i parametry
  4. Handler nie strumieniuje. Skrypt, który wywołuje oxphp_stream_flush(), wysyła Server-Sent Events albo w inny sposób zaczyna pisać, zanim znana jest długość, przechodzi bez zmian i jest to najczęstsza przyczyna w aplikacji, która opróżnia bufor
Różni klienci dostają różne kodowania

Negocjacja ma właśnie do tego prowadzić, a niemal wszystko wyjaśniają trzy przypadki. Przeglądarki wstrzymują br i zstd przez zwykłe HTTP: wysyłają tam Accept-Encoding: gzip, deflate i dostają gzip, niezależnie od tego, jak są nowe. Narzędzia wiersza poleceń często nie wysyłają Accept-Encoding wcale i dostają odpowiedź niezakodowaną. A przez HTTPS przeglądarka na tyle nowa, by wysyłać zstd, dostaje Zstandard dla odpowiedzi dynamicznej tam, gdzie starsza dostaje Brotli. W żadnym z tych przypadków nic nie jest źle skonfigurowane, a skompresowane odpowiedzi mają Vary: Accept-Encoding, więc pamięci podręczne je rozdzielają.

Kodowanie zmienia się dla tego samego pliku statycznego

To oczekiwane: pierwsze trafienia na buforowalny plik statyczny są kompresowane z Zstandard na potrzeby danego żądania, a gdy zapisana kopia Brotli zostanie zbudowana w tle, kolejne trafienia są obsługiwane z niej. Zobacz Buforowane pliki statyczne.

Żądanie zakresu zwraca cały plik

To oczekiwane dla kompresowalnego pliku w oknie kompresji, gdy klient wysyła też Accept-Encoding: serwowana mu reprezentacja jest zakodowana, a offsety bajtów identity jej nie opisują. To samo żądanie bez Accept-Encoding dostaje swoje 206. Zobacz Nagłówki odpowiedzi.

Kompresja powiększa odpowiedzi

Ciało, które się nie kompresuje (blob base64, ładunek losowych identyfikatorów, już skompresowany obraz opakowany jako image/svg+xml), może wyjść z każdego z trzech kodowań nie mniejsze, niż weszło. OxPHP porównuje oba wyniki i wysyła oryginał zawsze, gdy kompresja nie pomogła, więc nie wymaga to żadnej konfiguracji. Nie może to być problem małych odpowiedzi: 256 bajtów to próg, poniżej którego kompresja w ogóle nie jest próbowana.

Przepustowość spadła po włączeniu kompresji

Kompresja może podnieść albo obniżyć przepustowość. Kierunek zależy od tego, czego serwerowi brakowało przed jej włączeniem, a to samo ustawienie na tym samym serwerze może zmienić przepustowość kilkukrotnie w którąkolwiek stronę.

Przy nasyconym łączu to największa dostępna korzyść: odpowiedź tekstowa, która kurczy się ośmiokrotnie, jest dostarczana osiem razy szybciej, a zużyte na nią CPU i tak bezczynnie czekało na łącze.

Przy wąskim asynchronicznym runtimie to koszt. Ciało poniżej progu przekazania jest kompresowane na asynchronicznym wątku roboczym, a runtime domyślnie ustawia swój rozmiar na połowę procesorów dostępnych dla procesu, więc ta praca jest zabierana z puli obsługującej każde otwarte połączenie, podczas gdy reszta maszyny może mieć wolne zasoby. Niewiele trzeba, by tę pulę zapełnić: dwa wątki runtime'u kompresujące ciała JSON po 1,5 KB są nasycone samą tą pracą. Objaw, po którym to rozpoznać: przepustowość spada, a zarówno maszyna, jak i pula workerów PHP stają się mniej obciążone, bo to nie ich zabrakło.

Co sprawdzić: czy runtime jest węższy niż maszyna. TOKIO_WORKERS domyślnie wynosi połowę dostępnych rdzeni, a jego zwiększenie pozwala tej pracy wykorzystać większą część maszyny. Ile więcej, zależy od maszyny i od tego, co jeszcze na niej działa, więc mierz zamiast zakładać, i mierz przy tym pulę workerów: jej wątki są oddzielne od wątków runtime'u, więc we wdrożeniu, które już jest ograniczone przez CPU w PHP, szerszy runtime zabiera cykle aplikacji, zamiast znajdować nowe.

Wysyłanie małych ciał do puli blokującej tego nie naprawia. Na dwuwątkowym runtimie ciało JSON o rozmiarze 1,5 KB skompresowane w miejscu zajmuje około 7 µs wątku runtime'u; przekazane do puli blokującej i odebrane z powrotem zajmuje od 13 do 19 µs, ponieważ zlecenie pracy, parkowanie i wybudzanie same są pracą runtime'u. Przepustowość takiego runtime'u spada do od jednej trzeciej do połowy, a czas oczekiwania wszystkiego innego na runtimie rośnie zamiast maleć. Progi rozmiaru są ustawione z uwzględnieniem tego kosztu.

Wysokie zużycie CPU przez kompresję

Koszt kompresji rośnie gwałtownie na szczycie zakresu każdego kodowania (Brotli 8–11, gzip 8 i 9, Zstandard 12–19), a domyślne poziomy dla żądań leżą znacznie poniżej. Jeśli zaobserwujesz wysokie zużycie CPU przez kompresję:

Rozwiązanie: obniż COMPRESSION_ZSTD_LEVEL do 3 i COMPRESSION_GZIP_LEVEL do 4. Brotli to najdroższe z trzech kodowań, ale obniżenie COMPRESSION_BROTLI_LEVEL do 4 nie jest sposobem na oszczędzenie tego CPU: na poziomie 4 daje więcej bajtów niż gzip na własnym poziomie domyślnym, a nadal kosztuje więcej niż gzip. Zamiast tego usuń br z COMPRESSION_ENCODINGS. Klienci na tyle nowocześni, by wysyłać zstd, przechodzą do Zstandard, a nie do gzip, więc nic ich ta zmiana nie kosztuje; tylko klienci akceptujący Brotli bez Zstandard (Safari, przeglądarki sprzed 2024 roku, większość pośredników) przechodzą na gzip, który na prawdziwych ciałach jest o 2–6% większy przy mniej więcej połowie zużycia CPU.

Buforowane pliki statyczne nie wchodzą w ten koszt: ich skompresowane kopie są budowane raz w tle, niezależnie od tego, które kodowania pozostaną. Wchodzą w niego natomiast pliki od 1 MiB do 3 MiB. Są kompresowane przy każdym żądaniu i nie mogą być buforowane; zobacz Pliki zbyt duże do buforowania.

Wstępnie skompresowane zasoby są kompresowane ponownie

OxPHP nie szuka plików .br, .zst ani .gz obok oryginału: żądanie /app.js nigdy nie jest obsługiwane z /app.js.br, a bezpośrednie żądanie skompresowanego pliku serwuje go jako nieprzezroczyste pobranie, którego przeglądarki nie zdekodują. Kompresja zasobów statycznych w czasie budowania nie ma się gdzie podpiąć; tę rolę pełnią własne zapisane kopie serwera, z tą samą maksymalną jakością, bez kroku budowania.

Pomijanie ponownej kompresji dotyczy odpowiedzi, które mają już nagłówek Content-Encoding, czyli w praktyce skryptu PHP, który sam zakodował swoje ciało i sam ustawił ten nagłówek.

Przykład 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

Prompty dla Twojego agenta

Te prompty zakładają agenta z podłączonym serwerem MCP dokumentacji OxPHP — sam przeczyta strony, których potrzebuje.

prompt
Ustal, co OxPHP faktycznie skompresuje w mojej aplikacji, i dostrój kodowania. Przeczytaj stronę `features/compression` przez serwer MCP dokumentacji OxPHP. Przejrzyj typy zawartości i rozmiary odpowiedzi, które zwraca to repozytorium, powiedz mi, które z nich wypadają poza listę kompresowalnych albo poza okno od 256 B do 3 MiB, wskaż zasoby statyczne od 1 MiB do 3 MiB, które są kompresowane od nowa przy każdym żądaniu, i zaproponuj `COMPRESSION_ENCODINGS` oraz poziomy poszczególnych kodowań pod mieszankę ruchu, jaką obsługuje. Jeśli konfiguracja nadal ustawia przestarzałe `COMPRESSION_LEVEL`, przeprowadź migrację.

Zobacz także

Znalazłeś błąd? Zgłoś go →