Was ist das „N + 1-Auswahlproblem“ in ORM (Object-Relational Mapping)?

1597

Das "N + 1-Auswahlproblem" wird in ORM-Diskussionen (Object-Relational Mapping) allgemein als Problem angegeben, und ich verstehe, dass es etwas damit zu tun hat, dass viele Datenbankabfragen für etwas durchgeführt werden müssen, das im Objekt einfach erscheint Welt.

Hat jemand eine detailliertere Erklärung des Problems?

Lars A. Brekken
quelle
2
Dies ist ein großartiger Link mit einer netten Erklärung zum Verständnis des n + 1- Problems. Es werden auch die Lösungen behandelt, um diesem Problem entgegenzuwirken: Architects.dzone.com/articles/how-identify-and-resilve-n1
aces.
Es gibt einige hilfreiche Beiträge, die sich mit diesem Problem und der möglichen Lösung befassen. Häufige Anwendungsprobleme und deren Behebung: Das Select N + 1-Problem , das (silberne) Aufzählungszeichen für das N + 1-Problem , Lazy Loading
Eifriges
Für alle, die nach einer Lösung für dieses Problem suchen, habe ich einen Beitrag gefunden, der es beschreibt. stackoverflow.com/questions/32453989/…
damndemon
2
Sollte dies in Anbetracht der Antworten nicht als 1 + N-Problem bezeichnet werden? Da dies eine Terminologie zu sein scheint, frage ich OP nicht speziell.
user1418717

Antworten:

1017

Angenommen, Sie haben eine Sammlung von CarObjekten (Datenbankzeilen) und jede Carhat eine Sammlung von WheelObjekten (auch Zeilen). Mit anderen Worten, CarWheelist eine 1-zu-viele-Beziehung.

Angenommen, Sie müssen alle Autos durchlaufen und für jedes eine Liste der Räder ausdrucken. Die naive O / R-Implementierung würde Folgendes bewirken:

SELECT * FROM Cars;

Und dann für jeden Car:

SELECT * FROM Wheel WHERE CarId = ?

Mit anderen Worten, Sie haben eine Auswahl für die Autos und dann N zusätzliche Auswahlen, wobei N die Gesamtzahl der Autos ist.

Alternativ könnte man alle Räder bekommen und die Suchvorgänge im Speicher durchführen:

SELECT * FROM Wheel

Dies reduziert die Anzahl der Roundtrips zur Datenbank von N + 1 auf 2. Die meisten ORM-Tools bieten verschiedene Möglichkeiten, um die Auswahl von N + 1 zu verhindern.

Referenz: Java-Persistenz im Ruhezustand , Kapitel 13.

Matt Solnit
quelle
140
Um das "Das ist schlecht" zu verdeutlichen, könnten Sie alle Räder mit 1 select ( SELECT * from Wheel;) anstelle von N + 1 erhalten. Mit einem großen N kann der Leistungstreffer sehr signifikant sein.
Tucuxi
212
@tucuxi Ich bin überrascht, dass du so viele positive Stimmen dafür bekommen hast, dass du falsch liegst. Eine Datenbank ist sehr gut in Bezug auf Indizes. Die Abfrage nach einer bestimmten CarID würde sehr schnell zurückkehren. Wenn Sie jedoch alle Räder einmal haben, müssten Sie in Ihrer Anwendung nach CarID suchen, die nicht indiziert ist. Dies ist langsamer. Es sei denn, Sie haben größere Latenzprobleme, wenn Sie Ihre Datenbank erreichen. N + 1 ist tatsächlich schneller - und ja, ich habe sie mit einer Vielzahl von Code aus der realen Welt verglichen.
Ariel
74
@ariel Der 'richtige' Weg besteht darin, alle von CarId geordneten Räder abzurufen (1 Auswahl). Wenn mehr Details als die CarId erforderlich sind, führen Sie eine zweite Abfrage für alle Fahrzeuge durch (insgesamt 2 Abfragen). Das Ausdrucken von Dingen ist jetzt optimal, und es waren keine Indizes oder sekundärer Speicher erforderlich (Sie können die Ergebnisse durchlaufen, ohne sie alle herunterladen zu müssen). Sie haben das Falsche gemessen. Wenn Sie immer noch von Ihren Benchmarks überzeugt sind, würde es Ihnen etwas ausmachen, einen längeren Kommentar (oder eine vollständige Antwort) zu veröffentlichen, in dem Ihr Experiment und Ihre Ergebnisse erläutert werden?
Tucuxi
92
"Der Ruhezustand (ich bin mit den anderen ORM-Frameworks nicht vertraut) bietet Ihnen verschiedene Möglichkeiten, damit umzugehen." und so sind?
Tima
58
@Ariel Versuchen Sie, Ihre Benchmarks mit Datenbank- und Anwendungsservern auf separaten Computern auszuführen. Nach meiner Erfahrung kosten Roundtrips zur Datenbank mehr Overhead als die Abfrage selbst. Also ja, die Anfragen sind sehr schnell, aber es sind die Rundreisen, die Chaos anrichten. Ich habe "WHERE Id = const " in "WHERE Id IN ( const , const , ...)" konvertiert und Größenordnungen erhalten.
Hans
110
SELECT 
table1.*
, table2.*
INNER JOIN table2 ON table2.SomeFkId = table1.SomeId

