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

1598

Le «problème de sélection N + 1» est généralement indiqué comme un problème dans les discussions sur le mappage relationnel objet (ORM), et je comprends qu'il a quelque chose à voir avec le fait d'avoir à faire beaucoup de requêtes dans la 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?

Lars A. Brekken
la source
2
Ceci est un excellent lien avec une belle explication sur la compréhension du problème n + 1 . Il couvre également les solutions pour contrer ce problème: architects.dzone.com/articles/how-identify-and-resilve-n1
aces.
Pour tous ceux qui recherchent une solution à ce problème, j'ai trouvé un article le décrivant. stackoverflow.com/questions/32453989/…
damndemon
2
Compte tenu des réponses, cela ne devrait-il pas être qualifié de problème 1 + N? Comme cela semble être une terminologie, je ne demande pas spécifiquement à OP.
user1418717

Réponses:

1018

Supposons que vous ayez une collection d' Carobjets (lignes de base de données) et que chacun Carait une collection d' Wheelobjets (également des lignes). En d'autres termes, CarWheelest une relation 1 à plusieurs.

Maintenant, disons que vous devez parcourir toutes les voitures et, pour chacune, imprimer une liste des roues. La mise en œuvre naïve d'O / R ferait ce qui suit:

SELECT * FROM Cars;

Et puis pour chacun 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 pourrait obtenir toutes les roues et effectuer les recherches en mémoire:

SELECT * FROM Wheel

Cela réduit le nombre d'aller-retour dans la base de données de N + 1 à 2. La plupart des outils ORM vous offrent plusieurs façons d'empêcher les sélections N + 1.

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

Matt Solnit
la source
140
Pour clarifier sur le "c'est mauvais" - vous pouvez obtenir toutes les roues avec 1 select ( SELECT * from Wheel;), au lieu de N + 1. Avec un grand N, l'atteinte des performances peut être très importante.
tucuxi
212
@tucuxi Je suis surpris que vous ayez tant de votes positifs pour avoir tort. Une base de données est très bonne sur les index, faire la requête pour un CarID spécifique reviendrait très rapidement. Mais si vous obtenez toutes les roues une fois, vous devrez rechercher CarID dans votre application, qui n'est pas indexée, c'est plus lent. À moins que vous n'ayez des problèmes de latence majeurs pour atteindre votre base de données, n + 1 est en fait plus rapide - et oui, je l'ai comparé avec une grande variété de code du monde réel.
Ariel
74
@ariel La manière «correcte» consiste à obtenir toutes les roues, commandées par CarId (1 sélection), et si plus de détails que le CarId sont requis, faites une deuxième requête pour toutes les voitures (2 requêtes au total). L'impression est désormais optimale et aucun index ni stockage secondaire n'était requis (vous pouvez parcourir les résultats, pas besoin de les télécharger tous). Vous avez évalué la mauvaise chose. Si vous êtes toujours sûr de vos repères, cela vous dérangerait-il de publier un commentaire plus long (ou une réponse complète) expliquant votre expérience et vos résultats?
tucuxi
92
"Hibernate (je ne suis pas familier avec les autres frameworks ORM) vous donne plusieurs façons de le gérer." et ces voies sont?
Tima
58
@Ariel Essayez d'exécuter vos benchmarks avec des serveurs de bases de données et d'applications sur des machines distinctes. D'après mon expérience, les allers-retours vers la base de données coûtent plus cher que la requête elle-même. Alors oui, les requêtes sont vraiment rapides, mais ce sont les allers-retours qui font des havoks. J'ai converti "WHERE Id = const " en "WHERE Id IN ( const , const , ...)" et j'en ai obtenu des ordres de grandeur.
Hans
110
SELECT 
table1.*
, table2.*
INNER JOIN table2 ON table2.SomeFkId = table1.SomeId

Cela vous permet d'obtenir un jeu de résultats dans lequel les lignes enfants du tableau 2 provoquent la duplication en renvoyant les résultats du tableau 1 pour chaque ligne enfant du tableau 2. Les mappeurs O / R doivent différencier les instances de table1 en fonction d'un champ de clé unique, puis utiliser toutes les colonnes de table2 pour remplir les instances enfant.

SELECT table1.*

SELECT table2.* WHERE SomeFkId = #

Le 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 pour chacun des objets principaux uniques retourné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

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

