12k
All articles

Comprendre le problème des requêtes N+1

Le problème N+1 expliqué avec les solutions Rails, Django, Hibernate et Laravel, plus le eager loading, les JOIN et la détection.

OpenReplay Team
OpenReplay Team
Comprendre le problème des requêtes N+1

Le problème des requêtes N+1 survient lorsqu’une application exécute une requête pour récupérer une liste de N enregistrements, puis exécute une requête supplémentaire par enregistrement pour charger une entité associée : N+1 requêtes là où une ou deux suffiraient.

La plupart des développeurs le découvrent de la même manière. Une page qui semblait instantanée sur une base de développement pré-remplie devient poussive dès qu’elle touche des données réelles, et le journal des requêtes révèle le même SELECT répété quatre cents fois avec un id différent.

C’est le bug de performance le plus courant dans le code utilisant un mappeur objet-relationnel, et il se manifeste de façon identique sous Rails, Django, Hibernate et Laravel, car tous partagent le même comportement par défaut : le chargement paresseux (lazy loading). Cet article définit le schéma, l’illustre dans quatre environnements techniques, détaille la correction exacte pour chaque framework, corrige une idée fausse tenace au sujet du chargement anticipé (eager fetching) et explique comment détecter les N+1 avant leur mise en production.

Points clés à retenir

  • Le problème des requêtes N+1, c’est une requête pour charger N lignes parentes, plus N requêtes de suivi pour charger un enregistrement associé à chacune. Le nombre de requêtes croît linéairement avec N.
  • Il se produit parce que la plupart des ORM chargent les associations de façon paresseuse par défaut : accéder à une relation dans une boucle déclenche silencieusement une requête à chaque itération.
  • Il existe deux corrections valables, et toutes deux ramènent le N+1 à un nombre constant de requêtes : une jointure unique (JOIN) qui charge parents et enfants ensemble, ou une seconde requête groupée utilisant WHERE id IN (...).
  • Définir FetchType.EAGER en JPA ne corrige pas le N+1 sous JPQL. Cela change le moment où les requêtes supplémentaires sont déclenchées, pas le fait qu’elles soient groupées ou non.
  • Les journaux de requêtes en développement ne détectent les N+1 que sur les chemins de code que vous exécutez par hasard ; un APM en production détecte ceux que votre jeu de données local était trop petit pour révéler.

Qu’est-ce que le problème des requêtes N+1 ?

Prenons une relation posts/authors. Vous exécutez une requête pour charger tous les posts, puis vous les parcourez en lisant post.author.name sur chacun. Cet accès à la propriété constitue une seconde requête, répétée une fois par post. Dix posts produisent onze requêtes ; mille posts en produisent mille et une, et le temps de réponse total croît linéairement avec le nombre d’enregistrements.

C’est cette croissance linéaire qui rend le N+1 dangereux. Un bug N+1 est généralement invisible en développement sur cinq lignes pré-remplies, et devient une panne en production sur cinq mille. Le endpoint qui répondait en 40 ms sur votre portable répond en 4 secondes, ou expire, dès que les données réelles arrivent.

Pourquoi le N+1 se produit-il ?

Le N+1 se produit parce que la plupart des ORM chargent les associations de façon paresseuse par défaut : un objet associé n’est pas récupéré au chargement du parent, mais au premier accès. Les relations Eloquent se comportent exactement ainsi. Lire l’une d’elles comme une propriété déclenche la requête au moment de l’accès, et non lors du chargement du modèle parent ; le chargement anticipé est l’alternative à activer explicitement. Il en va de même pour les proxys ActiveRecord, les related managers de Django et les proxys paresseux d’Hibernate.

Dans une boucle, cet accès paresseux est silencieux et se produit à chaque itération. Rien dans le code source ne le signale : pas de mot-clé N+1, pas d’avertissement. C’est précisément pour cela qu’il survit à la revue de code et n’apparaît que sous charge.

À quoi cela ressemble dans le code

Le avant/après a la même forme dans tous les environnements : une boucle qui touche une relation, réécrite pour charger cette relation en amont.

Rails (ActiveRecord) :

# N+1: 1 query for books + 1 per book for the author
Book.limit(10).each { |book| puts book.author.last_name }

# Fixed: 2 queries total
Book.includes(:author).limit(10).each { |book| puts book.author.last_name }

ORM Django :

# N+1: 1 query for books + 1 per book for the author
for book in Book.objects.all():
    print(book.title, book.author.name)

# Fixed: one JOIN
for book in Book.objects.select_related("author"):
    print(book.title, book.author.name)

JPA / Hibernate (JPQL) :

// N+1: findAll() loads transports, then one SELECT per driver on access
List<Transport> all = transportRepository.findAll();

