web-dev-qa-db-fra.com

Qu'est-ce que le "problème de sélection N + 1" dans ORM (Object-Relational Mapping)?

Le "problème de sélection N + 1" est généralement défini comme un problème dans les discussions ORM (Object-Relational Mapping), et je comprends que cela a quelque chose à voir avec le fait de devoir faire beaucoup de requêtes de base de données pour quelque chose qui semble simple dans l'objet. monde.

Quelqu'un at-il une explication plus détaillée du problème?

1480
Lars A. Brekken

Supposons que vous avez une collection d'objets Car (lignes de la base de données) et que chaque Car possède une collection d'objets Wheel (ainsi que des lignes). En d'autres termes, Car -> Wheel est une relation 1 à plusieurs.

Maintenant, supposons que vous ayez à parcourir toutes les voitures et à chacune d’elles d’imprimer une liste des roues. La mise en œuvre naïve d'O/R aurait les conséquences suivantes:

SELECT * FROM Cars;

Et puis pour chaque Car:

SELECT * FROM Wheel WHERE CarId = ?

En d'autres termes, vous avez une sélection pour les voitures, puis N sélections supplémentaires, où N est le nombre total de voitures.

Alternativement, on peut obtenir toutes les roues et effectuer les recherches en mémoire:

SELECT * FROM Wheel

Cela réduit le nombre d'allers-retours à la base de données de N + 1 à 2. La plupart des outils ORM vous proposent plusieurs moyens d'empêcher les sélections N + 1.

Référence: Java Persistence with Hibernate, chapitre 13.

917
Matt Solnit
SELECT 
table1.*
, table2.*
INNER JOIN table2 ON table2.SomeFkId = table1.SomeId

Cela vous donne un ensemble de résultats où les lignes enfants de la table2 provoquent la duplication en renvoyant les résultats de la table1 pour chaque ligne enfant de la table2. Les mappeurs O/R doivent différencier les instances de table1 en fonction d'un champ clé unique, puis utiliser toutes les colonnes de table2 pour renseigner les instances enfant.

SELECT table1.*

SELECT table2.* WHERE SomeFkId = #

N + 1 est l'endroit où la première requête remplit l'objet principal et la deuxième requête remplit tous les objets enfants de chacun des objets primaires uniques renvoyés.

Considérer:

class House
{
    int Id { get; set; }
    string Address { get; set; }
    Person[] Inhabitants { get; set; }
}

class Person
{
    string Name { get; set; }
    int HouseId { get; set; }
}

et des tables avec une structure similaire. Une seule requête pour l'adresse "22 Valley St" peut renvoyer:

Id Address      Name HouseId
1  22 Valley St Dave 1
1  22 Valley St John 1
1  22 Valley St Mike 1

O/RM doit remplir une instance de Home avec ID = 1, Address = "22 Valley St", puis renseigner le tableau Inhabitants avec des instances People pour Dave, John et Mike en une seule requête.

Une requête N + 1 pour la même adresse utilisée ci-dessus aurait pour résultat:

Id Address
1  22 Valley St

avec une requête séparée comme

SELECT * FROM Person WHERE HouseId = 1

et aboutissant à un ensemble de données séparé comme

Name    HouseId
Dave    1
John    1
Mike    1

et le résultat final étant le même que ci-dessus avec la requête unique.

Les avantages de la sélection unique sont que vous obtenez toutes les données à l’avance, ce qui peut être ce que vous désirez au final. La complexité de la requête réduit les avantages de N + 1 et vous pouvez utiliser un chargement différé dans lequel les ensembles de résultats enfants ne sont chargés qu'à la première demande.

106
cfeduke

Fournisseur ayant une relation un-à-plusieurs avec le produit. Un fournisseur a (fournitures) de nombreux produits.

***** Table: Supplier *****
+-----+-------------------+
| ID  |       NAME        |
+-----+-------------------+
|  1  |  Supplier Name 1  |
|  2  |  Supplier Name 2  |
|  3  |  Supplier Name 3  |
|  4  |  Supplier Name 4  |
+-----+-------------------+

***** Table: Product *****
+-----+-----------+--------------------+-------+------------+
| ID  |   NAME    |     DESCRIPTION    | PRICE | SUPPLIERID |
+-----+-----------+--------------------+-------+------------+
|1    | Product 1 | Name for Product 1 |  2.0  |     1      |
|2    | Product 2 | Name for Product 2 | 22.0  |     1      |
|3    | Product 3 | Name for Product 3 | 30.0  |     2      |
|4    | Product 4 | Name for Product 4 |  7.0  |     3      |
+-----+-----------+--------------------+-------+------------+