Dadurch erhalten Sie eine Ergebnismenge, in der untergeordnete Zeilen in Tabelle2 zu Duplikaten führen, indem die Ergebnisse von Tabelle1 für jede untergeordnete Zeile in Tabelle2 zurückgegeben werden. O / R-Mapper sollten Tabellen1-Instanzen anhand eines eindeutigen Schlüsselfelds unterscheiden und dann alle Spalten der Tabelle2 verwenden, um untergeordnete Instanzen zu füllen.

SELECT table1.*

SELECT table2.* WHERE SomeFkId = #

Bei N + 1 füllt die erste Abfrage das primäre Objekt und die zweite Abfrage alle untergeordneten Objekte für jedes der zurückgegebenen eindeutigen primären Objekte.

Erwägen:

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

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

und Tabellen mit einer ähnlichen Struktur. Eine einzelne Abfrage für die Adresse "22 Valley St" kann Folgendes zurückgeben:

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

Das O / RM sollte eine Instanz von Home mit ID = 1, Address = "22 Valley St" füllen und dann das Array "Inhabitants" mit People-Instanzen für Dave, John und Mike mit nur einer Abfrage füllen.

Eine N + 1-Abfrage für dieselbe oben verwendete Adresse würde Folgendes ergeben:

Id Address
1  22 Valley St

mit einer separaten Abfrage wie

SELECT * FROM Person WHERE HouseId = 1

und was zu einem separaten Datensatz wie

Name    HouseId
Dave    1
John    1
Mike    1

und das Endergebnis ist das gleiche wie oben mit der einzelnen Abfrage.

Die Vorteile von Single Select bestehen darin, dass Sie alle Daten im Voraus erhalten, was letztendlich das sein kann, was Sie sich wünschen. Der Vorteil von N + 1 besteht darin, dass die Komplexität der Abfragen verringert wird und Sie das verzögerte Laden verwenden können, wenn die untergeordneten Ergebnismengen nur bei der ersten Anforderung geladen werden.

cfeduke
quelle
4
Der andere Vorteil von n + 1 ist, dass es schneller ist, da die Datenbank die Ergebnisse direkt aus einem Index zurückgeben kann. Für den Join und das anschließende Sortieren ist eine temporäre Tabelle erforderlich, die langsamer ist. Der einzige Grund, n + 1 zu vermeiden, besteht darin, dass Sie mit Ihrer Datenbank viel Latenz haben.
Ariel
17
Das Verknüpfen und Sortieren kann sehr schnell erfolgen (da Sie in indizierten und möglicherweise sortierten Feldern beitreten). Wie groß ist dein 'n + 1'? Glauben Sie ernsthaft, dass das n + 1-Problem nur für Datenbankverbindungen mit hoher Latenz gilt?
Tucuxi
9
@ariel - Ihr Rat, dass N + 1 das "schnellste" ist, ist falsch, obwohl Ihre Benchmarks möglicherweise korrekt sind. Wie ist das möglich? Siehe en.wikipedia.org/wiki/Anecdotal_evidence und auch meinen Kommentar in der anderen Antwort auf diese Frage.
Whitneyland
7
@Ariel - Ich denke ich habe es gut verstanden :). Ich möchte nur darauf hinweisen, dass Ihr Ergebnis nur für eine Reihe von Bedingungen gilt. Ich könnte leicht ein Gegenbeispiel konstruieren, das das Gegenteil zeigt. Ist das sinnvoll?
Whitneyland
13
Um es noch einmal zu wiederholen, das SELECT N + 1-Problem besteht im Kern darin, dass ich 600 Datensätze abrufen muss. Ist es schneller, alle 600 in einer Abfrage oder jeweils 1 in 600 Abfragen zu erhalten? Sofern Sie sich nicht in MyISAM befinden und / oder ein schlecht normalisiertes / schlecht indiziertes Schema haben (in diesem Fall ist das ORM nicht das Problem), gibt eine ordnungsgemäß abgestimmte Datenbank die 600 Zeilen in 2 ms zurück, während die einzelnen Zeilen in zurückgegeben werden jeweils ca. 1 ms. So sehen wir oft, dass N + 1 Hunderte von Millisekunden dauert, während ein Join nur ein paar dauert
Dogs
64

