Compression

OxPHP compresse les réponses HTTP avec Brotli, Zstandard ou gzip, selon ce que le client accepte. La compression s'applique automatiquement à une liste fixe de types de contenu (texte, JSON, XML, SVG, WebAssembly et plusieurs formats de polices), de sorte que la taille des transferts diminue sans aucune modification du code de votre application.

Fonctionnement

L'en-tête Accept-Encoding du client est lu une seule fois, avant que la requête n'atteigne le pipeline de routage, et confronté aux encodages que le serveur propose. La suite dépend de la provenance des octets.

Un fichier statique en cache qui dispose déjà d'une copie compressée stockée est servi directement depuis cette copie. Rien n'est compressé pendant la requête, et aucune des vérifications ci-dessous ne s'applique. Voir Fichiers statiques en cache.

Toutes les autres réponses (sortie PHP, page d'erreur personnalisée, fichier statique sans copie stockée pour l'instant) passent par les mêmes vérifications, dans cet ordre, une fois la réponse construite :

  1. Les réponses partielles sont ignorées. Une réponse 206 n'est jamais encodée, car les positions de son Content-Range décrivent la représentation non encodée.
  2. Vérification du type de contenu. Le type MIME de la réponse est recherché dans la liste des types compressibles.
  3. Vérification d'un encodage préexistant. Une réponse qui porte déjà un en-tête Content-Encoding est laissée telle quelle.
  4. Vérification de la taille. Seuls les corps compris entre 256 octets et 3 Mio (3 145 728 octets), bornes incluses, sont compressés. L'en-tête Content-Length est utilisé lorsque la réponse en porte un ; les réponses PHP n'en ont généralement pas et sont mesurées sur le corps mis en tampon.
  5. Compression. L'encodage négocié est appliqué à son niveau configuré. Si la sortie compressée n'est pas plus petite que l'originale, c'est la réponse non compressée qui est envoyée à la place.
Note

La compression intervient après l'exécution de PHP, après le service des fichiers statiques et après la substitution des pages d'erreur personnalisées : un corps 404 personnalisé de plus de 256 octets est donc compressé comme n'importe quelle autre réponse. Les corps d'erreur intégrés (404 Not Found, 500 Internal Server Error) ne font que quelques dizaines d'octets et restent sous le seuil de taille. Le code de statut n'est jamais consulté par ailleurs.

Les petits corps sont compressés en ligne ; au-delà d'un seuil, le travail passe sur un thread bloquant afin de ne pas paralyser le runtime asynchrone. Ce seuil est de 64 Kio et descend à 4 Kio dès que le niveau est assez élevé pour que la courbe de coût s'infléchisse : au-dessus de la qualité 4 pour Brotli, du niveau 6 pour gzip, du niveau 9 pour Zstandard. Avec les valeurs livrées par défaut, Brotli se trouve déjà dans la partie raide, si bien que les corps Brotli de plus de 4 Kio sont compressés hors du thread asynchrone.

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

Choisir un encodage

Accept-Encoding classe les encodages par poids (RFC 9110 §12.5.3), et OxPHP le lit ainsi :

  • Les noms d'encodage sont comparés sans tenir compte de la casse, et les encodages inconnus sont ignorés.
  • br;q=0 refuse Brotli. Ce n'est pas la même chose que d'omettre l'encodage : un * peut encore couvrir un encodage omis, mais pas un encodage explicitement refusé.
  • Un * couvre tout encodage que l'en-tête ne nomme pas explicitement, avec le poids qui lui est attribué.
  • Un poids illisible laisse l'encodage au poids par défaut ; un paramètre mal formé ne l'élimine pas. Cela compte pour q=0. Une qvalue comporte au plus trois décimales : q=0.000 refuse donc l'encodage, tandis que q=0.0000 n'est pas un poids valide du tout et se lit comme une acceptation complète. Un encodage nommé deux fois est lu avec son premier poids.
  • Parmi les encodages que le client accepte et que le serveur propose, celui qui a le poids le plus élevé l'emporte. Un encodage retiré par la configuration reçoit un score nul avant le classement : un client dont l'encodage favori n'est pas proposé par le serveur passe donc à son choix suivant plutôt qu'à une réponse non compressée.
  • Un en-tête qui n'accepte rien (vide, ou *;q=0) donne une réponse non encodée. OxPHP ne répond jamais 406 pour une question d'encodage de contenu, et ne lit jamais identity comme un jeton.

