Stratégies de performances en Laravel

Publié le 3 juillet 2024 par Mathieu De Gracia
Couverture de l'article Stratégies de performances en Laravel

Une boucle for est-elle plus performante qu'un foreach ?

Cette question, vous l'avez très certainement déjà entendue si vous développez en PHP ... et vous avez très probablement une réponse ferme à cette dernière.

Non, n'utilise pas des guillemets doubles, ce n'est pas performant !

1'000 itérations d'un switch case de quelques éléments seraient 368% plus lentes qu'une simple série de if ... le code passant de 12 µs à 44 µs ! Vous vous rendez compte, c'est 32 µs de différence !

Une boucle for pourrait, dans certaines circonstances, être 2 à 3 fois plus rapide que son homologue en foreach ... mais passer de 100 µs à 300 µs est-il vraiment un gage de qualité ?

Ces gains dérisoires doivent-ils dicter notre manière de coder ?

Dans les faits, traquer les bouts de code ne respectant pas les logiques les plus "performantes" pourrait devenir contre-productif, nuire à la lisibilité du code et vous éloignera probablement des véritables enjeux de performance de votre application.

Voyons dans cet article 3 enjeux de performance à adresser en priorité dans votre quête de performance !

La base de données

L'une des causes les plus courantes de lenteur d'une application n'a rien d'exotique et se trouve être tout simplement ... lancer une requête SQL.

Exécuter une requête oblige d'ouvrir une connexion vers une base de données, le plus souvent distante, d'attendre le traitement de la requête, de récupérer les résultats, de fermer la connexion ... le tout à travers internet avec toutes les problématiques de latences et de débit que cela peut engendrer.

Dans la majeure partie des cas, les micro optimisations du code pur pourraient vous faire gagner des microsecondes voire des millisecondes ... alors que des requêtes SQL mal optimisées peuvent, à elles seules, prendre plusieurs secondes à s'exécuter !

Voir des minutes dans des cas extremes !

Ces ordres de grandeur rendent les micro optimisations du code marginal et ce rapport de force disproportionné doit directement attirer votre attention, surveillez les performances de vos requêtes SQL avant même d'envisager une quelconque refactorisation du code.

Laravel offre bon nombre de fonctionnalités afin de détecter les requêtes les plus lentes d'une application, telle que la méthode whenQueryingForLongerThan qui vous permettra d'identifier les requêtes dépassant un seuil donné :

1DB::whenQueryingForLongerThan(500, function (Connection $connection, QueryExecuted $event) {
2 // Notify development team...
3});

Tout un attirail de services permettant d'analyser les requêtes SQL est également disponible mais ne remplacera jamais un véritable outil de surveillance tel que Laravel Telescope ou des agents dédiés à la surveillance de votre base de données.

Une fois vos requêtes les plus lentes identifiées, il sera nécessaire de les réécrire ou de chercher à les optimiser.

Dans certains cas, n'ayez crainte à parfois vous éloigner d'Eloquent et de retourner à des requêtes plus brutes que vous pourrez écrire à l'aide du query builder du framework. La qualité première d'un ORM est de faciliter l'interaction avec une base de données, la performance peut, parfois, être mise en retrait.

Portez également une attention particulière aux requêtes présentes dans les boucles, une requête relativement rapide de 50 ms, multipliée une centaine de fois, deviendra rapidement délétère pour les performances de votre application :

1$users = Excel::import('users.xlsx');
2 
3foreach ($users as $user) {
4 
5 $billing = Billing::query()
6 ->where('user_id', $user['id'])
7 ->first();
8 
9 //
10}

Privilégiez, quand cela est possible, les requêtes retournant un ensemble de données que vous pourrez manipuler par la suite :

1$users = Excel::import('users.xlsx');
2 
3$billings = Billing::query
4 ->whereIn('user_id', Arr::pluck($users, 'id'))
5 ->get();
6 
7$billings = $billings->keyBy('user_id');
8 
9foreach ($users as $user) {
10 
11 $billing = $billings->get($user->id);
12 
13 //
14}

Les problématiques de N+1 sont également monnaie courante dans les applications Laravel et un simple eager loading pourrait facilement améliorer les performances de votre application.

