Vous souhaitez nous soutenir ? Devenez sponsor de l'association sur notre page Github
Tutoriels

Comment du cache sur Eloquent a sauvé notre application

Mathieu De Gracia avatar
Publié le 4 avril 2025
Couverture de l'article Comment du cache sur Eloquent a sauvé notre application

Avec les années, votre application Laravel a probablement accumulé des milliers de requêtes SQL. Jusqu’ici, votre base de données répondait dans des délais raisonnables rendant même l’optimisation de ces requêtes secondaire. Mais un jour, votre moteur de base de données a commencé à montrer des signes de faiblesse ... et là, il était déjà trop tard.

Des requêtes dispersées partout dans l'application dont l'optimisation serait à la fois trop longue, fastidieuse et probablement génératrice de bugs. Notre approche d'Eloquent en est probablement la cause principale : dans bien des applications, nous l'utilisons de manière abusive sans le restreindre à des composants spécifiques.

Eloquent se retrouve donc partout entraînant une dépendance très forte de nos applications à la base de données.

Cet article partage un retour d'expérience sur une application réelle qui a soudainement rencontré de graves problématiques de performance et comment nous avons mis en cache nos relations avec Eloquent pour mitiger cette anomalie.

L'origine de l'anomalie

Notre application gère des posts, chacun étant lié à de nombreuses traductions pour prendre en charge différentes localisations et règles métier.

Le code suivant, ainsi que ses nombreuses variantes, est très courant dans l'application :

1$post = Post::with('translations')->where('user_id', $id)->get();

Cette relation avec les traductions est omniprésente dans l'application et, avec le temps, s'est progressivement intégrée à de nombreuses briques fonctionnelles.

Au fur et à mesure de l'ajout de nouvelles traductions et de l'afflux croissant d'utilisateurs il s'est avéré que ces requêtes sollicitaient énormément notre base de données.

Il n'était pas rare de voir plusieurs dizaines de milliers de requêtes vers la table translations sur des intervalles de 30 minutes monopolisant toutes les ressources de notre serveur !

Dans l'immédiat, pour améliorer les performances, nous pourrions envisager d'encapsuler ces requêtes dans un cache :

1// Impossible de part la nature de la données et des occurrences ❌
2return Cache::remember('query_posts_' . $id, 60, function () use ($id) {
3 return Post::with('translations')->where('user_id', $id)->get();
4});

Cependant, cette solution n'est pas envisageable pour deux raisons :

Après 10 ans d'évolution, ces requêtes sont disséminées partout dans de l'application avec de nombreuses variations, dans une situation de crise, il est impossible d'envisager de modifier toutes ces occurrences rapidement ... et encore moins sans provoquer des régressions.

Le contenu des posts changent régulièrement et ne sont pas les mêmes pour tous les utilisateurs, ce qui rend leur mise en cache hasardeuse. En revanche, les traductions, elles, pourraient être mises en cache car commun à tous les utilisateurs.

Notre objectif serait de mettre en cache la relation translations, sans modifier le code existant et sans impacter les requêtes vers la table posts ... et c'est là que les choses se compliquent : Laravel ne propose aucun mécanisme natif pour répondre à ce besoin.

Comment fonctionne l'eager loading

Avant de mettre en place un système de cache, essayons de mieux comprendre le fonctionnement de l’eager loading de Laravel.

Cette fonctionnalité est un indispensable et absolument tous les articles traitant de performance avec Eloquent en parlent, mais savez vous dans le fond comment cette méthode with fonctionne ?

Reprenons notre requête initiale :

1Post::with('translations')->where('user_id', $id)->get();

Vous pourriez penser que cette simple ligne exécute qu’une seule et unique requête SQL, peut-être via une jointure, pour récupérer toutes les informations demandées ?

Pour en avoir le cœur net, exécutons la requête et analysons les logs à l’aide de la fonction enableQueryLog de Laravel, nous obtenons les résultats suivants :

1SELECT * FROM posts WHERE user_id = 1;
2SELECT * FROM translations WHERE post_id IN (1);

Comme nous pouvons le voir dans les logs, l'utilisation du with sur un model Eloquent provoque deux requêtes SQL : une premiere pour récupérer le post et une seconde pour récupérer les translations associés aux posts.

Nous pouvons le schématiser de la maniere suivante :

Après avoir récupéré les posts indépendamment des translations, Laravel se retrouve avec deux collections qu'il associe directement en PHP pour créer une collection Eloquent.

Dans notre cas, nous souhaitons mettre en cache cette seconde requête, provenant du chargement des relations avec le with, fortement sollicitées en base, afin d’éviter de les exécuter à chaque récupération d’un post.

Maintenant que vous en savez un peu plus sur le fonctionnement du eager loading, voyons comment nous pourrions y ajouter un cache sans toucher aux requêtes existantes.

La mise en cache des relations

Essayons désormais de mettre en cache les résultats d'un with, nous avons vu précédemment que cela n'était pas directement possible avec Laravel, il est donc nécessaire d'étendre, au moment opportun, le fonctionnement du framework.

Pour gérer l'exécution des requêtes avec Eloquent, Laravel instancie en arrière plan une classe Eloquent\Builder implémentant le contrat suivant :

Cette instance de Builder a pour responsabilité d'orchestrer l'exécution des requêtes, le chargement des relations, des scopes et de toutes les spécificités de Laravel.