Une requête N + 1 pour la même adresse utilisée ci-dessus entraînerait:

Id Address
1  22 Valley St

avec une requête distincte comme

SELECT * FROM Person WHERE HouseId = 1

et résultant en un ensemble de données distinct 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 en fin de compte. Les avantages de N + 1 sont la complexité des requêtes est réduite et vous pouvez utiliser le chargement différé où les jeux de résultats enfants ne sont chargés qu'à la première demande.

cfeduke
la source
4
L'autre avantage de n + 1 est qu'il est plus rapide car la base de données peut renvoyer les résultats directement à partir d'un index. La jointure puis le tri nécessitent une table temporaire, qui est plus lente. La seule raison d'éviter n + 1 est si vous avez beaucoup de latence pour parler à votre base de données.
Ariel
17
La jointure et le tri peuvent être assez rapides (car vous vous joindrez à des champs indexés et éventuellement triés). Quelle est la taille de votre «n + 1»? Croyez-vous sérieusement que le problème n + 1 ne s'applique qu'aux connexions de base de données à latence élevée?
tucuxi
9
@ariel - Votre avis selon lequel N + 1 est le "plus rapide" est faux, même si vos repères peuvent être corrects. Comment est-ce possible? Voir en.wikipedia.org/wiki/Anecdotal_evidence , et aussi mon commentaire dans l'autre réponse à cette question.
whitneyland
7
@Ariel - Je pense que je l'ai bien compris :). J'essaie simplement de souligner que votre résultat ne s'applique qu'à un seul ensemble de conditions. Je pourrais facilement construire un contre-exemple qui montrait le contraire. Cela a-t-il du sens?
whitneyland
13
Pour réitérer, le problème de SELECT N + 1 est, à sa base: j'ai 600 enregistrements à récupérer. Est-il plus rapide de les obtenir tous en une seule requête, ou 1 à la fois dans 600 requêtes? À moins que vous ne soyez sur MyISAM et / ou que vous ayez un schéma mal normalisé / mal indexé (auquel cas l'ORM n'est pas le problème), une base de données correctement réglée renverra les 600 lignes en 2 ms, tout en renvoyant les lignes individuelles dans environ 1 ms chacun. Nous voyons donc souvent N + 1 prendre des centaines de millisecondes où une jointure ne prend que quelques couples
Dogs
64

Fournisseur avec une relation un-à-plusieurs avec le produit. Un fournisseur a (fournit) 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 «vrai» (par défaut)

  • Le mode d'extraction utilisé pour interroger le produit est Sélectionner

  • Mode d'extraction (par défaut): accès aux informations du fournisseur

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

  • Accès au fournisseur