Lieferant mit einer Eins-zu-Viele-Beziehung zum Produkt. Ein Lieferant hat (liefert) viele Produkte.

***** 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      |
+-----+-----------+--------------------+-------+------------+

Faktoren:

  • Lazy-Modus für Lieferanten auf "true" gesetzt (Standard)

  • Der für die Abfrage des Produkts verwendete Abrufmodus ist Auswählen

  • Abrufmodus (Standard): Auf Lieferanteninformationen wird zugegriffen

  • Caching spielt zum ersten Mal keine Rolle

  • Auf den Lieferanten wird zugegriffen

Der Abrufmodus ist Select Fetch (Standard).

// 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=?

Ergebnis:

  • 1 select-Anweisung für Produkt
  • N Anweisungen für Lieferanten auswählen

Dies ist N + 1 Auswahlproblem!

Summy
quelle
3
Soll es 1 Auswahl für den Lieferanten sein, dann N Auswahl für das Produkt?
Bencampbell_14
@bencampbell_ Ja, anfangs ging es mir genauso. Aber mit seinem Beispiel ist es ein Produkt für viele Lieferanten.
Mohd Faizan Khan
38

Ich kann andere Antworten nicht direkt kommentieren, weil ich nicht genug Ruf habe. Es ist jedoch anzumerken, dass das Problem im Wesentlichen nur auftritt, weil in der Vergangenheit viele DBMS beim Umgang mit Joins ziemlich schlecht waren (MySQL ist ein besonders bemerkenswertes Beispiel). Daher war n + 1 oft deutlich schneller als ein Join. Und dann gibt es Möglichkeiten, n + 1 zu verbessern, aber immer noch ohne Join, worauf sich das ursprüngliche Problem bezieht.

Allerdings ist MySQL jetzt viel besser als früher, wenn es um Joins geht. Als ich MySQL zum ersten Mal lernte, habe ich viel Joins verwendet. Dann entdeckte ich, wie langsam sie sind, und wechselte stattdessen im Code zu n + 1. Aber in letzter Zeit bin ich wieder zu Joins zurückgekehrt, weil MySQL jetzt viel besser damit umgehen kann als zu Beginn meiner Verwendung.

Heutzutage ist ein einfacher Join für einen ordnungsgemäß indizierten Satz von Tabellen in Bezug auf die Leistung selten ein Problem. Und wenn es einen Leistungseinbruch gibt, werden sie durch die Verwendung von Indexhinweisen häufig gelöst.

Dies wird hier von einem der MySQL-Entwicklungsteams besprochen:

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

Die Zusammenfassung lautet also: Wenn Sie in der Vergangenheit Joins aufgrund der miserablen Leistung von MySQL vermieden haben, versuchen Sie es erneut mit den neuesten Versionen. Sie werden wahrscheinlich angenehm überrascht sein.

Mark Goodge
quelle
7
Frühere Versionen von MySQL als relationales DBMS zu bezeichnen, ist ziemlich schwierig ... Wenn Leute, die auf diese Probleme stoßen, eine echte Datenbank verwendet hätten, wären sie auf solche Probleme nicht gestoßen. ;-)
Craig
2
Interessanterweise wurden viele dieser Arten von Problemen in MySQL mit der Einführung und anschließenden Optimierung der INNODB-Engine gelöst, aber Sie werden immer noch auf Leute stoßen, die versuchen, MYISAM zu bewerben, weil sie denken, dass es schneller ist.
Craig
5
Zu Ihrer Information, einer der drei JOINin RDBMS verwendeten Algorithmen wird als verschachtelte Schleifen bezeichnet. Grundsätzlich handelt es sich um eine N + 1-Auswahl unter der Haube. Der einzige Unterschied besteht darin, dass die Datenbank eine intelligente Entscheidung getroffen hat, sie basierend auf Statistiken und Indizes zu verwenden, anstatt dass Client-Code sie kategorisch auf diesen Pfad zwingt.
Brandon
2
@Brandon Ja! Ähnlich wie bei JOIN-Hinweisen und INDEX-Hinweisen wird das Erzwingen eines bestimmten Ausführungspfads in allen Fällen die Datenbank selten schlagen. Die Datenbank ist fast immer sehr, sehr gut darin, den optimalen Ansatz zum Abrufen der Daten zu wählen. Vielleicht mussten Sie in den frühen Tagen von dbs Ihre Frage auf eine eigenartige Weise "formulieren", um die db zu überreden, aber nach Jahrzehnten von Weltklasse-Engineering können Sie jetzt die beste Leistung erzielen, indem Sie Ihrer Datenbank eine relationale Frage stellen und sie zulassen Finden Sie heraus, wie Sie diese Daten für Sie abrufen und zusammenstellen können.
Hunde
3
Die Datenbank verwendet nicht nur Indizes und Statistiken, sondern alle Vorgänge sind auch lokale E / A-Vorgänge, von denen viele häufig eher mit hocheffizientem Cache als mit Festplatte ausgeführt werden. Die Datenbankprogrammierer widmen der Optimierung dieser Art von Dingen sehr viel Aufmerksamkeit.
Craig
27