Les facteurs:

  • Mode paresseux pour le fournisseur défini sur "true" (par défaut)

  • Le mode de récupération utilisé pour interroger le produit est sélectionné

  • Mode de récupération (par défaut): accès aux informations du fournisseur

  • La mise en cache ne joue pas pour la première fois le rôle

  • Le fournisseur est accédé

Le mode de récupération est Select Fetch (par défaut)

// It takes Select fetch mode as a default
Query query = session.createQuery( "from Product p");
List list = query.list();
// Supplier is being accessed
displayProductsListWithSupplierName(results);

select ... various field names ... from PRODUCT
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
select ... various field names ... from SUPPLIER where SUPPLIER.id=?

Résultat:

  • 1 instruction select pour le produit
  • N select statement for Supplier

C'est N + 1 problème choisi!

61
Summy

Je ne peux pas commenter directement d'autres réponses, car je n'ai pas assez de réputation. Mais il convient de noter que le problème ne se pose essentiellement que parce qu'historiquement, beaucoup de dbms ont été assez médiocres pour la gestion des jointures (MySQL est un exemple particulièrement remarquable). Donc, n + 1 a souvent été nettement plus rapide qu'une jointure. Et puis, il y a moyen d'améliorer n + 1 mais sans avoir besoin d'une jointure, ce à quoi se rapporte le problème initial.

Cependant, MySQL est maintenant bien meilleur qu'avant en ce qui concerne les jointures. Quand j'ai appris MySQL, j'ai utilisé beaucoup de jointures. Ensuite, j'ai découvert à quel point ils sont lents et je suis passé à n + 1 dans le code. Mais récemment, je reviens aux jointures, parce que MySQL est maintenant bien mieux à même de les gérer que lorsque j'ai commencé à les utiliser.

De nos jours, une simple jointure sur un ensemble de tables correctement indexé pose rarement problème, en termes de performances. Et si cela donne un impact négatif sur les performances, l'utilisation d'indices d'index les résout souvent.

Ceci est discuté ici par l’une des équipes de développement de MySQL:

http://jorgenloland.blogspot.co.uk/2013/02/dbt-3-q3-6-x-performance-in-mysql-5610.html

Le résumé est le suivant: si vous évitiez les jointures dans le passé en raison des performances abyssales de MySQL, essayez à nouveau avec les dernières versions. Vous serez probablement agréablement surpris.

37
Mark Goodge

Nous nous sommes éloignés de l'ORM dans Django à cause de ce problème. Fondamentalement, si vous essayez de faire

for p in person:
    print p.car.colour

L'ORM renverra volontiers toutes les personnes (généralement en tant qu'instances d'un objet Personne), mais il devra alors interroger la table car pour chaque personne.

J'appelle " fanfolding " une approche simple et très efficace, ce qui évite l'idée absurde que les résultats d'une requête provenant d'une base de données relationnelle doivent être redirigés vers les tables d'origine à partir desquelles la requête est composée.

Étape 1: large sélection

  select * from people_car_colour; # this is a view or sql function

Cela retournera quelque chose comme

  p.id | p.name | p.telno | car.id | car.type | car.colour
  -----+--------+---------+--------+----------+-----------
  2    | jones  | 2145    | 77     | ford     | red
  2    | jones  | 2145    | 1012   | toyota   | blue
  16   | ashby  | 124     | 99     | bmw      | yellow

Étape 2: Objectiver

Aspirez les résultats dans un créateur d'objet générique avec un argument à scinder après le troisième élément. Cela signifie que l'objet "jones" ne sera pas créé plus d'une fois.

Étape 3: rendu

for p in people:
    print p.car.colour # no more car queries

Voir cette page Web pour une implémentation de fanfolding pour python.

26
rorycl

Supposons que vous ayez COMPAGNIE et EMPLOYÉ. SOCIÉTÉ compte de nombreux EMPLOYÉS (c’est-à-dire que EMPLOYEE a un champ COMPANY_ID).

Dans certaines configurations O/R, lorsque vous avez un objet Société mappé et allez accéder à ses objets Employé, l'outil O/R effectue une sélection pour chaque employé. Dans ce cas, si vous exécutiez des tâches simplement en SQL, vous pourriez select * from employees where company_id = XX. Donc N (nombre d'employés) plus 1 (entreprise)

Voici comment fonctionnaient les versions initiales d’EJB Entity Beans. Je crois que des choses comme Hibernate ont éliminé cela, mais je n'en suis pas trop sûr. La plupart des outils incluent généralement des informations sur leur stratégie de cartographie.