Le mode d'extraction est Sélectionner l'extraction (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 déclarations pour le fournisseur

C'est un problème de sélection N + 1!

Summy
la source
3
Est-il censé être 1 sélection pour le fournisseur puis N sélectionne pour le produit?
bencampbell_14
@bencampbell_ Oui, au départ, je ressentais la même chose. Mais alors avec son exemple, c'est un produit pour de nombreux fournisseurs.
Mohd Faizan Khan
38

Je ne peux pas commenter directement les 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 que, historiquement, beaucoup de dbms ont été assez médiocres en ce qui concerne la gestion des jointures (MySQL en étant un exemple particulièrement remarquable). Ainsi, n + 1 a souvent été beaucoup plus rapide qu'une jointure. Et puis il y a des moyens d'améliorer n + 1 mais toujours sans avoir besoin d'une jointure, ce à quoi le problème d'origine se rapporte.

Cependant, MySQL est maintenant bien meilleur qu'il ne l'était quand il s'agit de jointures. Quand j'ai appris MySQL pour la première fois, j'ai beaucoup utilisé les jointures. Ensuite, j'ai découvert à quel point ils sont lents et je suis passé à n + 1 dans le code. Mais, récemment, je suis revenu sur les jointures, car MySQL est maintenant beaucoup plus efficace pour les gérer qu'il ne l'était lorsque j'ai commencé à l'utiliser.

De nos jours, une simple jointure sur un ensemble de tables correctement indexées est rarement un problème, en termes de performances. Et si cela donne un impact 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 MySQL:

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

Donc, le résumé est le suivant: si vous avez évité les jointures dans le passé en raison des performances abominables de MySQL avec elles, essayez à nouveau sur les dernières versions. Vous serez probablement agréablement surpris.

Mark Goodge
la source
7
Appeler les premières versions de MySQL comme un SGBD relationnel est assez complexe ... Si les personnes rencontrant ces problèmes avaient utilisé une vraie base de données, elles n'auraient pas rencontré ce genre de problèmes. ;-)
Craig
2
Fait intéressant, bon nombre de ces types de problèmes ont été résolus dans MySQL avec l'introduction et l'optimisation ultérieure du moteur INNODB, mais vous rencontrerez toujours des gens qui essaient de promouvoir MYISAM parce qu'ils pensent que c'est plus rapide.
Craig
5
Pour info, l'un des 3 JOINalgorithmes couramment utilisés dans les SGBDR est appelé boucles imbriquées. Il s'agit fondamentalement d'une sélection N + 1 sous le capot. La seule différence est que la base de données a fait un choix intelligent pour l'utiliser en fonction des statistiques et des index, plutôt que du code client la forçant catégoriquement sur ce chemin.
Brandon
2
@Brandon Oui! Tout comme les conseils JOIN et les conseils INDEX, forcer un certain chemin d'exécution dans tous les cas battra rarement la base de données. La base de données est presque toujours très, très bonne pour choisir l'approche optimale pour obtenir les données. Peut-être qu'au début du dbs, vous deviez `` formuler '' votre question d'une manière particulière pour amadouer le db, mais après des décennies d'ingénierie de classe mondiale, vous pouvez maintenant obtenir les meilleures performances en posant à votre base de données une question relationnelle et en la laissant trier comment récupérer et assembler ces données pour vous.
Chiens
3
Non seulement la base de données utilise des index et des statistiques, mais toutes les opérations sont également des E / S locales, dont une grande partie fonctionne souvent avec un cache très efficace plutôt qu'avec un disque. Les programmeurs de bases de données consacrent énormément d'attention à l'optimisation de ce genre de choses.
Craig
27

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

for p in person:
    print p.car.colour

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

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

Étape 1: Sélection large

  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

Suck les résultats dans un créateur d'objet générique avec un argument à diviser 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 du fanfolding pour python.

rorycl
la source
10
Je suis tellement content d'avoir trébuché sur votre message, parce que je pensais que je devenais fou. quand j'ai découvert le problème N + 1, ma pensée immédiate était: eh bien, pourquoi ne créez-vous pas simplement une vue qui contient toutes les informations dont vous avez besoin, et retirez-vous de cette vue? vous avez validé ma position. Merci Monsieur.
un développeur
14
Nous nous sommes éloignés de l'ORM à Django à cause de ce problème. Hein? Django a select_related, ce qui est censé résoudre ce problème - en fait, ses documents commencent par un exemple similaire à votre p.car.colourexemple.
Adrian17
8
C'est une vieille réponse, nous l'avons select_related()et prefetch_related()à Django maintenant.
Mariusz Jamro
1
Cool. Mais select_related()et ami ne semble pas faire d'extrapolations évidemment utiles d'une jointure comme LEFT OUTER JOIN. Le problème n'est pas un problème d'interface, mais un problème lié à l'étrange idée que les objets et les données relationnelles sont mappables ... à mon avis.
rorycl
26

Puisque c'est une question très courante, j'ai écrit cet article , sur lequel cette réponse est basée.

Quel est le problème de requête N + 1

Le problème de requête N + 1 se produit lorsque l'infrastructure d'accès aux données a exécuté N instructions SQL supplémentaires pour extraire les mêmes données qui auraient pu être récupérées lors de l'exécution de la requête SQL principale.

Plus la valeur de N est élevée, plus les requêtes seront exécutées, plus l'impact sur les performances sera important. Et, contrairement au journal des requêtes lentes qui peut vous aider à trouver des requêtes à exécution lente, le problème N + 1 ne sera pas localisé car chaque requête supplémentaire individuelle s'exécute suffisamment rapidement pour ne pas déclencher le journal des requêtes lentes.

Le problème est l'exécution d'un grand nombre de requêtes supplémentaires qui, dans l'ensemble, prennent suffisamment de temps pour ralentir le temps de réponse.

Considérons que nous avons les tables de base de données post et post_comments suivantes qui forment une relation de table un-à-plusieurs :

Les tables <code> post </code> et <code> post_comments </code>

Nous allons créer les 4 postlignes suivantes:

INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 1', 1)

INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 2', 2)

INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 3', 3)

INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 4', 4)

Et, nous allons également créer 4 post_commentenregistrements enfants:

INSERT INTO post_comment (post_id, review, id)
VALUES (1, 'Excellent book to understand Java Persistence', 1)

INSERT INTO post_comment (post_id, review, id)
VALUES (2, 'Must-read for Java developers', 2)

INSERT INTO post_comment (post_id, review, id)
VALUES (3, 'Five Stars', 3)

INSERT INTO post_comment (post_id, review, id)
VALUES (4, 'A great reference book', 4)

Problème de requête N + 1 avec SQL simple

Si vous sélectionnez l' post_commentsutilisation de cette requête SQL:

List<Tuple> comments = entityManager.createNativeQuery("""
    SELECT
        pc.id AS id,
        pc.review AS review,
        pc.post_id AS postId
    FROM post_comment pc
    """, Tuple.class)
.getResultList();

Et, plus tard, vous décidez de récupérer les associés post titlepour chacun post_comment:

for (Tuple comment : comments) {
    String review = (String) comment.get("review");
    Long postId = ((Number) comment.get("postId")).longValue();

    String postTitle = (String) entityManager.createNativeQuery("""
        SELECT
            p.title
        FROM post p
        WHERE p.id = :postId
        """)
    .setParameter("postId", postId)
    .getSingleResult();

    LOGGER.info(
        "The Post '{}' got this review '{}'",
        postTitle,
        review
    );
}

Vous allez déclencher le problème de requête N + 1 car, au lieu d'une requête SQL, vous avez exécuté 5 (1 + 4):

SELECT
    pc.id AS id,
    pc.review AS review,
    pc.post_id AS postId
FROM post_comment pc

SELECT p.title FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'

SELECT p.title FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'

SELECT p.title FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'

SELECT p.title FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'

La résolution du problème de requête N + 1 est très facile. Tout ce que vous devez faire est d'extraire toutes les données dont vous avez besoin dans la requête SQL d'origine, comme ceci:

List<Tuple> comments = entityManager.createNativeQuery("""
    SELECT
        pc.id AS id,
        pc.review AS review,
        p.title AS postTitle
    FROM post_comment pc
    JOIN post p ON pc.post_id = p.id
    """, Tuple.class)
.getResultList();

for (Tuple comment : comments) {
    String review = (String) comment.get("review");
    String postTitle = (String) comment.get("postTitle");

    LOGGER.info(
        "The Post '{}' got this review '{}'",
        postTitle,
        review
    );
}

Cette fois, une seule requête SQL est exécutée pour récupérer toutes les données que nous souhaitons utiliser.

Problème de requête N + 1 avec JPA et Hibernate

Lorsque vous utilisez JPA et Hibernate, il existe plusieurs façons de déclencher le problème de requête N + 1, il est donc très important de savoir comment éviter ces situations.

Pour les exemples suivants, considérez que nous mappons les tables postet post_commentsaux entités suivantes:

Entités <code> Post </code> et <code> PostComment </code>

Les mappages JPA ressemblent à ceci:

@Entity(name = "Post")
@Table(name = "post")
public class Post {

    @Id
    private Long id;

    private String title;

    //Getters and setters omitted for brevity
}

@Entity(name = "PostComment")
@Table(name = "post_comment")
public class PostComment {

    @Id
    private Long id;

    @ManyToOne
    private Post post;

    private String review;

    //Getters and setters omitted for brevity
}

FetchType.EAGER

L'utilisation FetchType.EAGERimplicite ou explicite de vos associations JPA est une mauvaise idée car vous allez récupérer bien plus de données dont vous avez besoin. De plus, la FetchType.EAGERstratégie est également sujette à des problèmes de requête N + 1.

Malheureusement, les associations @ManyToOneet @OneToOneutilisent FetchType.EAGERpar défaut, donc si vos mappages ressemblent à ceci:

@ManyToOne
private Post post;

Vous utilisez la FetchType.EAGERstratégie et, chaque fois que vous oubliez de l'utiliser JOIN FETCHlors du chargement de certaines PostCommententités avec une requête API JPQL ou Criteria:

List<PostComment> comments = entityManager
.createQuery("""
    select pc
    from PostComment pc
    """, PostComment.class)
.getResultList();

Vous allez déclencher le problème de requête N + 1:

SELECT 
    pc.id AS id1_1_, 
    pc.post_id AS post_id3_1_, 
    pc.review AS review2_1_ 
FROM 
    post_comment pc

SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4

Notez les instructions SELECT supplémentaires qui sont exécutées car le post association doit être puisée avant de retourner le Listdes PostCommententités.

Contrairement au plan de récupération par défaut que vous utilisez lorsque vous appelez le find méthode de EnrityManager, une requête API JPQL ou Criteria définit un plan explicite que Hibernate ne peut pas modifier en injectant automatiquement un JOIN FETCH. Donc, vous devez le faire manuellement.

Si vous n'aviez pas besoin du post association, vous n'avez pas de chance lors de l'utilisation FetchType.EAGERcar il n'y a aucun moyen d'éviter de la chercher. C'est pourquoi il vaut mieux utiliser FetchType.LAZYpar défaut.

Mais si vous souhaitez utiliser l' postassociation, vous pouvez utiliserJOIN FETCH pour éviter le problème de requête N + 1:

List<PostComment> comments = entityManager.createQuery("""
    select pc
    from PostComment pc
    join fetch pc.post p
    """, PostComment.class)
.getResultList();

for(PostComment comment : comments) {
    LOGGER.info(
        "The Post '{}' got this review '{}'", 
        comment.getPost().getTitle(), 
        comment.getReview()
    );
}

Cette fois, Hibernate exécutera une seule instruction SQL:

SELECT 
    pc.id as id1_1_0_, 
    pc.post_id as post_id3_1_0_, 
    pc.review as review2_1_0_, 
    p.id as id1_0_1_, 
    p.title as title2_0_1_ 
FROM 
    post_comment pc 
INNER JOIN 
    post p ON pc.post_id = p.id

-- The Post 'High-Performance Java Persistence - Part 1' got this review 
-- 'Excellent book to understand Java Persistence'

-- The Post 'High-Performance Java Persistence - Part 2' got this review 
-- 'Must-read for Java developers'

-- The Post 'High-Performance Java Persistence - Part 3' got this review 
-- 'Five Stars'

-- The Post 'High-Performance Java Persistence - Part 4' got this review 
-- 'A great reference book'

Pour plus de détails sur les raisons pour lesquelles vous devriez éviter la FetchType.EAGERstratégie de récupération, consultez également cet article .

FetchType.LAZY

Même si vous passez à l'utilisation FetchType.LAZY explicite pour toutes les associations, vous pouvez toujours rencontrer le problème N + 1.

Cette fois, l' postassociation est cartographiée comme suit:

@ManyToOne(fetch = FetchType.LAZY)
private Post post;

Maintenant, lorsque vous récupérez les PostCommententités:

List<PostComment> comments = entityManager
.createQuery("""
    select pc
    from PostComment pc
    """, PostComment.class)
.getResultList();

Hibernate exécutera une seule instruction SQL:

SELECT 
    pc.id AS id1_1_, 
    pc.post_id AS post_id3_1_, 
    pc.review AS review2_1_ 
FROM 
    post_comment pc

Mais, si après, vous allez référencer l'association paresseuse post:

for(PostComment comment : comments) {
    LOGGER.info(
        "The Post '{}' got this review '{}'", 
        comment.getPost().getTitle(), 
        comment.getReview()
    );
}

Vous obtiendrez le problème de requête N + 1:

SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review 
-- 'Excellent book to understand Java Persistence'

SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review 
-- 'Must-read for Java developers'

SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review 
-- 'Five Stars'

SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review 
-- 'A great reference book'

Parce que le post association est extraite paresseusement, une instruction SQL secondaire sera exécutée lors de l'accès à l'association paresseuse afin de générer le message de journal.

Encore une fois, le correctif consiste à ajouter une JOIN FETCHclause à la requête JPQL:

List<PostComment> comments = entityManager.createQuery("""
    select pc
    from PostComment pc
    join fetch pc.post p
    """, PostComment.class)
.getResultList();

for(PostComment comment : comments) {
    LOGGER.info(
        "The Post '{}' got this review '{}'", 
        comment.getPost().getTitle(), 
        comment.getReview()
    );
}

Et, tout comme dans le FetchType.EAGER exemple, cette requête JPQL générera une seule instruction SQL.

Même si vous utilisez FetchType.LAZY et ne faites pas référence à l'association enfant d'une @OneToOnerelation JPA bidirectionnelle , vous pouvez toujours déclencher le problème de requête N + 1.

Pour plus de détails sur la façon de résoudre le problème de requête N + 1 généré par les @OneToOneassociations, consultez cet article .

Comment détecter automatiquement le problème de requête N + 1

Si vous souhaitez détecter automatiquement le problème de requête N + 1 dans votre couche d'accès aux données, cet article explique comment procéder à l'aide de ladb-util projet open-source.

Tout d'abord, vous devez ajouter la dépendance Maven suivante:

<dependency>
    <groupId>com.vladmihalcea</groupId>
    <artifactId>db-util</artifactId>
    <version>${db-util.version}</version>
</dependency>

Ensuite, il vous suffit d'utiliser l' SQLStatementCountValidatorutilitaire pour affirmer les instructions SQL sous-jacentes générées:

SQLStatementCountValidator.reset();

List<PostComment> comments = entityManager.createQuery("""
    select pc
    from PostComment pc
    """, PostComment.class)
.getResultList();

SQLStatementCountValidator.assertSelectCount(1);

Si vous utilisez FetchType.EAGERet exécutez le scénario de test ci-dessus, vous obtiendrez l'échec du scénario de test suivant:

SELECT 
    pc.id as id1_1_, 
    pc.post_id as post_id3_1_, 
    pc.review as review2_1_ 
FROM 
    post_comment pc

SELECT p.id as id1_0_0_, p.title as title2_0_0_ FROM post p WHERE p.id = 1

SELECT p.id as id1_0_0_, p.title as title2_0_0_ FROM post p WHERE p.id = 2


-- SQLStatementCountMismatchException: Expected 1 statement(s) but recorded 3 instead!

Pour plus de détails sur le db-utilprojet open-source, consultez cet article .

Vlad Mihalcea
la source
Mais maintenant, vous avez un problème de pagination. Si vous avez 10 voitures, chaque voiture à 4 roues et vous souhaitez paginer les voitures avec 5 voitures par page. Donc, vous avez essentiellement SELECT cars, wheels FROM cars JOIN wheels LIMIT 0, 5. Mais ce que vous obtenez est 2 voitures à 5 roues (première voiture avec les 4 roues et deuxième voiture avec seulement 1 roue), car LIMIT limitera l'ensemble des résultats, pas seulement la clause racine.
CappY
2
J'ai aussi un article pour ça.
Vlad Mihalcea
Merci pour l'article. Je vais le lire. Par défilement rapide - j'ai vu que la solution est Window Function, mais ils sont assez nouveaux dans MariaDB - donc le problème persiste dans les anciennes versions. :)
CappY
@VladMihalcea, je l'ai souligné à partir de votre article ou du post chaque fois que vous faites référence au cas ManyToOne tout en expliquant le problème N + 1. Mais en fait, les gens sont principalement intéressés par le cas OneToMany concernant le problème N + 1. Pourriez-vous s'il vous plaît vous référer et expliquer le cas OneToMany?
JJ Beam
18