Wir sind wegen dieses Problems vom ORM in Django weggezogen. Grundsätzlich, wenn Sie versuchen und tun

for p in person:
    print p.car.colour

Das ORM gibt gerne alle Personen zurück (normalerweise als Instanzen eines Personenobjekts), muss dann jedoch die Autotabelle für jede Person abfragen.

Ein einfacher und sehr effektiver Ansatz ist etwas, das ich " Fanfolding " nenne , wodurch die unsinnige Idee vermieden wird, dass Abfrageergebnisse aus einer relationalen Datenbank wieder den ursprünglichen Tabellen zugeordnet werden sollten, aus denen die Abfrage besteht.

Schritt 1: Große Auswahl

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

Dies wird so etwas wie zurückgeben

  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

Schritt 2: Objektivieren

Saugen Sie die Ergebnisse in einen generischen Objektersteller mit einem Argument, das nach dem dritten Element aufgeteilt werden soll. Dies bedeutet, dass das Objekt "jones" nicht mehr als einmal erstellt wird.

Schritt 3: Rendern

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

Auf dieser Webseite finden Sie eine Implementierung von Fanfolding für Python.

rorycl
quelle
10
Ich bin so froh, dass ich über deinen Beitrag gestolpert bin, weil ich dachte, ich würde verrückt. Als ich von dem N + 1-Problem erfuhr, dachte ich sofort: Warum erstellen Sie nicht einfach eine Ansicht, die alle benötigten Informationen enthält, und ziehen sie aus dieser Ansicht heraus? Sie haben meine Position bestätigt. Danke mein Herr.
Ein Entwickler
14
Wir sind wegen dieses Problems vom ORM in Django weggezogen. Huh? Django hat select_related, was dieses Problem lösen soll - tatsächlich beginnen seine Dokumente mit einem Beispiel, das Ihrem p.car.colourBeispiel ähnlich ist .
Adrian17
8
Dies ist eine alte anwswer, wir haben select_related()und prefetch_related()in Django jetzt.
Mariusz Jamro
1
Cool. Aber select_related()und Freund scheinen keine der offensichtlich nützlichen Extrapolationen eines Joins wie z LEFT OUTER JOIN. Das Problem ist kein Schnittstellenproblem, sondern ein Problem, das mit der seltsamen Idee zu tun hat, dass Objekte und relationale Daten meiner Ansicht nach abgebildet werden können.
Rorycl
26

Da dies eine sehr häufige Frage ist, habe ich diesen Artikel geschrieben , auf dem diese Antwort basiert.

Was ist das N + 1-Abfrageproblem?

Das N + 1-Abfrageproblem tritt auf, wenn das Datenzugriffsframework N zusätzliche SQL-Anweisungen ausführt, um dieselben Daten abzurufen, die beim Ausführen der primären SQL-Abfrage abgerufen werden könnten.

Je größer der Wert von N ist, desto mehr Abfragen werden ausgeführt, desto größer ist die Auswirkung auf die Leistung. Und im Gegensatz zum langsamen Abfrageprotokoll , mit dem Sie langsam laufende Abfragen finden können, ist das N + 1-Problem nicht erkennbar, da jede einzelne zusätzliche Abfrage so schnell ausgeführt wird, dass das langsame Abfrageprotokoll nicht ausgelöst wird.

Das Problem besteht darin, eine große Anzahl zusätzlicher Abfragen auszuführen, die insgesamt ausreichend Zeit benötigen, um die Antwortzeit zu verlangsamen.

Nehmen wir an, wir haben die folgenden Post- und Post_comments-Datenbanktabellen, die eine Eins-zu-Viele-Tabellenbeziehung bilden :