Les poids sont à égalité dans le cas habituel, puisque les navigateurs envoient tous les encodages qu'ils prennent en charge sans poids. OxPHP départage alors selon ce que deviennent les octets compressés :

La réponse est Encodage préféré Pourquoi
Servie depuis une copie stockée Brotli, puis Zstandard, puis gzip Les octets sont compressés une seule fois à la qualité maximale puis servis depuis la mémoire, donc seule la taille compte, et au sommet de sa plage Brotli est le plus compact des trois
Tout le reste Zstandard, puis Brotli, puis gzip Les octets sont compressés pendant que le client attend puis jetés, le coût est donc payé à chaque requête. Aux niveaux qu'une requête peut se permettre, Zstandard s'est mesuré à quelques pour cent de Brotli en taille pour bien moins de la moitié du CPU

La première ligne ne s'applique qu'une fois la copie stockée disponible. Les accès qui la précèdent, et tout fichier trop volumineux pour le cache, relèvent de la seconde ligne : c'est pourquoi une ressource statique est généralement servie en Zstandard avant de l'être en Brotli.

La préférence ne sert qu'à départager. Un client qui écrit br;q=0.2, gzip;q=0.9 reçoit du gzip sur les deux chemins, et la copie stockée construite pour lui est une copie gzip.

Un client qui n'accepte aucun des trois reçoit la réponse non encodée. En pratique, gzip sert de repli : tous les clients HTTP des vingt dernières années l'acceptent, alors que Zstandard exige un navigateur sorti en 2024 ou après.

En HTTP simple, le site est un site gzip

Les navigateurs conditionnent les encodages récents à la connexion plutôt qu'à leur seule version : br et zstd sont annoncés en HTTPS et retenus en HTTP simple. Un site servi sans TLS est un site gzip, quel que soit le navigateur de ses visiteurs.

Vary: Accept-Encoding

Comme la réponse dépend de l'en-tête de la requête, toute réponse située dans la fenêtre de compression porte Vary: Accept-Encoding, qu'elle soit compressée ou non, statique ou dynamique. Une représentation que le serveur encoderait pour un autre client varie selon cet en-tête même lorsque ce client-ci reçoit les octets identité, et un cache partagé à qui l'on dirait le contraire transmettrait ces octets à un client ayant demandé un encodage.

Deux cas échappent à cette règle :

  • Une réponse dynamique que le serveur ne détient jamais en entier (tout ce qui est diffusé en flux au fil de la production PHP) ne porte pas de Vary : ce chemin ne peut compresser sous aucun en-tête, la réponse ne varie donc réellement pas. Un fichier statique assez volumineux pour être diffusé en flux n'appartient pas à ce groupe et porte bien l'en-tête, car le même fichier est lu en entier et compressé pour un client qui a négocié un encodage.
  • Une réponse 304 ne porte ni Vary ni Content-Encoding : elle rafraîchit une représentation que le client détient déjà au lieu d'en décrire une nouvelle.

Vary est ajouté sur sa propre ligne d'en-tête au lieu d'être fusionné avec celle que l'application a déjà envoyée : une réponse qui a défini Vary: Cookie part donc avec deux lignes Vary. C'est légal, et OxPHP les lit toutes avant d'ajouter la sienne afin de ne jamais dupliquer Accept-Encoding, mais un intermédiaire qui ne lit que la première ligne la manquera.

Fichiers statiques en cache

Un fichier statique qui tient dans le cache de contenu (1 Mio ou moins) et qui est compressible (type listé, au moins 256 octets) est compressé une seule fois plutôt qu'à chaque requête.

  1. 1
    Première requête : défaut de cache. Le fichier est lu sur le disque, stocké, et compressé pour cette requête comme n'importe quelle autre réponse.
  2. 2
    Requête suivante : construction déclenchée. Le fichier est en cache, et s'il n'existe pas encore de copie stockée pour l'encodage négocié par cette requête, le serveur en demande la construction sur un thread d'arrière-plan. La requête qui a déclenché la construction est servie de la manière habituelle, par compression à la requête, tout comme chaque requête qui arrive pendant la construction.
  3. 3
    La copie est prête. Les requêtes suivantes qui négocient le même encodage sont servies depuis la copie stockée.

