Accéder au contenu principal

5min.

Redis et la mémoire de PHP sont dans un bateau, il coule

Section intitulĂ©e la-situation-initialeLa situation initiale 👍

Dans un de nos projets, nous avons un endpoint d’API qui retourne de gros JSON Ă  ses clients. Les clients sont des serveurs, donc la bande passante n’est pas un problĂšme. Il y a quelques annĂ©es, ces JSON ne dĂ©passaient que rarement les 50 Mo. Et au fur et Ă  mesure de la vie de l’application, ce JSON n’a fait que grossir (un peu comme nous chez JoliCode, merci le bot Slack qui apporte les croissants đŸ„ 😀).

Ce JSON Ă©tait calculĂ© de maniĂšre asynchrone, puis mis en cache dans Redis. Il Ă©tait ensuite servi par une application Symfony. Nous n’avions jamais eu de problĂšme jusqu’à maintenant. Inutile donc d’over-engineer le code et d’ajouter de la complexitĂ© et des optimisations prĂ©maturĂ©es inutilement.

Section intitulĂ©e la-deriveLa dĂ©rive 😓

Vous vous en doutez, un problĂšme est arrivé ! La structure du JSON a rĂ©cemment changĂ©. Et il est un peu plus lourd maintenant
 Nous avons reçu quelques alertes de notre monitoring. Cet endpoint d’API dĂ©passe le memory_limit et finit en erreur 500 ! đŸ’„

Section intitulĂ©e le-quot-bon-quot-fixLe « bon Â» fix đŸ€“

Il faut maintenant revoir notre copie. Stocker des objets de plusieurs centaines de mĂ©gaoctets dans Redis n’est pas une bonne idĂ©e. À la place, nous devons stocker ces fichiers sur un stockage externe, plus pĂ©renne, avec une capacitĂ© bien plus grande. Nous pouvons penser Ă  un stockage fichier de type « Object Storage Â» disponible chez bon nombre de prestataires de service Cloud.

Par exemple, nos workers pourraient envoyer le JSON sur un bucket S3. PHP n’aurait plus besoin de le tĂ©lĂ©charger. Il pourrait Ă  la place envoyer l’URL du fichier sur l’Object Storage, protĂ©gĂ© par un token, directement au client.

Cependant, nous avions envie de jouer un peu avec les limites du systĂšme, et d’explorer ce qu’il Ă©tait possible de faire. Nous vous proposons une plongĂ©e dans la recherche d’un fix rapide
 mais temporaire.

Le fameux fix QUICK AND DIRTY.

Section intitulĂ©e le-fix-fun-mais-temporaireLe fix fun, mais temporaire đŸ©č

ATTENTION : Ce qui est dĂ©crit dans cette partie est une solution de contournement. Il n’est pas recommandĂ© de l’utiliser en production. L’article a pour but de montrer comment debugger un problĂšme, et trouver des solutions simples mais temporaires afin de rĂ©soudre un problĂšme de production dans l’urgence, le temps d’appliquer la bonne correction.

Revenons un instant Ă  notre problĂšme. En production, nous avons des erreurs 500 car PHP n’a plus assez de RAM. Le endpoint d’API dĂ©passe le memory_limit fixĂ© Ă  512 Mo.

Section intitulée recuperation-du-json-depuis-redisRécupération du JSON depuis Redis

Dans les logs, nous pouvons voir que l’exception arrive lors de la rĂ©cupĂ©ration de la valeur depuis Redis. Nous tĂ©lĂ©chargeons le JSON directement depuis Redis pour l’inspecter, et lĂ , surprise ! Le JSON fait moins de 300 Mo. Pourquoi l’application crash alors que le memory_limit est Ă  512 Mo ? Faisons un petit reproducer, pour bien comprendre :

// Nous utilisons l'extension phpredis : https://github.com/phpredis/phpredis
$redis = new Redis();
$redis->connect('redis', 6379);