Die Tabellen <code> post </ code> und <code> post_comments </ code>

Wir werden die folgenden 4 postZeilen erstellen :

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)

Außerdem erstellen wir 4 post_commentuntergeordnete Datensätze:

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)

N + 1-Abfrageproblem mit einfachem SQL

Wenn Sie die post_commentsVerwendung dieser SQL-Abfrage auswählen :

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();

Und später entscheiden Sie sich, die zugehörigen post titlefür jeden abzurufen 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
    );
}

Sie werden das Problem mit der N + 1-Abfrage auslösen, da Sie anstelle einer SQL-Abfrage 5 (1 + 4) ausgeführt haben:

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'

Das Beheben des N + 1-Abfrageproblems ist sehr einfach. Sie müssen lediglich alle Daten extrahieren, die Sie in der ursprünglichen SQL-Abfrage benötigen:

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
    );
}

Dieses Mal wird nur eine SQL-Abfrage ausgeführt, um alle Daten abzurufen, an deren Verwendung wir weiter interessiert sind.

N + 1-Abfrageproblem mit JPA und Ruhezustand

Bei Verwendung von JPA und Hibernate gibt es verschiedene Möglichkeiten, das N + 1-Abfrageproblem auszulösen. Daher ist es sehr wichtig zu wissen, wie Sie diese Situationen vermeiden können.

Betrachten Sie für die nächsten Beispiele, dass wir die postund post_comments-Tabellen den folgenden Entitäten zuordnen:

<code> Post </ code> und <code> PostComment </ code>

Die JPA-Zuordnungen sehen folgendermaßen aus:

@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

Die FetchType.EAGERimplizite oder explizite Verwendung für Ihre JPA-Zuordnungen ist eine schlechte Idee, da Sie viel mehr Daten abrufen werden, als Sie benötigen. Darüber hinaus ist die FetchType.EAGERStrategie auch anfällig für N + 1-Abfrageprobleme.

Leider werden die @ManyToOneund @OneToOneAssoziationen FetchType.EAGERstandardmäßig verwendet. Wenn Ihre Zuordnungen also folgendermaßen aussehen:

@ManyToOne
private Post post;

Sie verwenden die FetchType.EAGERStrategie und jedes Mal, JOIN FETCHwenn Sie vergessen, sie beim Laden einiger PostCommentEntitäten mit einer JPQL- oder Kriterien-API-Abfrage zu verwenden:

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

Sie werden das N + 1-Abfrageproblem auslösen:

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

Beachten Sie die zusätzlichen SELECT - Anweisungen , die ausgeführt werden , weil die postVereinigung der auf die Rückkehr geholt , bevor sein muss Listvon PostCommentEinheiten.

Im Gegensatz zum Standardabrufplan, den Sie beim Aufrufen der findMethode von verwenden EnrityManager, definiert eine JPQL- oder Kriterien-API-Abfrage einen expliziten Plan, den Hibernate nicht durch automatisches Einfügen eines JOIN FETCH ändern kann. Sie müssen dies also manuell tun.

Wenn Sie die postZuordnung überhaupt nicht benötigt haben , haben Sie bei der Verwendung FetchType.EAGERkein Glück, da es nicht möglich ist, das Abrufen zu vermeiden. Deshalb ist es besser, FetchType.LAZYstandardmäßig zu verwenden.

Wenn Sie jedoch die postZuordnung verwenden möchten, können Sie JOIN FETCHdas N + 1-Abfrageproblem umgehen:

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()
    );
}

Dieses Mal führt Hibernate eine einzelne SQL-Anweisung aus:

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'

Weitere Informationen darüber, warum Sie die FetchType.EAGERAbrufstrategie vermeiden sollten , finden Sie auch in diesem Artikel .

FetchType.LAZY

Selbst wenn Sie FetchType.LAZYexplizit für alle Zuordnungen verwenden, können Sie dennoch auf das Problem N + 1 stoßen.

Diesmal wird die postZuordnung folgendermaßen zugeordnet:

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

Wenn Sie nun die PostCommentEntitäten abrufen:

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

Der Ruhezustand führt eine einzelne SQL-Anweisung aus:

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

Wenn Sie sich danach darauf beziehen, verweisen Sie auf die faul geladene postZuordnung:

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

Sie erhalten das Problem mit der N + 1-Abfrage:

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'

Da die postZuordnung träge abgerufen wird, wird beim Zugriff auf die verzögerte Zuordnung eine sekundäre SQL-Anweisung ausgeführt, um die Protokollnachricht zu erstellen.

