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