Aller au contenu
AFUP AFUP Day 2025 Baromètre Planète PHP PUFA
 

Streams : nous sous-estimons tous Predis !

Description

Redis, ce n'est pas que du cache. C'est évidemment beaucoup de cache. C'est d'ailleurs son utilisation principale (voire unique) dans beaucoup d'applications.

Mais connaissez-vous les streams ? Ces structures de données un peu particulières intégrées à Redis prouvent que ce dernier peut être utilisé pour bien plus que ça. Écriture de logs, aggrégation de données, notifications, buffer temporaire pour l'écriture d'un énorme fichier, les cas d'usages sont infinis. On peut facilement imaginer qu'en couplant les Streams aux générateurs de PHP, les performances d'un tel système peuvent dépasser toutes les attentes.

Depuis quelques années, la bibliothèque PHP Predis prend en charge les Redis Streams et permet leur utilisation avec beaucoup d'aisance. Il est temps de donner un second souffle à nos scripts PHP utilisant intensément Redis... ou qui devraient l'utiliser !

Conférence donnée lors du Forum PHP 2023, ayant eu lieu les 12 et 13 octobre 2023.

Informations complémentaires

Vidéo

Le speaker

Alexandre DAUBOIS

Alexandre est Lead Développeur Symfony à Wanadev, une entreprise basée à Lyon. Ils créent une application permettant la modélisation 3D de votre maison directement dans votre navigateur, avec la possibilité de faire des rendus photoréalistes. Parallèlement aux conférences que Alexandre a donné aux événements officiels de Symfony, Pimcore, PHPers et Malt Academy, c’est en contribuant au code source de Symfony ainsi qu'à sa documentation qu'il ai renforcé son implication dans le monde PHP. Il a alors continué dans cette dynamique en intégrant Sensiolabs (l'entreprise qui a créé Symfony), ainsi qu’en écrivant son premier livre, "Clean Code in PHP" en octobre 2022.

Transcript

[Musique] [Applaudissements] Bonjour à tous et merci beaucoup d'être là.

Une salle presque comble, ça fait plaisir de voir qu'un sujet comme Redis peut intéresser du monde.

Ou alors c'est peut-être mon pitch qui vous a convaincu ! Quelques mots sur moi : je suis Alexandre Daubois, lead developpeur Symfony chez Wanadev Digital à Lyon, je suis aussi contributeur au code source de PHP et de Symfony, et à la doc de Symfony et de PHP, un peu de traduction aussi...

Je fais partie de l'entreprise Wanadev Digital, notre produit phare c'est la conception et la modélisation de maison en 3D directement dans votre navigateur.

Si vous voulez faire une cuisine, salle de bain, etc.

vous pouvez directement le faire dans votre navigateur puis acheter des rendus photos réalistes du résultat.

Par exemple, ça pourrait donner ça : c'est fait avec notre outil Kozikaza que vous retrouvez sur internet.

On a eu quelques problématiques, notamment sur le stockage des modèles, des textures, etc.

Tout ce que vous voyez à l'écran (les escaliers, les tables, les chaises, le parquet...) est stocké pour que vous puissiez ensuite l'utiliser dans votre navigateur.

On a mis ça dans des énormes fichiers pour télécharger ce gros fichier une seule fois dans votre navigateur et ensuite si vous voulez faire de la modélisation pendant 5 heures, même si vous perdez internet, ça fonctionne.

On a utilisé une technique méconnue pour les stocker : des énormes fichiers JSON.

Ça marche pas trop mal, ça fait exactement ce qu'on veut donc on s'est dit qu'on allait mettre ça dans des JSON, ça ferait l'affaire...

Mais quand on veut mettre à jour des modèles (par exemple des chaises, des tables...) dans notre interface on a besoin de faire ce qu'on appelle un déploiement : dans le backoffice, on upload un modèle 3D, on change ses caractéristiques et on fait une demande de déploiement qui met à jour cet énorme fichier JSON et ça va être utilisable dans le frontend.

Donc une demande de déploiement se passe à peu près comme ça : quelqu'un déclenche ce déploiement, le JSON se génère en mémoire dans un unique process PHP, on upload tout ça vers le Cloud (chez nous c'est GCP), on envoie ça dans un bucket, le frontend récupère ça dans le bucket avec un CDN...

Bref ça marche bien et ça a fonctionné pendant des années.

Et un jour on a eu un petit problème que vous connaissez peut-être : ce truc.