Das Update besteht wiederum darin JOIN FETCH, der JPQL-Abfrage eine Klausel hinzuzufügen :

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()
    );
}

Und genau wie im FetchType.EAGERBeispiel generiert diese JPQL-Abfrage eine einzelne SQL-Anweisung.

Selbst wenn Sie FetchType.LAZYdie untergeordnete Zuordnung einer bidirektionalen @OneToOneJPA-Beziehung verwenden und nicht darauf verweisen , können Sie dennoch das N + 1-Abfrageproblem auslösen.

Weitere Informationen dazu, wie Sie das durch @OneToOneAssoziationen verursachte N + 1-Abfrageproblem lösen können , finden Sie in diesem Artikel .

So erkennen Sie das N + 1-Abfrageproblem automatisch

Wenn Sie ein N + 1-Abfrageproblem in Ihrer Datenzugriffsebene automatisch erkennen möchten, wird in diesem Artikel erläutert, wie Sie dies mithilfe des db-utilOpen-Source-Projekts tun können .

Zunächst müssen Sie die folgende Maven-Abhängigkeit hinzufügen:

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

Danach müssen Sie nur noch das SQLStatementCountValidatorDienstprogramm verwenden, um die zugrunde liegenden SQL-Anweisungen zu aktivieren, die generiert werden:

SQLStatementCountValidator.reset();

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

SQLStatementCountValidator.assertSelectCount(1);

Wenn Sie FetchType.EAGERden obigen Testfall verwenden und ausführen, wird der folgende Testfallfehler angezeigt:

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!

Weitere Informationen zum db-utilOpen Source-Projekt finden Sie in diesem Artikel .

Vlad Mihalcea
quelle
Aber jetzt haben Sie ein Problem mit der Paginierung. Wenn Sie 10 Autos haben, jedes Auto mit 4 Rädern und Sie möchten Autos mit 5 Autos pro Seite paginieren. Sie haben also im Grunde genommen SELECT cars, wheels FROM cars JOIN wheels LIMIT 0, 5. Sie erhalten jedoch 2 Autos mit 5 Rädern (erstes Auto mit allen 4 Rädern und zweites Auto mit nur 1 Rad), da LIMIT die gesamte Ergebnismenge und nicht nur die Root-Klausel einschränkt.
CappY
2
Ich habe auch einen Artikel dafür.
Vlad Mihalcea
Vielen Dank für den Artikel. Ich werde es lesen. Durch schnelles Scrollen - ich habe gesehen, dass die Lösung die Fensterfunktion ist, aber sie sind in MariaDB ziemlich neu - so dass das Problem in älteren Versionen weiterhin besteht. :)
CappY
@VladMihalcea, ich habe jedes Mal, wenn Sie sich auf den Fall ManyToOne beziehen, entweder aus Ihrem Artikel oder aus dem Beitrag darauf hingewiesen, während Sie das N + 1-Problem erläuterten. Aber tatsächlich interessieren sich die Leute hauptsächlich für den OneToMany-Fall in Bezug auf das N + 1-Problem. Könnten Sie bitte den OneToMany-Fall verweisen und erklären?
JJ Beam
18

Angenommen, Sie haben UNTERNEHMEN und MITARBEITER. UNTERNEHMEN hat viele MITARBEITER (dh MITARBEITER hat ein Feld UNTERNEHMEN_ID).

In einigen O / R-Konfigurationen führt das O / R-Tool eine Auswahl für jeden Mitarbeiter durch, wenn Sie ein zugeordnetes Unternehmensobjekt haben und auf dessen Mitarbeiterobjekte zugreifen. Wenn Sie nur in reinem SQL arbeiten, können Sie dies tun select * from employees where company_id = XX. Also N (Anzahl der Mitarbeiter) plus 1 (Unternehmen)

So funktionierten die ersten Versionen von EJB Entity Beans. Ich glaube, Dinge wie Hibernate haben dies beseitigt, aber ich bin mir nicht sicher. Die meisten Tools enthalten normalerweise Informationen zu ihrer Strategie für die Zuordnung.

davetron5000
quelle
18

Hier ist eine gute Beschreibung des Problems

Nachdem Sie das Problem verstanden haben, können Sie es normalerweise vermeiden, indem Sie in Ihrer Abfrage einen Join-Abruf durchführen. Dies erzwingt im Wesentlichen das Abrufen des verzögert geladenen Objekts, sodass die Daten in einer Abfrage anstelle von n + 1 Abfragen abgerufen werden. Hoffe das hilft.

Joe Dean
quelle
17