Il n'y a pas de compteur d'accès : le déclencheur est le premier accès au cache sans copie stockée pour cet encodage. Un fichier qui n'a jamais été servi qu'à des clients gzip obtient donc sa copie Brotli au premier client Brotli qui le demande.

Les copies stockées sont construites au niveau maximal de chaque encodage (Brotli 11, Zstandard 19, gzip 9), indépendamment des niveaux configurés pour la compression à la requête, qu'une copie stockée n'utilise jamais. Aucun réglage distinct ne les concerne : le seul moyen d'empêcher leur construction est de retirer entièrement l'encodage. La qualité maximale produit généralement 8 à 12 % d'octets en moins que le niveau de Brotli utilisé à la requête.

Chaque encodage a sa propre copie stockée, construite à la demande : un fichier servi uniquement à des clients gzip ne coûte donc jamais une compression Brotli. Toutes partagent le validateur du fichier en cache et sont jetées en même temps que les octets en cache : lorsque l'entrée est évincée pour rester dans le budget de cache de 64 Mio, auquel les copies stockées sont imputées, ou lorsque STATIC_REVALIDATE est activé et remarque que le fichier a changé sur le disque. Ce réglage est désactivé par défaut : sur un déploiement par défaut, l'éviction et le redémarrage sont donc les deux seules façons dont une copie stockée est jetée. Les octets qui ne se compressent pas sont marqués comme tels et ne sont pas retentés, et une réponse 304 ne déclenche aucune construction et ne sert aucune copie stockée.

L'encodage change une fois par fichier

Comme les deux règles de départage diffèrent, un client qui accepte à la fois Brotli et Zstandard voit généralement l'encodage changer une fois par fichier : les premiers accès reçoivent du Zstandard, compressé pour servir cette requête, et les accès postérieurs à la disponibilité de la copie stockée reçoivent du Brotli. Les deux sont des représentations valides de la même ressource et portent toutes deux Vary, donc rien ne casse. Elles partagent aussi un même ETag faible : un cache qui détient la variante Zstandard la revalide avec succès et la conserve ; le changement atteint les nouveaux clients, pas ceux qui ont déjà stocké une copie.

Fichiers trop volumineux pour le cache

Entre la limite du cache et le haut de la fenêtre de compression (de 1 Mio à 3 Mio, là où vivent les bundles de frameworks, les source maps et les modules WebAssembly), un fichier est lu en entier et compressé à chaque requête, dès que le client a négocié un encodage et que le type est compressible. Il n'est pas mis en cache et n'obtient jamais de copie stockée : c'est la compression la plus coûteuse que le serveur effectue par requête.

Tip

Placez un CDN devant les ressources de la plage de 1 à 3 Mio, ou gardez-les sous la limite de cache de 1 Mio, et le travail n'est fait qu'une fois.

La lecture d'un tel fichier le garde en mémoire jusqu'à ce que la réponse soit écrite, si bien que seul un nombre limité d'entre eux est lu à la fois. Une requête qui arrive au-delà de cette limite reçoit le fichier diffusé en flux et non encodé plutôt que d'attendre qu'une place se libère. Sous une charge soutenue sur une ressource volumineuse, certains clients la reçoivent donc compressée et d'autres non ; les deux réponses sont correctes pour l'URL, et Vary: Accept-Encoding est présent dans les deux cas.

En dehors de ce cas, un fichier de plus de 1 Mio est diffusé en flux depuis le disque sans modification : type non compressible, client qui n'accepte aucun encodage, ou tout ce qui dépasse 3 Mio.

Ce que coûte un fichier statique, selon sa taille :

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

Ce parcours suppose un type compressible et un client qui a négocié un encodage. Retirez l'une ou l'autre condition : le fichier est mis en cache jusqu'à 1 Mio, diffusé en flux au-delà, les plages sont honorées partout, et rien n'est encodé nulle part.

Configuration