Cependant, cette dernière ne contient pas la requête SQL en tant que telle, cette responsabilité revient à la classe Query\Builder implémentant un contrat du même nom, et représentant la requête SQL en elle même.

Ces classes s'articulent de la manière suivante :

Dans notre situation, ce qui nous intéresse ici est l’orchestration de la requête SQL, car nous voulons pouvoir interrompre son exécution pour récupérer un résultat en cache, cette responsabilité est détenue par Eloquent\Builder. Heureusement pour nous, Laravel nous permet de surcharger ce builder avec notre propre implémentation.

Commençons par créer notre nouveau builder en ajoutant une classe implémentant le contrat Illuminate\Contracts\Database\Eloquent\Builder :

1namespace App\Custom\Builders;
2 
3use Illuminate\Contracts\Database\Eloquent\Builder;
4 
5class CustomTranslationBuilder extends Builder
6{
7 //
8}

Dans cette nouvelle classe, nous allons surcharger la méthode get de la classe Builder en y récupérant son code.

Cette méthode était la plus appropriée à surcharger car elle intervient au moment même où Laravel s'apprête à exécuter la requête SQL.

1namespace App\Custom\Builders;
2 
3use Illuminate\Contracts\Database\Eloquent\Builder;
4 
5class CustomTranslationBuilder extends Builder
6{
7 public function get($columns = ['*'])
8 {
9 $builder = $this->applyScopes();
10 
11 if (count($models = $builder->getModels($columns)) > 0) {
12 $models = $builder->eagerLoadRelations($models);
13 }
14 
15 return $builder->getModel()->newCollection($models);
16 }
17}

Dans cette méthode, notre systeme de cache aura besoin de générer une empreinte unique de la requête SQL afin de l'identifier.

Pour se faire, nous utiliserons la fonction toRawSql de Laravel pour récupérer la requête SQL brute et la convertir en une chaîne de caractères à l'aide d'un simple MD5.

1namespace App\Custom\Builders;
2 
3use Illuminate\Contracts\Database\Eloquent\Builder;
4 
5class CustomTranslationBuilder extends Builder
6{
7 public function get($columns = ['*'])
8 {
9 $builder = $this->applyScopes();
10 
11 $cacheKey = md5($builder->toRawSql());
12 
13 if (count($models = $builder->getModels($columns)) > 0) {
14 $models = $builder->eagerLoadRelations($models);
15 }
16 
17 return $builder->getModel()->newCollection($models);
18 }
19}

MD5 est généralement considéré comme une fonction de hachage obsolète et peu sécurisée (et à juste titre), mais cette fonction était légèrement plus rapide que SHA-1 et le risque de collision relativement faible et négligeable. Il nous a semblé pertinent de l'utiliser en raison du besoin en performance élevé de notre méthode get.

Un choix qui pourrait être remis en cache si des collisions apparaissaient

Un second point important, il était essentiel de générer l'empreinte après l'appel à la méthode applyScopes, sans cela, celle-ci aurait été incomplète car les différents scopes n'auraient pas encore été appliqués à la requête.

1## Avant applyScopes ❌
2SELECT * FROM posts WHERE id = 1;
3
4## Apres applyScopes ✅
5SELECT * FROM posts WHERE id = 1 AND deleted_at IS NULL;

Notre empreinte est générée, nous n'avons plus qu'à mettre en cache le résultat de la requête SQL, nous utiliserons la fonction remember de Laravel avec un TTL de 5 minutes :

1namespace App\Custom\Builders;
2 
3use Illuminate\Contracts\Database\Eloquent\Builder;
4 
5class CustomTranslationBuilder extends Builder
6{
7 public function get($columns = ['*'])
8 {
9 $builder = $this->applyScopes();
10 
11 $cacheKey = md5($builder->toRawSql());
12 
13 return Cache::remember('query_' . $cacheKey, 60 * 5, function () use ($builder, $columns) {
14 
15 if (count($models = $builder->getModels($columns)) > 0) {
16 $models = $builder->eagerLoadRelations($models);
17 }
18 
19 return $builder->getModel()->newCollection($models);
20 });
21 }
22}

Pour finir, il sera nécessaire de préciser au model Translation d'utiliser notre builder custom pour gérer ses requêtes :

1class Translation extends Model
2{
3 protected $guarded = [];
4 
5 // [...]
6 
7 public function newEloquentBuilder($query)
8 {
9 return new CustomTranslationBuilder($query);
10 }
11}

C'est tout ! Notre extension du Builder est désormais fonctionnelle et en mesure de mettre en cache les résultats de toutes les requêtes SQL vers la table translations qu'il exécutera !

Conclusion

Cette solution nous a permis de résoudre rapidement nos problématiques de performance et de mitiger une gêne que pouvaient subir nos utilisateurs en production.

Pour autant, étendre le cœur de Laravel comporte des risques et nécessite une bonne compréhension du framework.

Modifier le comportement du framework peut entraîner des régressions ou des bugs exotiques tout en compliquant les mises à jour à venir. Il est donc essentiel de faire preuve de prudence et de bien documenter vos modifications si vous êtes amené à le faire.

En d'autres mots, ne faites pas ça chez vous avant d'envisager d'autres solutions moins dangereuses et radicales pour votre application.

A lire

Autres articles de la même catégorie