Ouais ça parle à certaines personnes ici ! Qui n'a jamais vu ça ? Aucune main levée ! On va faire en sorte de pas avoir ce souci à nouveau, on s'est demandé comment faire pour mitiger ce problème, parce qu'on pouvait plus du tout déployer nos gros fichiers JSON, c'était terminé, c'était trop gros, on pouvait plus rien faire.

On a eu une idée de génie : écrire dans un fichier temporaire.

Pourquoi pas finalement ? On a pris le problème d'aujourd'hui, on l'a remis à demain et on était content : ça fonctionnait.

C'était une bonne solution qui a fonctionné quelques mois, et un jour notre client nous a dit : "Kazaplan fonctionne bien maintenant, on aimerait mettre tout ça dans le Cloud pour scaler plus facilement".

Donc le Cloud est arrivé, avec tout ce qu'on lui connaît : Docker, Kubernetes, etc.

toutes ces joyeusetés...

Quand on nous a parlé d'aller dans le Cloud alors qu'on écrivait dans un fichier temporaire, ça a été notre réaction... on s'est dit que c'était un peu problématique parce que le problème qu'on avait remis à demain, il est là aujourd'hui et il est urgent.

Donc il a fallu re-réfléchir à ce problème parce que le Cloud arrivait.

Il fallait trouver une solution, parce que ça arrivait bientôt, donc il fallait faire vite.

On s'est demandé où on pouvait écrire notre fichier en construction.

Et comme je vous ai dit notre fichier grossit de plus en plus, donc on pourrait profiter du Cloud pour avoir une infinité théoriquement de pods, pour paralléliser le traitement, générer ce fichier JSON et on envoie tout.

On pourrait faire "poper" 2000 pods dans le Cloud, et ça irait 2000 fois plus vite ! Sauf qu'avec un fichier temporaire, ça fonctionnait pas.

Donc on a réfléchi aux solutions pour stocker ce fichier JSON, avant de l'envoyer dans le Cloud.

Un système de fichiers, c'est beaucoup trop lent et pas adapté au Cloud, donc du coup ça ne règle absolument pas le problème.

On a aussi pensé à le mettre en mémoire, ce qui est beaucoup plus rapide, mais c'est limité par PHP et toujours pas adapté au Cloud, parce que si chaque instance de notre application a sa propre mémoire quelque part on pourra pas récupérer la mémoire de tous les pods en même temps bref c'est un peu compliqué...

La base de données, pourquoi pas ? C'est un endroit unique pour tous les pods, où tout le monde peut écrire en même temps, mais le problème c'est que c'est trop intense pour l'IO, et y'a les FinOps (les gens qui font de la technique mais qui disent que vous coûtez cher) qui ont dit non ! On se retrouve devant une problématique : trouver quelque chose qui est partagé entre tous les pods, qui se trouve en mémoire (ce serait vachement plus efficace) et qui a de la grande capacité potentiellement si nos fichiers JSON font 2,3,4 Go...

Et c'est là qu'est apparu un peu comme une évidence : Redis.

On l'utilisait parce qu'on voulait stocker nos sessions Symfony et c'était la seule chose pour laquelle on l'utilisait : on avait une instance Redis avec des gigas de mémoire juste pour stocker des sessions.

Pour les personnes qui connaissent pas Redis : c'est un truc qui va très vite, c'est tout en mémoire, c'est écrit en C, c'est une base de données "simplifiée" dans le sens où c'est vraiment juste de la clé et de la valeur globalement.

C'est hyper simple d'utilisation : on fait un "set mykey" à une valeur, ça va nous dire "ok", on peut récupérer la clé, ça retourne la valeur, et c'est tout.

Mais comment générer nos fichiers JSON alors que là on est avec des clés, des valeurs, ça a pas l'air super adapté...

C'est à ce moment-là qu'on a découvert les streams dans Redis : c'est une structure de données ajoutée récemment (il y a 2-3 ans), dit en "append-only" avec un identifiant par ordre chronologique d'ajout, c'est très rapide.

Pourquoi je dis que c'est rapide : dans la doc, il y a marqué qu'on peut faire 1 million d'insertions par seconde sur une machine qualifiée de moyenne, (allez savoir ce que ça veut dire) mais en tous cas c'est dans la doc officielle.