Variable Valeur par défaut Description
COMPRESSION_ENCODINGS br,zstd,gzip Encodages proposés par le serveur, séparés par des virgules. Accepte br (ou brotli), zstd et gzip, sans tenir compte de la casse ni des espaces autour. L'ordre indiqué ici est ignoré ; le serveur choisit pour chaque réponse (voir Choisir un encodage). Donnez à la valeur entière off (ou none) pour désactiver complètement la compression. off n'est pas un élément de liste : br,off est donc rejeté comme une erreur
COMPRESSION_BROTLI_LEVEL 5 Qualité Brotli (0 à 11)
COMPRESSION_ZSTD_LEVEL 6 Niveau Zstandard (0 à 19)
COMPRESSION_GZIP_LEVEL 6 Niveau gzip (0 à 9)
COMPRESSION_LEVEL (non défini) Ancien nom, déprécié, de COMPRESSION_BROTLI_LEVEL. Le définir journalise un avertissement au démarrage. Un COMPRESSION_BROTLI_LEVEL explicite l'emporte sur une valeur non nulle, mais pas sur COMPRESSION_LEVEL=0, qui désactive toute compression quels que soient les autres réglages

Un encodage est proposé lorsqu'il figure dans COMPRESSION_ENCODINGS et que son niveau n'est pas 0 ; l'une ou l'autre condition suffit à le retirer. Un encodage retiré disparaît de toute la fonctionnalité : il n'est jamais négocié, jamais compressé sur le chemin de la requête et jamais construit en copie stockée.

Les valeurs sont validées, pas corrigées