1// Deux requêtes SQL
2$users = User::with('roles')->get();
3 
4// Une infinité de requêtes SQL ...
5$users = User::all();
6$users->map(function (User $user) {
7 $user->roles;
8});

La méthode preventLazyLoading pourra même vous aider à identifier les situations de N+1 de votre application.

L'ajout d'un chunk bien placé pourrait facilement vous faire gagner de précieuses secondes en découpant une requête coûteuse en une multitude de requêtes de plus petite envergure.

Si malgré tout, vos contraintes de performances sont plus fortes et exigent des mesures particulières, vous pourrez alors envisager des patterns plus complexes tels que le CQRS qui vous permettra d'isoler les requêtes de lecture et d'écriture sur des noeuds dédiés ayant leurs propres ressources :

Dans la majorité des cas, vous pourriez probablement arrêter vos travaux d'amélioration de performance à ce chapitre, l'exécution des requêtes SQL sera bien souvent la première et principale cause des lenteurs de votre application

Paralléliser l'exécution

Historiquement, PHP n'est pas un langage nativement asynchrone, malgré l'arrivée de Fibers en 8.1 et de quelques fonctionnalités offertes ces dernières années par Octane ou des packages tels que Swoole.

Cette contrainte peut parfois générer des problématiques de performance assez sévères que nous pouvons cependant facilement mitiger à l'aide du framework.

Imaginons le code suivant permettant d'activer un compte utilisateur lors d'une prise de commande :

1class RegistrationController
2{
3 public function __invoke(User $user)
4 {
5 $user->accountActivation();
6 
7 Mail::to($user)->send(new WelcomeEmail());
8 Mail::to($user)->send(new PaymentConfirmationEmail());
9 
10 return redirect()->to('home');
11 }
12}

Ce controller a pour responsabilité d'activer le compte de l'utilisateur, de lui envoyer un mail de bienvenue et un récapitulatif de son paiement pour enfin pouvoir le rediriger vers la page d'accueil.

Bien que sommaire, ce controller pourrait facilement prendre plusieurs secondes à s'exécuter. À l'instar d'une base de données, envoyer un mail contraint à contacter un serveur SMTP ... qui pourrait prendre plusieurs secondes à réagir et à traiter la demande.

En d'autres termes, l'utilisateur est bloqué plusieurs secondes alors que l'envoi des mails n'est fondamentalement pas essentiel à l'affichage de la page d'accueil !

Isoler ces envois de mails dans une queue sera un palliatif efficace afin de gagner rapidement en performance :

1class RegistrationController
2{
3 public function __invoke(User $user)
4 {
5 $user->accountActivation();
6 
7 RegistrationEmails::dispatch($user);
8 
9 return redirect()->to('home');
10 }
11}

Désormais, apres l'activation de son compte, l'utilisateur sera immédiatement redirigé et un autre processus aura la responsabilité de traiter les envois de mails.

Le maître mot sera toujours de traiter séparément les actions non essentielles à la demande de votre utilisateur de manière asynchrone, ne retardez pas l'affichage d'une page pour effectuer une action à laquelle elle n'est pas directement dépendante !

D'autres problématiques de parallélisation pourront être adressées à l'aide de la commande process du framework. Nous vous présentions dans cet article un cas d'usage de parallélisation permettant de gagner en performance dans le traitement d'une commande artisan.

Mettre en cache

Nous avons optimisé nos requêtes SQL, nous avons parallélisé quand cela était possible, il est temps de s'intéresser à notre dernière stratégie d'optimisation des performances : l'utilisation des caches !

L'objectif d'un cache sera de conserver en mémoire une données fréquemment demandées ou bien couteuse à calculer, le cache étant en mesure de restituer immédiatement une donnée qu'on lui aura transmit au préalable.

Il existe bon nombre de pattern de cache mais nous allons en présentés ici que deux, la mémorisation et le cache aside.

Le plus simple de ces deux patterns, la mémorisation, consiste tout simplement à conserver le résultat de certaines opérations afin de limiter les recalculs inutiles et coûteux.