Überprüfen Sie den Ayende-Beitrag zum Thema: Bekämpfung des Select N + 1-Problems in NHibernate .

Wenn Sie ein ORM wie NHibernate oder EntityFramework verwenden und eine Eins-zu-Viele-Beziehung (Master-Detail) haben und alle Details für jeden Master-Datensatz auflisten möchten, müssen Sie grundsätzlich N + 1 Abfrageaufrufe an die Datenbank, wobei "N" die Anzahl der Stammsätze ist: 1 Abfrage zum Abrufen aller Stammsätze und N Abfragen, eine pro Stammsatz, um alle Details pro Stammsatz abzurufen.

Mehr Datenbankabfrageaufrufe → mehr Latenzzeit → verminderte Anwendungs- / Datenbankleistung.

ORMs haben jedoch Optionen, um dieses Problem zu vermeiden, hauptsächlich mithilfe von JOINs.

Nathan
quelle
3
Verknüpfungen sind (oft) keine gute Lösung, da sie zu einem kartesischen Produkt führen können. Dies bedeutet, dass die Anzahl der Ergebniszeilen die Anzahl der Ergebnisse der Stammtabelle multipliziert mit der Anzahl der Ergebnisse in jeder untergeordneten Tabelle ist. besonders schlecht über mehrere Herarchieebenen. Die Auswahl von 20 "Blogs" mit jeweils 100 "Posts" und 10 "Kommentaren" zu jedem Post führt zu 20000 Ergebniszeilen. NHibernate bietet Problemumgehungen wie die "Stapelgröße" (untergeordnete Elemente mit in-Klausel für übergeordnete IDs auswählen) oder "Unterauswahl".
Erik Hart
14

Es ist viel schneller, 1 Abfrage auszugeben, die 100 Ergebnisse zurückgibt, als 100 Abfragen auszugeben, die jeweils 1 Ergebnis zurückgeben.

jj_
quelle
13

Meiner Meinung nach ist der Artikel in Hibernate Pitfall: Warum Beziehungen faul sein sollten, genau das Gegenteil von echtem N + 1-Problem.

Wenn Sie eine korrekte Erklärung benötigen, lesen Sie bitte Ruhezustand - Kapitel 19: Verbessern der Leistung - Abrufen von Strategien

Select-Abruf (Standardeinstellung) ist äußerst anfällig für N + 1-Auswahlprobleme. Daher möchten wir möglicherweise das Join-Abrufen aktivieren

Anoop Isaac
quelle
2
Ich habe die Seite zum Ruhezustand gelesen. Es sagt nicht, was das N + 1-Auswahlproblem tatsächlich ist . Es heißt jedoch, dass Sie Joins verwenden können, um das Problem zu beheben.
Ian Boyd
3
Für das ausgewählte Abrufen ist eine Stapelgröße erforderlich, um untergeordnete Objekte für mehrere übergeordnete Elemente in einer select-Anweisung auszuwählen. Unterauswahl könnte eine andere Alternative sein. Verknüpfungen können sehr schlecht werden, wenn Sie mehrere Hierarchieebenen haben und ein kartesisches Produkt erstellt wird.
Erik Hart
10

Der mitgelieferte Link enthält ein sehr einfaches Beispiel für das n + 1-Problem. Wenn Sie es auf den Ruhezustand anwenden, handelt es sich im Grunde genommen um dasselbe. Wenn Sie nach einem Objekt fragen, wird die Entität geladen, aber alle Zuordnungen (sofern nicht anders konfiguriert) werden verzögert geladen. Daher eine Abfrage für die Stammobjekte und eine andere Abfrage zum Laden der Zuordnungen für jedes dieser Objekte. 100 zurückgegebene Objekte bedeuten eine anfängliche Abfrage und dann 100 zusätzliche Abfragen, um die Zuordnung für jede n + 1 zu erhalten.

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


quelle
9

Ein Millionär hat N Autos. Sie möchten alle (4) Räder bekommen.

Eine (1) Abfrage lädt alle Autos, aber für jedes (N) Auto wird eine separate Abfrage zum Laden von Rädern gesendet.

Kosten:

Angenommen, die Indizes passen in den RAM.

Parsen und Planen von 1 + N-Abfragen + Indexsuche UND 1 + N + (N * 4) Plattenzugriff zum Laden von Nutzdaten.

Angenommen, die Indizes passen nicht in den RAM.

Zusätzliche Kosten im schlimmsten Fall 1 + N Plattenzugriffe für den Ladeindex.

Zusammenfassung