18
davetron5000

Voici une bonne description du problème - https://web.archive.org/web/20160310145416/http://www.realsolve.co.uk/site/tech/hib-tip-pitfall.php? nom = pourquoi-paresseux

Maintenant que vous comprenez le problème, vous pouvez généralement l'éviter en effectuant une extraction de jointure dans votre requête. Cela force essentiellement l'extraction de l'objet chargé paresseux afin que les données soient récupérées dans une requête au lieu de n + 1 requêtes. J'espère que cela t'aides.

17
Joe Dean

Voir le post d'Ayende sur le sujet: Combattre le problème de Select N + 1 dans NHibernate

Fondamentalement, lorsque vous utilisez un ORM comme NHibernate ou EntityFramework, si vous avez une relation un vers plusieurs (maître-détail) et que vous souhaitez répertorier tous les détails de chaque enregistrement principal, vous devez effectuer N base de données, "N" étant le nombre d'enregistrements principaux: 1 requête pour obtenir tous les enregistrements principaux et N requêtes, une par enregistrement principal, pour obtenir tous les détails par enregistrement principal.

Plus d'appels à la base de données -> plus de temps de latence -> diminution des performances de l'application/de la base de données.

Cependant, les ORM ont des options pour éviter ce problème, principalement en utilisant des "jointures".

14
Nathan

À mon avis, l’article écrit dans Hibernate Pitfall: Pourquoi les relations doivent être paresseuses est exactement le contraire de la vraie question N + 1.

Si vous avez besoin d'une explication correcte, veuillez vous reporter Hibernate - Chapitre 19: Amélioration des performances - Stratégies de récupération

Sélectionner l'extraction (par défaut) est extrêmement vulnérable à la sélection de N + 1 problèmes, nous voudrons peut-être activer l'activation de la jointure.

13
Anoop Isaac

Le problème de requête N + 1 se produit lorsque vous oubliez de récupérer une association et que vous devez ensuite y accéder:

List<PostComment> comments = entityManager.createQuery(
    "select pc " +
    "from PostComment pc " +
    "where pc.review = :review", PostComment.class)
.setParameter("review", review)
.getResultList();

LOGGER.info("Loaded {} comments", comments.size());

for(PostComment comment : comments) {
    LOGGER.info("The post title is '{}'", comment.getPost().getTitle());
}

Qui génère les instructions SQL suivantes:

SELECT pc.id AS id1_1_, pc.post_id AS post_id3_1_, pc.review AS review2_1_
FROM   post_comment pc
WHERE  pc.review = 'Excellent!'

INFO - Loaded 3 comments

SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM   post pc
WHERE  pc.id = 1

INFO - The post title is 'Post nr. 1'

SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM   post pc
WHERE  pc.id = 2

INFO - The post title is 'Post nr. 2'

SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM   post pc
WHERE  pc.id = 3

INFO - The post title is 'Post nr. 3'

Tout d’abord, Hibernate exécute la requête JPQL et une liste d’entités PostComment est extraite.

Ensuite, pour chaque PostComment, la propriété associée post est utilisée pour générer un message de journal contenant le titre Post.

Puisque l'association post n'est pas initialisée, Hibernate doit extraire l'entité Post avec une requête secondaire, et pour N PostComment, de nombreuses autres requêtes vont être exécutées (d'où la N + 1 problème de requête).

Tout d’abord, vous avez besoin de journalisation et surveillance SQL appropriées pour pouvoir repérer ce problème.

Deuxièmement, il est préférable que ce type de problème soit pris en compte par les tests d’intégration. Vous pouvez utiliser un assertion automatique de JUnit pour valider le nombre attendu d'instructions SQL générées . Le projet db-unit project fournit déjà cette fonctionnalité et est open source.

Lorsque vous avez identifié le problème de requête N + 1, vous devez utiliser un JOIN FETCH pour que les associations enfants soient extraites dans une requête au lieu de N . Si vous devez extraire plusieurs associations enfants, il est préférable d'extraire une collection dans la requête initiale et la seconde avec une requête SQL secondaire.

13
Vlad Mihalcea

Le lien fourni a un exemple très simple du problème n + 1. Si vous l'appliquez à Hibernate, vous parlez de la même chose. Lorsque vous interrogez un objet, l'entité est chargée mais toutes les associations (sauf configuration contraire) sont chargées paresseux. D'où une requête pour les objets racine et une autre requête pour charger les associations pour chacun d'eux. 100 objets renvoyés correspondent à une requête initiale, puis à 100 requêtes supplémentaires pour obtenir l'association pour chaque n + 1.

