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
JoliCode accompagne lâĂ©quipe technique Dayuse dans lâoptimisation des performances de sa plateforme. Nous sommes intervenus sur diffĂ©rents sujets : La fonctionnalitĂ© de recherche dâhĂŽtels, en remplaçant MongoDB et Algolia par Redis et Elasticsearch. La mise en place dâun workflow de rĂ©servation, la migration dâun site en Twig vers une SPA Ă base deâŠ
Au fil de notre collaboration avec Deezer, nous avons Ă©tĂ© impliquĂ©s dans plusieurs initiatives visant Ă optimiser les performances de leur plateforme. Notre engagement initial sâest concentrĂ© sur le soutien et le conseil Ă lâĂ©quipe « Product Features » lors de leur projet de migration en cours. Nous avons apportĂ© notre expertise pour rĂ©soudreâŠ
Axiatel Ă fait appel Ă JoliCode afin dâĂ©valuer les dĂ©veloppements effectuĂ©s par la sociĂ©tĂ©. A lâissue dâun audit court, nous avons prĂ©sentĂ© un bilan de la situation actuelle, une architecture applicative cible, et nos recommandations en vue dâune migration en douceur vers le modĂšle applicatif prĂ©sentĂ©.