jobsphp

Problème N+1 : pourquoi vos requêtes SQL ralentissent PHP

Ingénierie Web. Problème N+1 : pourquoi vos requêtes SQL ralentissent PHP

Vous pensiez que Laravel 12 avait définitivement réglé son compte au N+1 grâce à son auto-eager loading présenté comme la killer feature de la release? Rattrapage sur le bitume.

La fonctionnalité reste explicitement étiquetée bêta dans la doc, et le texte prévient très clairement que son comportement peut évoluer — y compris dans des versions correctives. On n'a donc pas encore trouvé la balle d'argent. Côté Doctrine, le discours est tout aussi mesuré: un fetch join combiné à setMaxResults() peut renvoyer moins d'entités racines que la limite demandée, parce que la limite SQL porte sur les lignes, pas sur les entités hydratées. Le N+1 n'est pas un bug à patcher bêtement, c'est un anti-pattern d'accès aux données qu'on doit comprendre avant de pouvoir le corriger.

Le N+1 en une image: 1 + N, c'est l'arithmétique qui tue

Le N+1 tire son nom d'un calcul qui fait mal. Une requête initiale ramène une liste de N entités — disons 50 livres. Ensuite, dans la boucle d'affichage, on accède à une propriété relationnelle (l'auteur de chaque livre) et chaque accès déclenche une nouvelle requête SQL. Au total: 1 + 50 = 51 requêtes pour afficher ce qui aurait pu tenir en deux.

Cette mécanique a un nom: le chargement paresseux, ou lazy loading. Dans Eloquent, dès qu'on accède à une relation comme si c'était une propriété dynamique, l'ORM déclenche la requête SQL correspondante au premier accès. Pratique en soi (on ne charge que ce qu'on utilise), désastreux en boucle.

Le N+1, c'est une requête pour la liste, puis une requête par élément — chaque fois qu'on touche à une relation dans une boucle. C'est l'arithmétique qui tue l'app, pas la requête elle-même.

Soyons honnêtes: on en a tous écrit. Sur une page admin où on liste 10 commandes avec leur client, ça passe crème. Sur une marketplace qui affiche 200 produits avec leur vendeur, leurs tags et leurs trois premiers avis, on passe de 600 requêtes à une seule page. Et c'est là que le serveur tousse, que la latence s'envole, que le pool de connexions sature, que tout le monde se regarde en mode "ça marchait pourtant hier".

Mesurer avant de refacto: on compte d'abord (la règle d'or)

Première chose, et non négociable: on ne devine pas, on compte. L'optimisation sans compteur, c'est de l'astrologie. Le N+1 se manifeste d'abord par la multiplication des allers-retours vers la base, pas par la lenteur d'une requête isolée. Une requête SQL peut être rapide; 200 requêtes identiques ne le seront jamais collectivement.

Le compteur côté Symfony et Laravel

Côté Symfony, dans un test fonctionnel, le profiler permet de récupérer le nombre d'appels à la base via le collecteur DB (la doc évoque explicitement getCollector('db')->getQueryCount()), et d'écrire une assertion de plafond sur le nombre maximal de requêtes pour une URL donnée. La documentation donne des exemples avec un plafond inférieur à 10 requêtes et une durée inférieure à 500 ms — mais soyons clairs, ce sont des exemples illustratifs, pas des normes universelles. Aucun seuil magique n'a été établi par les sources consultées: tout dépend du volume de données, des index, de la latence réseau, du cache, du pool de connexions et de la charge concurrente.

Côté Laravel, on a DB::enableQueryLog() couplé à DB::getQueryLog() qui liste les requêtes SQL exécutées pendant un cycle. Pratique pour investiguer à chaud, mais sans agrégation native — il faut compter soi-même, ou pousser le log dans Telescope.

Ce que dit le serveur de base lui-même

Quand l'application est en production, le compteur applicatif ne suffit plus. MySQL met à disposition le Performance Schema, qui regroupe les requêtes SQL similaires sous une forme normalisée appelée digest. Ce digest conserve notamment des informations permettant d'observer les types de requêtes exécutées et leur fréquence — la signature exacte d'un N+1 qui boucle en silence sur la même forme de requête avec des paramètres qui changent. Côté PostgreSQL, EXPLAIN ANALYZE fournit des temps d'exécution réels pour chaque étape du plan. Mais un détail qu'on oublie trop souvent: le temps affiché par EXPLAIN ANALYZE n'inclut ni la phase d'analyse syntaxique, ni la réécriture, ni la planification. Ce qu'on lit, c'est uniquement le coût d'exécution; le coût complet suppose du logging applicatif en complément.