$value = $redis->get('my-key');
dd([
    'strlen' => sprintf("%.2fMb\n", strlen($value) / 1024 / 1024),
    'memory_get_usage()' => sprintf("%.2fMb\n", memory_get_usage(true) / 1024 / 1024),
    'memory_get_peak_usage()' => sprintf("%.2fMb\n", memory_get_peak_usage(true) / 1024 / 1024),
]);

👇

^ array:3 [
  "strlen" => "278.98Mb\n"
  "memory_get_usage()" => "304.98Mb\n"
  "memory_get_peak_usage()" => "583.95Mb\n"
]

Surprenant ! L’extension phpredis doit sĂ»rement faire une copie de la valeur dans la mĂ©moire 😱 et nous nous retrouvons probablement avec deux fois la valeur en mĂ©moire.

Nous avons essayé de changer le mode de serialization du client, mais il était déjà en mode NONE.

$redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE);

Comme nous n’avons pas vraiment de moyens d’action, Ă  part faire une issue ou debbuger la librairie, nous choisissons un moyen de contournement :

ini_set('memory_limit', '2G');
try {
    $cacheItem = $this->cache->getItem($cacheKey);
} finally {
    ini_restore('memory_limit');
}

Tips : Nous pourrions utiliser la fonction ini_parse_quantity() pour rĂ©cupĂ©rer la valeur actuelle du memory_limit. Mais elle n’est disponible qu’à partir de PHP 8.2, qui n’est pas encore sorti. De mĂȘme, le polyfill n’est pas encore disponible.

Section intitulĂ©e nouveau-problemeNouveau problĂšme đŸ˜±

Yeah, ça fonctionne ! Enfin non
 Nouveau memory limit, mais cette fois dans la Response::sendContent() de Symfony :

public function sendContent(): static
{
    echo $this->content;

    return $this;
}

Nous essayons de passer avec des streams, mais cela ne fonctionne toujours pas :

public function sendContent(): static
 {
-    echo $this->content;
+    $h = fopen('php://output', 'wb');
+    fwrite($h, $this->content);

     return $this;
 }

Finalement, nous testons avec une StreamedResponse et ça marche (mĂȘme trĂšs bien). Quelle est la diffĂ©rence entre une Response et une StreamedResponse ? Une rĂ©ponse classique va envoyer tout le contenu du body via un echo au serveur web en une fois. Avec une StreamedResponse, nous pouvons envoyer des chunks (ou fragments) de rĂ©ponses les uns Ă  la suite des autres. C’est trĂšs utile pour exporter un CSV, ou gĂ©nĂ©rer un gros fichier. Attention cependant Ă  ne pas utiliser trop de temps CPU, pour ne pas surcharger le serveur.

new StreamedResponse(function () use ($responseBody) {
    $l = strlen($responseBody);
    $i = 0;
    while ($i < $l) {
        echo substr($responseBody, $i, 8096);
        $i += 8096;
    }
});

Ici, nous envoyons des petits bouts de 8 Ko sur le stream HTTP, au lieu d’envoyer toute la rĂ©ponse d’un coup.

Et voilà ! Avec ces deux techniques, notre endpoint d’API n’est plus en 500. Cependant, nous avons seulement reportĂ© le problĂšme Ă  plus tard. Il est pourtant possible d’optimiser un peu ce que nous venons de voir.

Section intitulĂ©e optimisationOptimisation đŸ’Ș

Au lieu de rĂ©cupĂ©rer le JSON d’un coup depuis Redis, nous pourrions utiliser la commande GETRANGE de Redis pour rĂ©cupĂ©rer le JSON de maniĂšre incrĂ©mentale, et l’envoyer dans la foulĂ©e. Malheureusement, nous utilisons symfony/cache qui altĂšre la valeur avant de la mettre en cache.

En combinant des petits chunks entre Redis, et une StreamedResponse, nous pourrions en thĂ©orie servir des fichiers de plusieurs Go sans problĂšme. Cependant, comme nous l’avons prĂ©cisĂ© en dĂ©but d’article, mettre autant de donnĂ©es dans Redis n’est vraiment pas une bonne idĂ©e, il est plus efficace de privilĂ©gier une solution de stockage sur disque.

Commentaires et discussions

Ces clients ont profité de notre expertise