Donc Redis pourrait être notre point d'entrée unique pour tous les pods dans le Cloud, donc notre demande de déploiement qu'on a vue avant où on avait demande de déploiement, génération des fichiers en mémoire et upload dans le bucket, maintenant ça ressemblerait plutôt à ça : voilà la demande de déploiement et on aurait quelque chose qui pourrait dispatcher des milliers de messages dans un Pub/Sub ou un RabbitMQ etc.

qui va déclencher autant de worker qui vont chacun générer des petites parties du JSON envoyer tout ça dans Redis qui serait le point central, on aurait à la fin un worker qui prendrait notre stream, concaténerait tout ça et l'enverrait dans le Cloud.

On va se concentrer un peu sur cette partie, regarder un peu de code mais sans trop rentrer dans les détails, pour voir un peu à quoi ça ressemble.

On aurait un message handler : une fonction exécutée en asynchrone par des workers on va commencer à prendre notre petit bout de JSON, le message get component donc un composant par exemple une chaise, une table...

on va sérialiser ce petit bout en JSON puis l'ajouter dans le stream avec Redis XADD on utilise ici la bibliothèque Predis (pour PHP Redis) on va l'insérer dans notre stream avec un ID unique et faire une vérification de consolidation : vérifier s'il y a d'autres petits composants (chaises, tables...) à sérialiser, si oui, on ne fait rien et sinon, tout a été fait, donc on peut envoyer un message pour dire que le stream est fini de construire, on peut tout concaténer, et on devrait obtenir notre gros fichier à la fin.

Parce qu'on aura pris tous nos petits bouts de JSON, tout mis à la suite, et normalement on a notre fichier JSON.

On a fait ça, on était super contents, on a concaténé le stream, ça fonctionnait bien, on lance la commande et... ça marche pas ! On pourrait se dire : "des semaines de dev foutues en l'air", non ça allait...

Par contre, on a eu encore ce problème de memory size.

Parce qu'en fait il nous manquait une dernière étape.

Au moment où on a concaténé le stream, on a généré notre fichier JSON d'1Go dans le stream par petit bout...

sauf que d'un coup on a voulu prendre toutes les données du stream, les mettre dans un gros fichier et les envoyer dans le Cloud.

Donc on a juste déplacé le problème parce que notre process qui s'occupe de consolider le catalogue de produits, il explose en mémoire.

Donc la petite dernière étape serait de faire de l'écriture par chunks, on lirait un petit bout du stream de Redis, on l'écrit dans le Cloud, on vient chercher le prochain, on le ré-upload, etc.

C'est notamment possible grâce aux générateurs ou fonctions génératrices, (je sais pas si vous êtes familier avec ça, c'est avec le mot-clé "yield") et globalement ça se passe comme ça : quand vous avez une fonction génératrice, vous passez dans un "for each" la fonction génératrice va s'exécuter, chercher la prochaine instruction "yield" et la valeur va immédiatement être retournée plutôt que d'exécuter la suite de la fonction ça vous permet de générer des valeurs à la volée, sans avoir besoin de générer l'intégralité de votre jeu de données, parce que c'est retourné au fur et à mesure.

Une fois la valeur retournée, exécution du corps de la boucle, et à la fin de la boucle, on revient au "for each", la fonction génératrice s'exécute de nouveau jusqu'à la prochaine instruction "yield", jusqu'à ce que le générateur soit terminé.

Ça ressemble à quelque chose comme ça : une classe JSON Redis stream encoder comme vous pouvez le voir on a une fonction "encode", quand on va mettre cette fonction génératrice dans un "for each" on va commencer à rentrer dedans, on va mettre index zéro, et d'un coup on va avoir "yield" immédiatement, on va retourner le premier caractère de notre fichier JSON.

Donc ici il y a le "yield", on retourne directement dans la fonction "for" en bas, on uploade ce petit chunk (donc on upload juste un bracket), on retourne dans la fonction, on rentre dans la boucle, "yield", on va récupérer la valeur dans notre stream avec le stream ID qu'on a vu avant, on va "yield" à nouveau la prochaine donnée du stream en ajoutant une virgule si besoin vu qu'on est dans un fichier JSON, à nouveau on retourne dans l'appel du "for each" on ré-upload le chunk, on fait du ménage dans le stream, on revient et on continue d'uploader le chunk jusqu'à ce que le stream soit vide grâce à la boucle "while".

Voilà, ça vous le faites à la main, ça fonctionne assez bien mais pour ce besoin-là je me suis dit que j'allais coder une petite librairie qui pourrait être utile pour tout le monde parce qu'on n'est peut-être pas les seuls à avoir ce souci : c'est comme ça qu'est né Lazy Stream qui permet de faire ce que je viens de montrer.