OutilCe qu'il mesure réellementLimite à garder en tête
Symfony Profiler (DB collector)Nombre de requêtes par requête HTTPSurtout visible en environnement de test
Laravel query log / TelescopeListe brute des requêtes SQLPas d'agrégation native, lecture manuelle
MySQL Performance SchemaDigests normalisés des requêtes répétéesDoit être activé, overhead léger en runtime
PostgreSQL EXPLAIN ANALYZECoût d'exécution du planIgnore le temps de parse, de rewrite et de planification

Eager loading: le bon outil, mais pas un talisman

Le préchargement (eager loading), c'est l'arme classique: on dit à l'ORM « ramène-moi les relations en même temps que l'entité racine », et il balance tout en deux requêtes en s'appuyant sur une clause IN sur les identifiants liés.

L'exemple Eloquent canonique: Book::with('author')->get() fait deux requêtes — une pour les livres, une pour les auteurs dont les identifiants figurent dans un IN. 51 requêtes qui deviennent 2. Le saint Graal, apparemment. Sauf que le préchargement ne supprime pas tous les N+1. La doc Laravel documente un cas vicieux: si on a préchargé les commentaires depuis les articles, l'accès inverse à comment->post dans une boucle peut encore provoquer des requêtes supplémentaires. Pourquoi? Parce que le préchargement charge les enfants vers le parent, pas l'inverse. Tant qu'on n'a pas explicitement demandé la relation post sur comment, l'ORM reste aveugle et retape dans la base.

C'est précisément pour ça que Laravel a ajouté chaperone() sur les relations hasMany. Quand on l'active, Eloquent hydrate automatiquement le parent sur les enfants à partir de la collection déjà chargée. On reste sur deux requêtes, plus aucun round-trip supplémentaire dans la boucle inverse.

Le préchargement n'est pas un talisman: si on ne précise pas la direction inverse, l'accès inverse vous refait un N+1 en silence, et la dette revient par la fenêtre.

Côté Doctrine, on ne fait pas d'eager loading à la Laravel. Le mécanisme équivalent, c'est le fetch join: un JOIN dans le DQL devient un fetch join dès lors que des champs de l'entité jointe apparaissent dans la clause SELECT hors agrégat. Le fetch join inclut les entités liées directement dans le résultat hydraté, et c'est le bon outil quand on a un volume contrôlé et une cardinalité connue.

Interdire le chargement paresseux: le game changer pédagogique

Voilà la mesure qui change vraiment la vie d'une équipe: Model::preventLazyLoading(...). Activez-la, et toute relation chargée paresseusement lève une Illuminate\Database\LazyLoadingViolationException. Exit le N+1 silencieux. Place aux exceptions explicites qu'on attrape, qu'on diagnostique, qu'on corrige.

Petit twist: la doc Laravel illustre une activation en environnement hors production. Ce n'est pas anodin. Le legacy qui n'a pas encore été refacto va vous péter à la figure dès la première page admin qui boucle sur des entités avec relations. Mais c'est précisément l'intérêt pédagogique: on transforme des N+1 invisibles en bugs localisés, et on les traite un par un plutôt que de les laisser pourrir lentement dans un coin du monolithe jusqu'au jour où le monitoring s'affole.

Et pour les optimistes qui pensaient qu'on avait trouvé la solution royale avec l'auto-eager loading de Laravel 12: patience. La fonctionnalité est explicitement étiquetée bêta, et le texte est très clair — son comportement peut évoluer, y compris dans des versions correctives. On l'essaie, on mesure, on ne lui fait pas une confiance aveugle. C'est exactement pour ce genre de feature qu'on a inventé le staging.

Quand Doctrine nous recadre: les limites du fetch join

Doctrine n'est pas en reste, et ses avertissements sont à prendre au sérieux, parce qu'on tombe régulièrement dans le piège inverse du N+1: trop de jointures mal maîtrisées.