Supposons que vous ayez une ENTREPRISE et un EMPLOYÉ. L'ENTREPRISE a de nombreux EMPLOYÉS (c'est-à-dire que l'EMPLOYÉ a un champ COMPANY_ID).

Dans certaines configurations O / R, lorsque vous avez un objet Company mappé et accédez à ses objets Employee, l'outil O / R effectue une sélection pour chaque employé, alors que si vous faisiez simplement des choses en SQL simple, vous le pouviez select * from employees where company_id = XX. Ainsi N (nombre d'employés) plus 1 (entreprise)

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

davetron5000
la source
18

Voici une bonne description du problème

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

Joe Dean
la source
17

Consultez l'article d'Ayende sur le sujet: Combattre le problème Select N + 1 dans NHibernate .

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

Plus d'appels de requête de base de données → plus de temps de latence → baisse des performances de l'application / base de données.

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

Nathan
la source
3
les jointures ne sont pas une bonne solution (souvent), car elles peuvent entraîner un produit cartésien, ce qui signifie que le nombre de lignes de résultat est le nombre de résultats de la table racine multiplié par le nombre de résultats dans chaque table enfant. particulièrement mauvais sur plusieurs niveaux d'hérarchie. La sélection de 20 "blogs" avec 100 "articles" sur chacun et 10 "commentaires" sur chaque article entraînera 20000 lignes de résultats. NHibernate propose des solutions de contournement, comme la "taille de lot" (sélectionnez les enfants avec une clause sur les identifiants parent) ou la "sous-sélection".
Erik Hart
14

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.