Avec Lazy Stream vous pouvez avoir un data provider, une fonction génératrice, donc on reprend notre JSON Redis stream encoder d'avant avec "encode" (le fait d'appeler "encode" alors que c'est une fonction génératrice permet de ne pas l'exécuter mais de récupérer une référence vers cette fonction) on va déclarer un nouveau Lazy Stream writer avec l'url de là où on veut l'envoyer, ici en l'occurrence c'est Google Storage le bucket catalog.JSON, et on lui passe notre fonction génératrice en paramètres.

Pour l'instant il s'est rien passé : le stream n'a pas été ouvert, la connexion vers Google Storage n'a pas été ouverte non plus, jusqu'au moment où on va appeler la fonction trigger ligne 12, où on va ouvrir la connexion vers Google Storage et on va unwrap la fonction génératrice et à chaque fois qu'on va avoir un "yield" dans cette fonction, on va l'uploader vers le bucket et ainsi de suite.

Donc tout ça c'est un peu un wrapper de tout ce que je vous ai montré jusque là on lui donne une URL (ça supporte les streams PHP donc ça marche avec FTP, HTTP...) vous donnez une URL et un générateur et ça va s'occuper d'unwrap ce générateur pour tout envoyer dans le stream que vous lui passez.

Il y a la possibilité de faire du multiplexing : vous pouvez donc récupérer votre fonction génératrice avec "encode" ici, et on peut avoir un multi Lazy Stream writer qui permet d'écrire les données de cette fonction génératrice vers plusieurs destinations.

Imaginons qu'on veuille faire de la sauvegarde de base de données par exemple, sur Google Storage, sur S3 et puis sur un FTP, etc. pour être sûr que tout va bien, on peut utiliser le multil Lazy Stream writer qui va unwrap une seule et unique fois la fonction génératrice mais qui va l'écrire partout où vous avez besoin d'écrire.

Encore une fois, tant que vous n'avez pas appelé "trigger", il ne se passe rien, aucune connexion n'est lancée, rien n'est fait.

C'est seulement quand vous appelez "trigger" qu'il se passe quelque chose.

Donc ça c'est disponible sur Composer si vous en avez besoin mais je tiens quand même à rappeler que c'est grâce à Redis que ça a été possible, parce qu'on avait vraiment notre point d'entrée unique pour l'intégralité des pods qu'on avait dans le Cloud, un truc qui peut avoir des gigas de mémoire vive, qui est ultra-rapide, bref, ce qu'on a vu tout à l'heure : c'est partagé, c'est en mémoire, ça a une grande capacité, c'était vraiment la solution clé et ensuite ce petit bout avec Lazy Stream qui nous permet d'écrire bout par bout dans nos buckets et dans le Cloud, ça a permis de résoudre tous nos soucis et maintenant, on peut si on veut écrire 50 Go de JSON c'est peut-être pas la bonne solution mais c'est possible sans avoir de problèmes de mémoire.

Merci beaucoup et si vous avez un feedback, je le prendrai avec plaisir.

Nous avons le temps pour quelques questions.

Bonjour et merci pour la conf.

Une question un peu annexe : comment vous gérez en front le fait que vous télécharger plusieurs gigas ? En fait on a une durée d'activité des utilisateurs sur notre application...

certains utilisateurs passent 3, 4, 5, 6 heures d'affilée sur le modélisateur, donc avoir à la rigueur un téléchargement de 2-3 voire 5 minutes, ou un peu plus au lancement de l'application c'était pas dérangeant pour les utilisateurs, parce qu'ils laissent ça ouvert toute la journée sur le navigateur c'est une vraie stat d'ailleurs : pendant les weekends pluvieux, les gens font leur maison ! donc c'est pas dérangeant d'avoir ce temps de chargement qui peut être pénible au début mais par contre une fois que c'est là, ça fonctionne même en cas de coupure d'internet ou quoi que ce soit, ça fonctionne donc c'est ok pour les utilisateurs.

Pour l'instant ça nous va, après on est en train de réfléchir à passer tout ça en API avec du cache, des CDN, etc. mais ça c'était la solution historique et il fallait qu'on compose avec le plus vite possible parce qu'on avait des délais à tenir, donc c'est la solution qu'on a retenue, c'est peut-être pas la meilleure mais c'est celle qui fonctionne pour nous.

Je suis ravi de voir que ça a été clair.

[Musique]