Premier piège: la pagination avec fetch join sur une collection. Si on pagine des articles avec leurs commentaires et qu'on applique setMaxResults(100), on pense naïvement qu'on aura 100 articles en sortie. Erreur: la limite SQL porte sur les lignes de la base, et une même entité racine peut occuper plusieurs lignes dans un fetch join de collection. Résultat: moins d'entités racines hydratées que la limite demandée, et un comportement de pagination décalé qu'on ne comprend pas tout de suite. C'est le genre de bug silencieux qui ne sort qu'en prod avec un volume réel.

Deuxième piège: empiler les fetch joins sur plusieurs niveaux. Chaque niveau supplémentaire augmente le coût du JOIN SQL. La doc Doctrine prévient explicitement: remplacer systématiquement tous les chargements paresseux par des jointures n'est pas une règle de performance générale. Le lazy loading a existé pour une raison; le rendre agressivement immédiat peut très bien transformer un code fonctionnel en un plan d'exécution catastrophique, surtout quand l'optimiseur a du mal à estimer la cardinalité réelle.

Et tant qu'on y est, une clarification utile qu'on entend trop souvent à tort: les requêtes préparées PDO n'éliminent pas le N+1 à elles seules. Elles peuvent aider lorsqu'une même requête est exécutée plusieurs fois (réduction du coût de parsing côté serveur), mais elles ne réduisent pas le nombre de requêtes déclenchées par le code applicatif. Le problème de fond reste algorithmique: c'est la boucle qui décide, pas la couche d'abstraction en dessous.

Ce qu'il faut en retenir

L'ironie du N+1, c'est qu'il est ultra-simple à expliquer et ultra-compliqué à éradiquer sérieusement. Pas de seuil universel, pas de solution magique, pas de KPI standardisé. Le seul plan qui tient la route, c'est une discipline en trois temps, applicable à n'importe quel projet PHP qui grossit sans qu'on s'en rende compte.

D'abord on compte: compteur applicatif en test, digests MySQL en prod, EXPLAIN ANALYZE quand on creuse un plan suspects. Ensuite on précharge intelligemment — with() et chaperone() quand on est sur Eloquent, fetch joins soigneusement dimensionnés quand on est sur Doctrine, sans empiler les niveaux par réflexe. Enfin on protège: preventLazyLoading activé hors production pour transformer les N+1 silencieux en exceptions explicites, et revue de code qui surveille les boucles sur relations comme on surveille les magic numbers.

Le N+1 n'est pas un bug à patcher: c'est une discipline d'accès aux données. Et comme toute discipline, elle s'apprend en la pratiquant, pas en la théorisant.

Pour la communauté, on a un vrai débat à ouvrir. Laravel 12 et son auto-eager loading bêta méritent un retour d'expérience collectif — qui l'a activé en staging, qui a vu ses compteurs chuter sans régression, qui s'est fait piéger par une query oubliée. Les RFC passent, les correctifs suivent, mais ce sont les retours du terrain qui font la qualité réelle d'une release. Et côté Doctrine, le débat est tout aussi ouvert sur les fetch joins imbriqués: jusqu'où pousse-t-on la jointure avant que l'optimiseur ne décide de nous remplacer par un DBA nerveux? On en parle?

Questions fréquentes

Pourquoi le chargement paresseux (lazy loading) pose-t-il problème ?
Il déclenche une nouvelle requête SQL à chaque fois qu'une relation est accédée dans une boucle, multipliant ainsi le nombre total d'appels à la base de données.
Comment détecter le N+1 dans une application Laravel ?
Vous pouvez utiliser DB::getQueryLog() pour lister les requêtes, ou activer Model::preventLazyLoading() pour lever une exception dès qu'un chargement paresseux est détecté.
Le préchargement (eager loading) règle-t-il tous les problèmes de N+1 ?
Non, car si vous accédez à une relation inverse non préchargée dans une boucle, l'ORM déclenchera tout de même des requêtes supplémentaires.
Pourquoi la pagination avec un fetch join dans Doctrine peut-elle échouer ?
La limite SQL s'applique aux lignes de la base et non aux entités hydratées, ce qui peut entraîner un nombre d'entités racines inférieur à celui attendu.
Les requêtes préparées PDO permettent-elles d'éviter le N+1 ?
Non, elles réduisent le coût de parsing côté serveur, mais elles ne diminuent pas le nombre de requêtes déclenchées par la logique applicative.