http://pramatr.com/2009/02/05/sql-n-1-selects-explained/

10
Jeff Mills

Un millionnaire a N voitures. Vous voulez obtenir toutes les (4) roues.

Une (1) requête charge toutes les voitures, mais pour chaque (N) voiture, une requête distincte est soumise pour le chargement des roues.

Frais:

Supposons que les index entrent dans le bélier.

Analyse et planification de requête 1 + N + recherche d'index ET 1 + N + (N * 4) accès aux plaques pour le chargement de la charge utile.

Supposons que les index ne rentrent pas dans le bélier.

Coûts supplémentaires dans le pire des cas 1 + N accès aux plaques pour l’indice de chargement.

Sommaire

Le goulot de la bouteille correspond à l'accès à la plaque (environ 70 fois par seconde, accès aléatoire sur disque dur). Donc, si les index entrent dans le ram - pas de problème, c’est assez rapide parce que seules les opérations du ram sont impliquées.

9
hans wurst

Il est beaucoup plus rapide d’émettre 1 requête qui renvoie 100 résultats que d’émettre 100 requêtes qui renvoient chacune 1 résultat.

9
jj_

N + 1 problème sélectionné est une douleur, et il est logique de détecter de tels cas dans les tests unitaires. J'ai développé une petite bibliothèque pour vérifier le nombre de requêtes exécutées par une méthode de test donnée ou juste un bloc de code arbitraire - JDBC Sniffer

Ajoutez simplement une règle spéciale JUnit à votre classe de test et placez une annotation avec le nombre attendu de requêtes sur vos méthodes de test:

@Rule
public final QueryCounter queryCounter = new QueryCounter();

@Expectation(atMost = 3)
@Test
public void testInvokingDatabase() {
    // your JDBC or JPA code
}
8
bedrin

Le problème, comme d’autres l’ont dit de manière plus élégante, est que vous avez soit un produit cartésien des colonnes OneToMany, soit vous effectuez des sélections N + 1. Résultats gigantesques possibles ou bavardage avec la base de données, respectivement.

Je suis surpris que cela ne soit pas mentionné, mais voici comment j'ai résolu ce problème ... Je crée un tableau d'identifiants semi-temporaire . Je le fais aussi quand vous avez la limitation de la clause IN () .

Cela ne fonctionne pas dans tous les cas (probablement même pas la majorité), mais cela fonctionne particulièrement bien si vous avez beaucoup d'objets enfants tels que le produit cartésien deviendra incontrôlable (c'est-à-dire beaucoup de OneToMany colonnes le nombre des résultats sera une multiplication des colonnes) et sa plus d'un lot comme travail.

Tout d'abord, vous insérez vos identifiants d'objet parent en tant que lot dans une table d'identifiants. Ce batch_id est quelque chose que nous générons dans notre application et que nous conservons.

INSERT INTO temp_ids 
    (product_id, batch_id)
    (SELECT p.product_id, ? 
    FROM product p ORDER BY p.product_id
    LIMIT ? OFFSET ?);

Maintenant, pour chaque colonne OneToMany, il vous suffit de faire un SELECT sur la table ids INNER JOINing la table enfant avec un WHERE batch_id= (ou vice versa). Vous voulez simplement vous assurer de classer par la colonne id car cela facilitera la fusion des colonnes de résultats (sinon, vous aurez besoin d'un HashMap/Table pour tout le jeu de résultats, qui peut ne pas être si mauvais).

Ensuite, il vous suffit de nettoyer périodiquement la table des identifiants.

Cela fonctionne aussi particulièrement bien si l’utilisateur sélectionne une centaine d’articles distincts pour un traitement en bloc. Placez les 100 identifiants distincts dans la table temporaire.

Maintenant, le nombre de requêtes que vous effectuez est fonction du nombre de colonnes OneToMany.

5
Adam Gent

Prenez l'exemple de Matt Solnit, imaginez que vous définissiez une association entre Car et Wheels comme étant LAZY et que vous ayez besoin de quelques champs Wheels. Cela signifie qu'après le premier choix, hibernate va faire "Select * from Wheels où car_id =: id" FOR EACH Car.

Cela fait la première sélection et plus 1 sélection par chaque voiture N, c'est pourquoi on l'appelle n + 1 problème.

Pour éviter cela, associez l'extraction à l'association de manière à ce que hibernate charge les données avec une jointure.

Mais attention, si vous n'avez souvent pas accès aux roues associées, il est préférable de la conserver ou de changer de type d'extraction avec Criteria.

1
martins.tuga