jj_
la source
13

À mon avis, l'article écrit dans Hibernate Pitfall: Pourquoi les relations devraient être paresseuses est exactement à l'opposé du vrai problème N + 1.

Si vous avez besoin d'une explication correcte, reportez-vous à Hibernate - Chapitre 19: Amélioration des performances - Stratégies d'extraction

La récupération par sélection (par défaut) est extrêmement vulnérable aux problèmes de sélection N + 1, donc nous pourrions vouloir activer la récupération par jointure

Anoop Isaac
la source
2
j'ai lu la page d'hibernation. Il ne dit pas quel est réellement le problème de sélection N + 1 . Mais il dit que vous pouvez utiliser des jointures pour le corriger.
Ian Boyd
3
la taille de lot est requise pour l'extraction de sélection, pour sélectionner des objets enfants pour plusieurs parents dans une instruction de sélection. La sous-sélection pourrait être une autre alternative. Les jointures peuvent devenir vraiment mauvaises si vous avez plusieurs niveaux de hiérarchie et qu'un produit cartésien est créé.
Erik Hart
10

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

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


la source
9

Un millionnaire possède 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 s'insèrent dans la RAM.

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

Supposons que les index ne rentrent pas dans le ram.

Coûts supplémentaires dans le pire des cas 1 + N accès à la plaque pour l'indice de chargement.