Der Flaschenhals ist Plattenzugriff (ca. 70-mal pro Sekunde zufälliger Zugriff auf Festplatte). Ein eifriger Join-Select würde auch 1 + N + (N * 4) Mal für die Nutzlast auf die Platte zugreifen. Wenn also die Indizes in den RAM passen - kein Problem, ist dies schnell genug, da nur RAM-Operationen erforderlich sind.

hans wurst
quelle
9

N + 1-Auswahlproblem ist ein Schmerz, und es ist sinnvoll, solche Fälle in Unit-Tests zu erkennen. Ich habe eine kleine Bibliothek entwickelt, um die Anzahl der Abfragen zu überprüfen, die von einer bestimmten Testmethode oder nur einem beliebigen Codeblock ausgeführt werden - JDBC Sniffer

Fügen Sie Ihrer Testklasse einfach eine spezielle JUnit-Regel hinzu und platzieren Sie Anmerkungen mit der erwarteten Anzahl von Abfragen zu Ihren Testmethoden:

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

@Expectation(atMost = 3)
@Test
public void testInvokingDatabase() {
    // your JDBC or JPA code
}
Bedrin
quelle
5

Wie andere eleganter festgestellt haben, besteht das Problem darin, dass Sie entweder ein kartesisches Produkt der OneToMany-Spalten haben oder N + 1 Selects ausführen. Entweder mögliche gigantische Ergebnismenge oder Chat mit der Datenbank.

Ich bin überrascht, dass dies nicht erwähnt wird, aber so bin ich um dieses Problem herumgekommen ... Ich erstelle eine semi-temporäre ID-Tabelle . Ich mache das auch, wenn Sie die IN ()Klauselbeschränkung haben .

Dies funktioniert nicht in allen Fällen (wahrscheinlich nicht einmal in der Mehrheit), aber es funktioniert besonders gut, wenn Sie viele untergeordnete Objekte haben, sodass das kartesische Produkt außer Kontrolle gerät (dh viele OneToManySpalten, die Anzahl der Ergebnisse ist a Multiplikation der Spalten) und es ist eher ein Batch-ähnlicher Job.

Zuerst fügen Sie Ihre übergeordneten Objekt-IDs als Stapel in eine ID-Tabelle ein. Diese batch_id generieren wir in unserer App und halten daran fest.

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

Jetzt machen Sie für jede OneToManySpalte einfach eine SELECTID-Tabelle in der untergeordneten INNER JOINTabelle mit einem WHERE batch_id=(oder umgekehrt). Sie möchten nur sicherstellen, dass Sie nach der ID-Spalte sortieren, da dies das Zusammenführen von Ergebnisspalten erleichtert (andernfalls benötigen Sie eine HashMap / Tabelle für die gesamte Ergebnismenge, die möglicherweise nicht so schlecht ist).

Dann bereinigen Sie einfach regelmäßig die ID-Tabelle.

Dies funktioniert auch besonders gut, wenn der Benutzer etwa 100 verschiedene Elemente für eine Massenverarbeitung auswählt. Fügen Sie die 100 unterschiedlichen IDs in die temporäre Tabelle ein.

Die Anzahl der Abfragen, die Sie ausführen, richtet sich nach der Anzahl der OneToMany-Spalten.

Adam Gent
quelle
1

Nehmen Sie das Beispiel von Matt Solnit und stellen Sie sich vor, Sie definieren eine Zuordnung zwischen Auto und Rädern als LAZY und benötigen einige Felder für Räder. Dies bedeutet, dass der Ruhezustand nach der ersten Auswahl "Select * from Wheels where car_id =: id" für jedes Auto ausführt.

Dies macht die erste Auswahl und mehr 1 Auswahl von jedem N Auto, deshalb heißt es n + 1 Problem.

Um dies zu vermeiden, lassen Sie die Zuordnung als eifrig abrufen, damit der Ruhezustand Daten mit einem Join lädt.

Aber Vorsicht, wenn Sie oft nicht auf zugehörige Räder zugreifen, ist es besser, sie faul zu halten oder den Abruftyp mit Kriterien zu ändern.

martins.tuga
quelle
1
Auch hier sind Verknüpfungen keine gute Lösung, insbesondere wenn mehr als zwei Hierarchieebenen geladen werden können. Aktivieren Sie stattdessen "Unterauswahl" oder "Stapelgröße". Der letzte lädt untergeordnete Elemente anhand der übergeordneten IDs in der Klausel "in", z. B. "Wählen Sie ... von Rädern aus, auf denen car_id in (1,3,4,6,7,8,11,13)" angezeigt wird.
Erik Hart