Un nom d'encodage inconnu, un niveau hors de sa plage ou un niveau qui n'est pas un nombre interrompt le démarrage avec une erreur qui nomme la variable. Rien n'est borné et rien n'est ignoré en silence. Une affectation vide (COMPRESSION_ENCODINGS=, la forme que produit ${VAR:-} lorsque la variable de l'hôte est absente) est traitée comme non définie et rétablit la valeur par défaut. Elle ne signifie pas off.

Migrer depuis COMPRESSION_LEVEL

Les versions précédentes n'avaient qu'un encodage et qu'un réglage : COMPRESSION_LEVEL, une qualité Brotli valant 4 par défaut, 0 désactivant la compression. La variable est toujours lue, avec ses deux significations, si bien que les déploiements existants continuent de fonctionner, mais elle journalise un avertissement de dépréciation.

  • COMPRESSION_LEVEL=N (non nul) devient COMPRESSION_BROTLI_LEVEL=N.
  • COMPRESSION_LEVEL=0 devient COMPRESSION_ENCODINGS=off.

Conserver l'ancienne valeur laisse aussi Zstandard et gzip actifs à leurs valeurs par défaut, ce qui est généralement ce que vous voulez : les clients qui n'acceptaient pas Brotli recevaient auparavant des réponses non compressées et reçoivent désormais du gzip.

Pourquoi ces valeurs par défaut

  • Brotli 5, et non 4. Le coude de qualité de Brotli (un changement de fonction de hachage) se situe entre les deux. À 4, il produisait plus d'octets que gzip à son propre niveau par défaut sur du JSON de plus de 4 Ko et sur de vraies ressources minifiées, en consommant davantage de CPU, ce qui ne laisse aucune raison de le préférer à gzip. À 5, il est le plus compact des deux sur tous les corps mesurés, pour environ deux fois le CPU de gzip.
  • Gzip 6. Le niveau par défaut de zlib lui-même, proche du point de rendement décroissant. Le niveau 9 coûte environ deux fois plus pour un ou deux pour cent.
  • Zstandard 6, et non son propre défaut de 3. Sur les corps de plus de quelques kilo-octets, le niveau 6 coûte moins de temps que la qualité Brotli avec laquelle les versions précédentes compressaient tout, tout en produisant moins d'octets : rien ne régresse par rapport à ce qu'envoyaient ces versions.

Les niveaux 9 à 11 conviennent mieux à une compression hors ligne ou effectuée au moment de la construction qu'au travail par requête ; les fichiers statiques en cache les utilisent malgré tout, car ce coût n'est payé qu'une fois.

Types de contenu compressibles

La liste est fermée : il n'existe aucune règle de préfixe ni de joker, donc un type absent de la liste n'est jamais compressé, même s'il se compresserait bien. À l'intérieur de la liste, la correspondance est tolérante. Les paramètres et les espaces autour sont supprimés et le type lui-même est comparé sans tenir compte de la casse : text/html; charset=utf-8 et Application/JSON correspondent tous deux.

Types texte :

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

Types application :

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

Autres types :

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

Non compressé

Les réponses sont envoyées sans compression lorsque l'une des conditions suivantes est remplie :

  • Le client n'accepte aucun de br, zstd et gzip dans l'en-tête Accept-Encoding, ne les accepte qu'avec un poids nul (br;q=0, zstd;q=0, gzip;q=0), ou n'envoie pas cet en-tête du tout
  • Chaque encodage accepté par le client a été retiré par COMPRESSION_ENCODINGS ou par un niveau de 0
  • La réponse possède déjà un en-tête Content-Encoding (par exemple un contenu pré-compressé produit par l'application)
  • Le corps de la réponse fait moins de 256 octets ou plus de 3 Mio
  • La réponse est un 206 Partial Content : les positions de la plage décrivent la représentation non encodée, un corps partiel n'est donc jamais encodé
  • Le type de contenu ne figure pas dans la liste ci-dessus. image/png, image/jpeg, font/woff2 et application/zip sont exclus parce que ces formats embarquent déjà leur propre compression, et text/event-stream parce qu'un flux d'événements doit parvenir au client événement par événement. text/csv, application/pdf, image/x-icon et une réponse sans aucun Content-Type sont également exclus, parce qu'ils ne figurent pas dans la liste
  • Le serveur ne détient pas le corps en entier : scripts PHP utilisant oxphp_stream_flush(), Server-Sent Events, et fichiers statiques lus sur le disque au fil de leur envoi. OxPHP compresse une réponse d'un seul tenant. Brotli, Zstandard et gzip savent tous encoder de manière incrémentale : il s'agit donc d'une limite du serveur et non des encodages
  • La réponse provient d'un plugin qui a répondu avant l'exécution du pipeline, comme un 429 de limitation de débit, qui revient avant l'étape de compression
  • La réponse provient de l'écouteur interne (INTERNAL_ADDR), qui sert /metrics et les endpoints de santé sans encodage, quoi qu'accepte le collecteur

En-têtes de réponse

Une réponse que le serveur encode est réécrite comme suit, qu'elle ait été compressée pour cette requête ou servie depuis une copie stockée :

En-tête Valeur
Content-Encoding br, zstd ou gzip, selon l'encodage négocié
Content-Length Mis à jour avec la taille du corps compressé
Vary Accept-Encoding y est ajouté, afin que les caches HTTP stockent des versions distinctes par encodage
ETag Une étiquette forte est affaiblie en W/"…". Les octets encodés sont une représentation différente, et une étiquette forte partagée avec les octets identité permettrait à un client de reprendre un téléchargement encodé avec des fragments identité
Accept-Ranges Supprimé. Les positions en octets n'ont aucun sens sur un corps encodé

Tous les encodages d'un même fichier statique partagent cette étiquette faible, dérivée de la taille identité et de la date de modification. C'est Vary qui sépare les variantes ; l'étiquette ne décide que de la fraîcheur, si bien qu'un cache qui revalide une variante ne peut pas indiquer au serveur laquelle il détient.

Plages et compression

La gestion des plages est désactivée avant la construction de la réponse pour toute réponse qui serait compressée pour ce client. Un fichier statique compressible situé dans la fenêtre de compression reçoit un 200 complet lorsque le client envoie à la fois Range et Accept-Encoding, et une plage impossible à satisfaire venant d'un tel client reçoit ce 200 plutôt qu'un 416. Les clients qui n'acceptent aucun encodage conservent les plages, et c'est là que vivent les téléchargements reprenables.

Observabilité

Deux compteurs de l'endpoint de métriques indiquent ce que la compression a réellement fait :

  • oxphp_compressed_responses_total : réponses envoyées sous un encodage de contenu
  • oxphp_compression_bytes_saved_total : taille identité moins taille compressée, cumulée

Les deux comptent les accès aux copies stockées aussi bien que les compressions à la requête : un ratio par rapport à oxphp_requests_total répond donc à la question « la compression atteint-elle mon trafic ? » de façon plus fiable qu'un simple curl -I.

Dépannage

Les réponses ne sont pas compressées

Vérifiez que le client envoie bien un en-tête Accept-Encoding. Les navigateurs le font, mais certains outils de test HTTP n'en envoient aucun par défaut, et une requête sans cet en-tête reçoit une réponse non encodée.

Un poids nul refuse l'encodage : Accept-Encoding: br;q=0, zstd;q=0, gzip;q=0 désactive la compression pour cette requête, exactement comme l'absence d'en-tête.

Vérifiez avec curl :

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

Cherchez Content-Encoding dans les en-têtes de la réponse. S'il est absent, vérifiez que :

  1. COMPRESSION_ENCODINGS liste toujours l'encodage demandé, et que son niveau n'est pas 0
  2. Le corps de la réponse fait au moins 256 octets et au plus 3 Mio
  3. Le Content-Type de la réponse figure dans la liste des types compressibles ci-dessus. La correspondance ignore la casse et les paramètres
  4. Le handler ne diffuse pas en flux. Un script qui appelle oxphp_stream_flush(), envoie des Server-Sent Events ou commence à écrire avant que la longueur soit connue est transmis sans modification, et c'est la cause la plus fréquente dans une application qui vide ses tampons
Des clients différents reçoivent des encodages différents

La négociation est faite pour cela, et trois cas en expliquent presque la totalité. Les navigateurs retiennent br et zstd en HTTP simple : ils y envoient Accept-Encoding: gzip, deflate et reçoivent du gzip, aussi récents soient-ils. Les outils en ligne de commande n'envoient souvent aucun Accept-Encoding et reçoivent une réponse non encodée. Enfin, en HTTPS, un navigateur assez récent pour envoyer zstd reçoit du Zstandard sur une réponse dynamique là où un plus ancien reçoit du Brotli. Rien n'est mal configuré dans ces cas, et les réponses compressées portent Vary: Accept-Encoding pour que les caches les gardent séparées.

L'encodage change pour un même fichier statique

C'est attendu : les premiers accès à un fichier statique mis en cache sont compressés en Zstandard pour répondre à la requête, et une fois la copie Brotli stockée construite en arrière-plan, les accès suivants sont servis depuis elle. Voir Fichiers statiques en cache.

Une requête de plage renvoie le fichier entier

C'est attendu pour un fichier compressible situé dans la fenêtre de compression lorsque le client envoie aussi Accept-Encoding : la représentation qui lui est servie est encodée, et les positions en octets de la représentation identité ne la décrivent pas. La même requête sans Accept-Encoding obtient son 206. Voir En-têtes de réponse.

La compression rend les réponses plus volumineuses

Un corps qui ne se compresse pas (un blob base64, une charge utile d'identifiants aléatoires, une image déjà compressée enveloppée dans un image/svg+xml) peut ressortir de n'importe lequel des trois encodages sans être plus petit qu'à l'entrée. OxPHP compare les deux et envoie l'original dès que la compression n'a pas aidé : aucune configuration n'est donc nécessaire. Ce ne peut pas être un problème de petites réponses : 256 octets est le seuil en dessous duquel la compression n'est même pas tentée.

Le débit a baissé à l'activation de la compression

La compression peut augmenter ou réduire le débit. Le sens dépend de ce qui manquait au serveur avant son activation, et le même réglage sur le même serveur peut faire varier le débit de plusieurs fois dans un sens comme dans l'autre.

Face à une liaison saturée, c'est le gain le plus important disponible : une réponse textuelle huit fois plus petite est livrée huit fois plus vite, et le CPU qu'elle consomme était inactif, à attendre le réseau.

Face à un runtime asynchrone étroit, c'est un coût. Un corps sous le seuil de transfert est compressé sur un thread de travail asynchrone, et le runtime se dimensionne par défaut à la moitié des CPU disponibles pour le processus : ce travail est donc prélevé sur le pool qui porte toutes les connexions ouvertes, alors que le reste de la machine a peut-être de la capacité disponible. Il n'en faut pas beaucoup pour le remplir : deux threads du runtime qui compressent des corps JSON de 1,5 Ko sont saturés par ce seul travail. Le symptôme qui l'identifie : le débit baisse alors que la machine et le pool de workers PHP deviennent tous deux moins occupés, car ce n'est ni l'un ni l'autre qui s'est épuisé.

Ce qu'il faut vérifier : si le runtime est plus étroit que la machine. TOKIO_WORKERS vaut par défaut la moitié des cœurs disponibles, et l'augmenter permet à ce travail d'utiliser davantage la machine. Le gain dépend de la machine et de ce qui s'exécute d'autre : mesurez plutôt que de supposer, et mesurez le pool de workers en même temps. Ses threads sont distincts de ceux du runtime, si bien que sur un déploiement déjà limité par le CPU côté PHP, un runtime plus large prend des cycles à l'application au lieu d'en trouver de nouveaux.

Envoyer plutôt les petits corps au pool bloquant ne règle pas le problème. Sur un runtime à deux threads, un corps JSON de 1,5 Ko compressé en ligne occupe environ 7 µs d'un thread du runtime ; confié au pool bloquant puis récupéré, il en occupe 13 à 19 µs, car soumettre le travail, se mettre en attente et se réveiller sont eux-mêmes du travail pour le runtime. Le débit de ce runtime tombe alors entre un tiers et la moitié, et l'attente de tout le reste sur le runtime augmente au lieu de diminuer. Les seuils de taille sont fixés en tenant compte de ce coût.

Utilisation CPU élevée due à la compression

Le coût de la compression grimpe fortement au sommet de la plage de chaque encodage (Brotli 8 à 11, gzip 8 et 9, Zstandard 12 à 19), et les valeurs par défaut par requête se situent bien en dessous. Si vous observez une consommation CPU élevée liée à la compression :

Correctif : abaissez COMPRESSION_ZSTD_LEVEL à 3 et COMPRESSION_GZIP_LEVEL à 4. Brotli est le plus coûteux des trois encodages, mais abaisser COMPRESSION_BROTLI_LEVEL à 4 n'est pas le bon moyen d'économiser ce CPU : à 4, il produit plus d'octets que gzip à son propre niveau par défaut tout en coûtant toujours plus que gzip. Retirez plutôt br de COMPRESSION_ENCODINGS. Les clients assez récents pour envoyer zstd passent à Zstandard, pas à gzip, et le changement ne leur coûte donc rien ; seuls les clients qui acceptent Brotli sans Zstandard (Safari, navigateurs antérieurs à 2024, la plupart des intermédiaires) passent à gzip, qui sur des corps réels est 2 à 6 % plus volumineux pour environ la moitié du CPU.

Les fichiers statiques en cache n'entrent pas dans ce coût : leurs copies compressées sont construites une seule fois en arrière-plan, quels que soient les encodages restants. Les fichiers entre 1 Mio et 3 Mio, en revanche, y entrent. Ils sont compressés à chaque requête et ne peuvent pas être mis en cache ; voir Fichiers trop volumineux pour le cache.

Des ressources pré-compressées sont compressées à nouveau

OxPHP ne cherche pas de fichiers .br, .zst ou .gz à côté de l'original : une requête pour /app.js n'est jamais servie depuis /app.js.br, et demander directement le fichier compressé le sert comme un téléchargement opaque que les navigateurs ne décoderont pas. La compression des ressources statiques au moment de la construction n'a nulle part où se brancher ; les copies stockées du serveur remplissent ce rôle, à la même qualité maximale, sans étape de construction.

L'absence de recompression s'applique aux réponses qui portent déjà un en-tête Content-Encoding, ce qui correspond en pratique à un script PHP qui a encodé son propre corps et défini lui-même l'en-tête.

Exemple 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

Prompts pour votre agent

Ces prompts supposent un agent connecté au serveur MCP de la documentation OxPHP — il lira lui-même les pages dont il a besoin.

prompt
Détermine ce qu'OxPHP va réellement compresser pour mon application, et règle les encodages. Lis la page `features/compression` via le serveur MCP de la documentation OxPHP. Passe en revue les types de contenu et les tailles de réponse que ce dépôt renvoie, dis-moi lesquels sortent de la liste des types compressibles ou de la fenêtre de 256 o à 3 Mio, signale les ressources statiques entre 1 Mio et 3 Mio qui sont recompressées à chaque requête, et recommande `COMPRESSION_ENCODINGS` ainsi que les niveaux par encodage pour le mélange de trafic qu'il sert. Si la configuration définit encore le `COMPRESSION_LEVEL` déprécié, migre-le.

Voir aussi

Une erreur ? Signalez-la →