Sommaire

Le col de la bouteille est un accès à la plaque (environ 70 fois par seconde, un accès aléatoire sur le disque dur). Une sélection de jointure désireuse accède également à la plaque 1 + N + (N * 4) fois pour la charge utile. Donc, si les index s'insèrent dans ram - pas de problème, c'est assez rapide car seules les opérations de ram sont impliquées.

Hans Wurst
la source
9

Le problème de sélection N + 1 est une douleur, et il est logique de détecter de tels cas dans des 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 JUnit spéciale à 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
}
bédrine
la source
5

Le problème, comme d'autres l'ont dit plus élégamment, est que vous avez un produit cartésien des colonnes OneToMany ou que vous effectuez des sélections N + 1. Soit un ensemble de résultats gigantesque possible ou bavard 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 une table d'ID semi-temporaire . Je le fais également lorsque vous avez la IN ()limitation de clause .

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 OneToManycolonnes, le nombre de résultats sera un multiplication des colonnes) et son plus d'un travail de type batch.

Vous insérez d'abord vos ID d'objet parent en tant que lot dans une table d'ID. 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 OneToManycolonne, vous venez de faire un SELECTsur la table des identifiants de la table INNER JOINenfant avec un WHERE batch_id=(ou vice versa). Vous voulez simplement vous assurer que vous triez par la colonne id car cela facilitera la fusion des colonnes de résultats (sinon vous aurez besoin d'un HashMap / Table pour l'ensemble des résultats, ce qui n'est peut-être pas si mauvais).

Ensuite, vous nettoyez périodiquement la table ids.

Cela fonctionne également particulièrement bien si l'utilisateur sélectionne environ 100 éléments distincts pour une sorte de traitement en masse. Mettez les 100 identifiants distincts dans la table temporaire.

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

Adam Gent
la source
1

Prenons l'exemple de Matt Solnit, imaginez que vous définissez une association entre la voiture et les roues comme LAZY et que vous ayez besoin de certains champs Wheels. Cela signifie qu'après la première sélection, l'hibernation va faire "Select * from Wheels où car_id =: id" POUR CHAQUE voiture.

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

Pour éviter cela, rendez l'association extraite comme désireuse, afin que l'hibernation charge les données avec une jointure.

Mais attention, si plusieurs fois vous n'accédez pas aux roues associées, il est préférable de le garder paresseux ou de changer le type de récupération avec les critères.

martins.tuga
la source
1
Encore une fois, les jointures ne sont pas une bonne solution, surtout lorsque plus de 2 niveaux de hiérarchie peuvent être chargés. Cochez plutôt "subselect" ou "batch-size"; le dernier chargera les enfants par ID parent dans la clause "in", comme "select ... from wheels where car_id in (1,3,4,6,7,8,11,13)".
Erik Hart