Par exemple, imaginons un service calculant la valeur d'un nombre dans la suite de Fibonacci :

1$fibonacciService = new FibonacciService();
2 
3echo $fibonacciService->calcul(0); // 0
4echo $fibonacciService->calcul(1); // 1
5echo $fibonacciService->calcul(2); // 1
6echo $fibonacciService->calcul(3); // 2

Le service en question fonctionnerait de la maniere suivante :

1class FibonacciService
2{
3 public function calcul(int $n): int
4 {
5 if ($n <= 1) {
6 return $n;
7 }
8 
9 $value = $this->calcul($n - 1) + $this->calcul($n - 2);
10 
11 return $value;
12 }
13}

Ce service, bien que fonctionnel, ne conserve pas en mémoire les réponses précédentes : nous perdrons potentiellement du temps à calculer deux fois le même nombre.

Il devient alors intéressant de mettre en place une mémorisation des résultats afin de pouvoir répondre immédiatement à un calcul que nous aurions déjà effectué au préalable :

1class FibonacciService
2{
3 private $cache = [];
4 
5 public function calcul(int $n): int
6 {
7 if (isset($this->cache[$n])) {
8 return $this->cache[$n];
9 }
10 
11 if ($n <= 1) {
12 return $n;
13 }
14 
15 $values = $this->calcul($n - 1) + $this->calcul($n - 2);
16 
17 $this->cache[$n] = $values;
18 
19 return $values;
20 }
21}

Ce pattern de mémorisation est relativement simple et peu coûteux à implémenter et pourrait grandement améliorer la performance de vos scripts sans pour autant perturber votre code actuel.

Le pattern cache aside est quant à lui plus élaboré que la mémorisation et sera utile pour conserver de l'information sur le long terme et potentiellement partageable entre plusieurs utilisateurs.

1class UserBillingsCache
2{
3 public function get(User $user): array
4 {
5 $billings = Cache::tags('user_billings')->get($user->id);
6 
7 if ($billings) {
8 return $billings;
9 }
10 
11 return $this->refresh($user);
12 }
13 
14 public function refresh(User $user): void
15 {
16 $billings = $this->doingSomeHeavyCalculation($user);
17 
18 Cache::tags('user_billings')->put($user->id, $billings);
19 
20 return $billings;
21 }
22}

Attention, ces stratégies de cache, bien que permettant des gains concrets de performance amènent avec elles de nouvelles contraintes.

À un instant T, il sera difficile d'avoir un aperçu de toutes les données en cache de vos applications, ces caches deviennent peu à peu des boîtes noires nuisant à l'exploitation de votre application.

L'invalidation peut également devenir un problème avec des caches contenant des informations désuètes ou erronées qui pourraient se propager d'une session à l'autre.

La mémorisation et les cache aside sont donc des stratégies intéressantes pour gagner en performance mais dont la balance avantages / inconvénients devra être mûrement réfléchie.

Utilisez judicieusement les caches, ne les systématisez pas !

Conclusion

Nous venons de voir dans cet article plusieurs stratégies permettant de gagner en performance dans vos applications Laravel.

Une question, qui pourrait vous sembler contre-intuitive, reste toutefois en suspens ... avez-vous réellement besoin de cette performance ?

Votre application n'est probablement pas Amazon et vous n'avez probablement pas besoin d'une application extrêmement performante ... ne faites pas de concessions sur la lisibilité et la maintenabilité de votre code sur la base de suppositions !

La performance, comme tout critère de qualité, doit pouvoir se justifier : elle n'est pas une finalité en soi.

Si la performance est un critère déterminant, il sera nécessaire de l'appréhender sous le spectre analytique en utilisant des outils de profilage tels que xdebug ou Blackfire pour identifier vos composants les plus lents et la cause de cette lenteur. Ne partez pas dans des refactorisations de performance basées sur des a priori !

Il devient primordial de connaître votre code car l'optimisation de performance est un acte chirurgical, elle ne s'effectue pas au lance-pierre au risque de passer à côté de vos véritables problèmes.

Mathieu De Gracia avatar
Mathieu De Gracia
Des fois, mon chat code à ma place 🐱

A lire

Autres articles de la même catégorie