// Fixed: a single fetch join
@Query("SELECT t FROM Transport t JOIN FETCH t.driver")
List<Transport> findAllWithDriver();

SQL brut : remplacez la recherche ligne par ligne par un unique LEFT JOIN, en utilisant LEFT afin de conserver les parents sans enfants :

SELECT c.id, c.name, i.id AS item_id, i.name AS item_name
FROM categories c
LEFT JOIN items i ON i.category_id = c.id
ORDER BY c.name, i.name;

Comment corriger le problème des requêtes N+1

Il existe deux façons correctes de corriger un N+1, et toutes deux le ramènent à un nombre constant de requêtes : une jointure unique qui charge parents et enfants ensemble, ou une seconde requête groupée qui récupère toutes les lignes associées avec un unique WHERE id IN (...). La jointure n’utilise qu’un aller-retour mais peut dupliquer les lignes parentes (et provoquer une explosion cartésienne sur plusieurs collections) ; la requête groupée utilise deux allers-retours mais ne renvoie aucune donnée dupliquée. Chaque framework expose ces deux stratégies sous des noms différents.

FrameworkStratégie JOIN (une requête)Stratégie de requête groupée (WHERE id IN)
Railseager_load(:assoc)preload(:assoc)
Rails (auto)includes(:assoc) (Rails choisit)includes(:assoc)
Djangoselect_related("assoc")prefetch_related("assoc")
JPA/HibernateJOIN FETCH / @EntityGraph / fetchJoin() de QueryDSLchargement par lots (@BatchSize)
Laravelwith('assoc')
SQL brutLEFT JOINsecond SELECT ... WHERE fk IN (...)

Les deux méthodes Django sont celles que l’on confond le plus souvent : il vaut donc la peine d’être précis sur le rôle de chacune. La référence de l’API QuerySet de Django trace la frontière selon l’arité de la relation : select_related construit une jointure et ramène les lignes associées dans la même instruction, ce qui ne fonctionne que lorsqu’un parent a au plus un enregistrement associé — d’où sa couverture des ForeignKey et OneToOneField. prefetch_related émet sa propre requête par relation et assemble les résultats en Python, ce qui lui permet de gérer les ManyToManyField et les clés étrangères inverses.

Rails répartit la même distinction sur trois méthodes. Le guide Active Record Query Interface décrit preload comme déclenchant une requête supplémentaire par association nommée, et eager_load comme ramenant tout via un unique LEFT OUTER JOIN. includes se situe entre les deux : la documentation de l’API indique qu’elle utilise par défaut une requête distincte par association et ne bascule vers une jointure que lorsque les conditions de la requête l’imposent. En résumé : preload est toujours une requête distincte, eager_load est toujours une jointure, et includes laisse ActiveRecord choisir.

Sous Laravel, with() est la correction canonique du chargement anticipé, en émettant une requête groupée pour la relation. Laravel 12.8 a introduit Model::automaticallyEagerLoadRelationships(), qui charge automatiquement en anticipé toute relation à laquelle une collection accède sans appel explicite à with().

Pourquoi FetchType.EAGER ne corrige pas le N+1

Définir FetchType.EAGER ne corrige pas le N+1 sous JPQL. Le chargement anticipé change le moment où les requêtes supplémentaires sont déclenchées, pas le fait qu’elles soient groupées ou non : vous avez donc toujours besoin d’un JOIN FETCH ou d’un @EntityGraph. C’est l’idée fausse la plus répandue à propos de JPA. Le guide utilisateur d’Hibernate ORM l’explicite : une requête JPQL qui laisse une association eager hors de son plan de récupération amène Hibernate à exécuter un select de suivi par association eager, ce qui n’est rien d’autre qu’un N+1 sous un autre nom ; la recommandation du guide lui-même est de mapper les associations en paresseux et de les ramener en anticipé requête par requête.

Le principe général vaut pour tous les ORM : configurer une relation en eager au niveau du mapping est une décision sur le moment, pas sur le regroupement. C’est une jointure de récupération (fetch join) ou un graphe d’entités qui charge réellement l’association en une seule instruction, en condensant parent et enfants en un unique aller-retour.

Comment détecter les requêtes N+1

Commencez par lire le SQL émis par votre ORM. Le journal de développement de Rails affiche chaque requête ; Django expose les décomptes via la django-debug-toolbar ; Hibernate journalise les instructions avec spring.jpa.show-sql=true ; Laravel les fait remonter via Laravel Debugbar. Des SELECT répétés et quasi identiques, ne différant que par un id, en sont la signature.

Les outils fail-fast transforment le N+1 en erreur dès le développement. La gem Bullet avertit en cas d’associations Rails non optimisées (dev/test uniquement), nplusone en Python journalise les violations, et sous Laravel, Model::preventLazyLoading() rend l’accès paresseux bruyant : une fois activé, une relation résolue après coup lève une LazyLoadingViolationException au lieu d’exécuter discrètement une requête de plus. Restreignez-le aux environnements hors production, afin qu’une relation oubliée ne fasse jamais planter une requête en conditions réelles.

Le hic : les journaux de requêtes en développement ne détectent les N+1 que sur les chemins de code que vous exécutez par hasard ; un APM en production détecte ceux que votre jeu de données local était trop petit pour révéler. Les outils de supervision des performances applicatives observent chaque requête dans chaque requête HTTP et chaque tâche de fond, en signalant les schémas répétés avec le site d’appel exact. C’est une couverture que des outils réservés au développement comme Bullet ou la debug toolbar ne peuvent pas offrir.

Quand un N+1 est-il acceptable ?

Tous les N+1 ne méritent pas d’être corrigés. Lorsque N est petit et borné — par exemple une page qui affiche toujours exactement trois éléments — les requêtes supplémentaires peuvent revenir moins cher que le coût de maintenance d’une chaîne de préchargement. Lorsque les enregistrements associés sont déjà servis depuis un cache de requêtes ou un cache applicatif, les requêtes « supplémentaires » peuvent ne jamais atteindre la base de données. Et il arrive qu’une boucle explicite accompagnée d’un commentaire se lise plus clairement qu’un chargement anticipé imbriqué. Ce sont des exceptions ; traitez-les comme des choix délibérés et documentés, car N a tendance à croître avec le temps, même quand vous êtes certain du contraire.

Ce schéma est un concept unique avec une orthographe propre à chaque framework : apprenez-le une fois pour toutes. Repérez la relation accédée à l’intérieur d’une boucle, choisissez une jointure ou une requête groupée, et mettez en place une détection pour que le prochain N+1 échoue sur votre machine plutôt que sur celle de vos utilisateurs.

FAQ

Quelle est la différence entre le chargement anticipé par jointure et le chargement anticipé par requête groupée ?

Une correction par jointure (eager_load sous Rails, select_related sous Django, JOIN FETCH en JPA, LEFT JOIN en SQL brut) charge parents et enfants en une seule requête, mais peut dupliquer les lignes parentes et provoquer une explosion cartésienne sur plusieurs collections. Une correction par requête groupée (preload sous Rails, prefetch_related sous Django, with() sous Laravel) exécute une seconde requête utilisant WHERE id IN (...), ce qui ajoute un aller-retour mais ne renvoie aucune ligne dupliquée. Toutes deux ramènent le N+1 à un nombre constant de requêtes.

Définir FetchType.EAGER corrige-t-il le problème N+1 dans Hibernate ?

Non. Dans le cadre d'une requête JPQL, FetchType.EAGER ne regroupe pas les entités associées ; Hibernate émet un SELECT secondaire pour chaque association eager dont il a besoin, ce qui reproduit le N+1. Le chargement anticipé change le moment où les requêtes supplémentaires sont déclenchées, pas le fait qu'elles soient groupées. Pour charger réellement une association en une seule instruction, il vous faut JOIN FETCH, un @EntityGraph ou le fetchJoin() de QueryDSL. Ce comportement est inchangé jusqu'à Hibernate 7 inclus.

Pourquoi les bugs N+1 passent-ils la revue de code et les tests locaux, mais cassent en production ?

Le N+1 est invisible dans le code source, car le chargement paresseux déclenche silencieusement une requête lors de l'accès à une relation dans une boucle, sans mot-clé ni avertissement pour le signaler. Le nombre de requêtes croît linéairement avec N : cinq lignes pré-remplies produisent six requêtes rapides en développement, tandis que cinq mille lignes en produisent cinq mille et une en production. De plus, les journaux de requêtes en développement ne détectent les N+1 que sur les chemins de code que vous exécutez par hasard, ce qui explique qu'un APM en production détecte ceux que votre jeu de données local était trop petit pour révéler.

Quelle méthode Django utiliser : select_related ou prefetch_related ?

Utilisez select_related pour les relations ForeignKey et OneToOneField ; elle effectue une jointure SQL et charge les objets associés dans la même requête. Utilisez prefetch_related pour les ManyToManyField et les clés étrangères inverses ; elle exécute une recherche distincte par relation et assemble les résultats en Python. Choisir la mauvaise est l'erreur N+1 la plus courante sous Django : prefetch_related ne peut pas être utilisée pour les relations directes à valeur unique de la manière dont select_related est prévue pour l'être.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.