Co to jest „N + 1 wybiera problem” w ORM (mapowaniu obiektowo-relacyjnym)?


1596

„N + 1 wybiera problem” jest ogólnie określany jako problem w dyskusjach ORM (Object-Relational Mapation) i rozumiem, że ma to coś wspólnego z koniecznością wykonywania dużej liczby zapytań do bazy danych dla czegoś, co wydaje się proste w obiekcie świat.

Czy ktoś ma bardziej szczegółowe wyjaśnienie problemu?


2
To świetny link z dobrym wyjaśnieniem na temat zrozumienia problemu n + 1 . Obejmuje również rozwiązania mające na celu rozwiązanie tego problemu: architects.dzone.com/articles/how-identify-and-resilve-n1
asy.

Istnieje kilka pomocnych postów mówiących o tym problemie i możliwej poprawce. Typowe problemy z aplikacjami i jak je naprawić: problem Select N + 1 , (Srebrny) punkt dla problemu N + 1 , Leniwe ładowanie
szybkie

Dla wszystkich, którzy szukają rozwiązania tego problemu, znalazłem post opisujący ten problem. stackoverflow.com/questions/32453989/…
damndemon

2
Biorąc pod uwagę odpowiedzi, czy nie należy tego nazywać problemem 1 + N? Ponieważ wydaje się to terminologią, nie pytam OP.
user1418717

Odpowiedzi:


1014

Załóżmy, że masz kolekcję Carobiektów (wiersze bazy danych), a każdy z nich Carma kolekcję Wheelobiektów (także wierszy). Innymi słowy, CarWheeljest relacją jeden do wielu.

Powiedzmy, że musisz iterować przez wszystkie samochody i dla każdego wydrukować listę kół. Naiwna implementacja operacji byłaby następująca:

SELECT * FROM Cars;

A następnie dla każdego Car:

SELECT * FROM Wheel WHERE CarId = ?

Innymi słowy, masz jeden wybór dla samochodów, a następnie N dodatkowych wyborów, gdzie N jest całkowitą liczbą samochodów.

Alternatywnie można uzyskać wszystkie koła i wykonać wyszukiwanie w pamięci:

SELECT * FROM Wheel

Zmniejsza to liczbę podróży w obie strony do bazy danych z N + 1 do 2. Większość narzędzi ORM oferuje kilka sposobów zapobiegania wybieraniu N + 1.

Odniesienia: Java Persistence with Hibernate , rozdział 13.


139
Aby wyjaśnić „To jest złe” - możesz dostać wszystkie koła za pomocą 1 select ( SELECT * from Wheel;), zamiast N + 1. Przy dużej wartości N wydajność może być bardzo znacząca.
tucuxi

211
@tucuxi Jestem zaskoczony, że masz tyle głosów poparcia za to, że się mylisz. Baza danych jest bardzo dobra w zakresie indeksów, wykonanie zapytania dla określonego CarID zwróci się bardzo szybko. Ale jeśli wszystkie koła są raz, musisz wyszukać CarID w aplikacji, która nie jest indeksowana, jest to wolniejsze. O ile nie masz poważnych problemów z opóźnieniem w dotarciu do bazy danych, przejście na n + 1 jest w rzeczywistości szybsze - i tak, przeprowadziłem testy porównawcze z dużą różnorodnością kodu w świecie rzeczywistym.
Ariel,

73
@ariel „Prawidłowym” sposobem jest uzyskanie wszystkich kół, zamówionych przez CarId (1 wybór), a jeśli wymagane są więcej szczegółów niż CarId, wykonaj drugie zapytanie dla wszystkich samochodów (łącznie 2 zapytania). Drukowanie rzeczy jest teraz optymalne i nie były wymagane żadne indeksy ani dodatkowa pamięć (możesz iterować wyniki, nie musisz pobierać ich wszystkich). Zrobiłeś test porównawczy niewłaściwej rzeczy. Jeśli nadal jesteś pewien swoich wyników, czy mógłbyś opublikować dłuższy komentarz (lub pełną odpowiedź) wyjaśniający swój eksperyment i wyniki?
tucuxi,

92
„Hibernacja (nie znam innych frameworków ORM) daje kilka sposobów radzenia sobie z tym.” a te są?
Tima,

58
@Ariel Spróbuj uruchomić swoje testy porównawcze z serwerami baz danych i aplikacji na osobnych komputerach. Z mojego doświadczenia wynika, że ​​podróże w obie strony do bazy danych kosztują więcej narzutów niż samo zapytanie. Tak, zapytania są bardzo szybkie, ale to spustoszenie w obie strony. Przekształciłem „WHERE Id = const ” na „WHERE Id IN ( const , const , ...)” i otrzymałem z tego rzędy wielkości.
Hans

110
SELECT 
table1.*
, table2.*
INNER JOIN table2 ON table2.SomeFkId = table1.SomeId

Otrzymasz zestaw wyników, w którym wiersze podrzędne w tabeli 2 powodują duplikację, zwracając wyniki tabeli 1 dla każdego wiersza podrzędnego w tabeli 2. Mapujący O / R powinien rozróżnić instancje table1 na podstawie unikalnego pola klucza, a następnie użyć wszystkich kolumn table2, aby zapełnić instancje potomne.

SELECT table1.*

SELECT table2.* WHERE SomeFkId = #

N + 1 to miejsce, w którym pierwsze zapytanie wypełnia główny obiekt, a drugie zapytanie wypełnia wszystkie obiekty potomne dla każdego zwróconego unikalnego obiektu podstawowego.

Rozważać:

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

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

oraz tabele o podobnej strukturze. Jedno zapytanie dotyczące adresu „22 Valley St” może zwrócić:

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

O / RM powinien wypełnić instancję Dom o ID = 1, Adres = „22 Valley St”, a następnie wypełnić tablicę Inhabitants instancjami People dla Dave'a, Johna i Mike'a za pomocą tylko jednego zapytania.

Zapytanie N + 1 dla tego samego adresu użytego powyżej spowoduje:

Id Address
1  22 Valley St

z osobnym zapytaniem jak

SELECT * FROM Person WHERE HouseId = 1

i w wyniku czego powstaje osobny zestaw danych, np

Name    HouseId
Dave    1
John    1
Mike    1

a wynik końcowy jest taki sam jak powyżej dla pojedynczego zapytania.

Zaletą pojedynczego wyboru jest to, że masz wszystkie dane z góry, które mogą być tym, czego ostatecznie pragniesz. Zaletą N + 1 jest to, że złożoność zapytań jest zmniejszona i można użyć leniwego ładowania, w którym potomne zestawy wyników są ładowane tylko na pierwsze żądanie.


4
Inną zaletą n + 1 jest to, że jest szybszy, ponieważ baza danych może zwracać wyniki bezpośrednio z indeksu. Wykonanie łączenia, a następnie sortowanie wymaga tabeli temp, która jest wolniejsza. Jedynym powodem do uniknięcia n + 1 jest to, że masz dużo czasu na rozmowę z bazą danych.
Ariel

17
Łączenie i sortowanie może być dość szybkie (ponieważ dołączasz do pól indeksowanych i ewentualnie sortowanych). Jak duże jest twoje „n + 1”? Czy naprawdę wierzysz, że problem n + 1 dotyczy tylko połączeń z bazą danych o dużym opóźnieniu?
tucuxi,

9
@ariel - Twoja rada, że ​​N + 1 jest „najszybszy” jest błędna, nawet jeśli twoje testy porównawcze mogą być prawidłowe. Jak to możliwe? Zobacz en.wikipedia.org/wiki/Anecdotal_evidence , a także mój komentarz w innej odpowiedzi na to pytanie.
whitneyland

7
@Ariel - Myślę, że dobrze to rozumiem :). Próbuję tylko podkreślić, że twój wynik dotyczy tylko jednego zestawu warunków. Z łatwością mógłbym skonstruować licznik, który pokazałby coś przeciwnego. Czy to ma sens?
whitneyland

13
Powtórzę raz jeszcze, problem SELECT N + 1 leży u podstaw: Mam 600 rekordów do odzyskania. Czy szybciej jest uzyskać wszystkie 600 z nich w jednym zapytaniu, czy 1 w 600 zapytaniach na raz? O ile nie korzystasz z MyISAM i / lub nie masz źle znormalizowanego / źle zaindeksowanego schematu (w takim przypadku ORM nie stanowi problemu), odpowiednio dostrojona db zwróci 600 wierszy w 2 ms, zwracając poszczególne wiersze w około 1 ms każdy. Często więc widzimy, że N + 1 zajmuje setki milisekund, a łączenie zajmuje tylko kilka
Dogs

64

Dostawca z relacją jeden do wielu z Produktem. Jeden dostawca ma (dostarcza) wiele produktów.

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

Czynniki:

  • Tryb leniwy dla dostawcy ustawiony na „prawda” (domyślnie)

  • Tryb pobierania używany do wysyłania zapytań o Produkt to Wybierz

  • Tryb pobierania (domyślnie): Dostęp do informacji o dostawcy

  • Buforowanie nie odgrywa roli po raz pierwszy

  • Dostęp do dostawcy

Tryb pobierania to Wybierz pobieranie (domyślnie)

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

Wynik:

  • 1 instrukcja wyboru produktu
  • N wybierz oświadczenia dla dostawcy

To jest problem wyboru N + 1!


3
Czy ma to być 1 wybór dla dostawcy, a następnie N dla produktu?
bencampbell_14

@bencampbell_ Tak, początkowo czułem to samo. Ale z jego przykładem jest to jeden produkt dla wielu dostawców.
Mohd Faizan Khan

38

Nie mogę komentować bezpośrednio innych odpowiedzi, ponieważ nie mam wystarczającej reputacji. Warto jednak zauważyć, że problem zasadniczo pojawia się tylko dlatego, że historycznie wiele dbms było dość słabych, jeśli chodzi o obsługę sprzężeń (MySQL jest szczególnie godnym uwagi przykładem). Tak więc n + 1 często było zauważalnie szybsze niż złączenie. Są też sposoby na poprawienie n + 1, ale nadal bez konieczności łączenia, z czym wiąże się pierwotny problem.

Jednak MySQL jest teraz o wiele lepszy niż kiedyś, jeśli chodzi o dołączenia. Kiedy po raz pierwszy nauczyłem się MySQL, często używałem łączenia. Potem odkryłem, jak wolne są i zamiast tego przełączyłem na n + 1 w kodzie. Ale ostatnio wróciłem do przyłączeń, ponieważ MySQL jest teraz o wiele lepszy w obsłudze niż wtedy, gdy zacząłem go używać.

Obecnie proste sprzężenie na odpowiednio zindeksowanym zestawie tabel rzadko stanowi problem pod względem wydajności. A jeśli daje to hit wydajności, to użycie wskazówek indeksu często rozwiązuje je.

Jest to omówione tutaj przez jeden z zespołów programistów MySQL:

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

Podsumowując, jeśli w przeszłości unikałeś złączeń z powodu fatalnej wydajności MySQL, spróbuj ponownie w najnowszych wersjach. Prawdopodobnie będziesz mile zaskoczony.


7
Nazywanie wczesnych wersji MySQL relacyjnym DBMS jest dość rozciągliwe ... Gdyby ludzie napotykający te problemy korzystali z prawdziwej bazy danych, nie napotkaliby takich problemów. ;-)
Craig

2
Co ciekawe, wiele tego typu problemów zostało rozwiązanych w MySQL wraz z wprowadzeniem i późniejszą optymalizacją silnika INNODB, ale nadal spotkasz ludzi próbujących promować MYISAM, ponieważ uważają, że jest to szybsze.
Craig

5
FYI, jeden z 3 popularnych JOINalgorytmów używanych w RDBMS nazywa się pętlami zagnieżdżonymi. Zasadniczo jest to wybór N + 1 pod maską. Jedyną różnicą jest to, że DB dokonał inteligentnego wyboru, aby użyć go na podstawie statystyk i indeksów, a nie kodu klienta, zmuszając go kategorycznie.
Brandon,

2
@Brandon Tak! Podobnie jak wskazówki JOIN i INDEX, wymuszenie określonej ścieżki wykonania we wszystkich przypadkach rzadko przebije bazę danych. Baza danych jest prawie zawsze bardzo, bardzo dobra w wyborze optymalnego podejścia do uzyskania danych. Być może we wczesnych dniach dbs trzeba było „sformułować” swoje pytanie w szczególny sposób, aby zachęcić db, ale po dziesięcioleciach światowej klasy inżynierii możesz teraz uzyskać najlepszą wydajność, zadając swojej bazie danych pytanie relacyjne i pozwalając mu dowiedzieć się, jak pobrać i zebrać te dane za Ciebie.
Psy

3
Baza danych wykorzystuje nie tylko indeksy i statystyki, ale wszystkie operacje są również lokalnymi operacjami we / wy, z których większość często działa na bardzo wydajnej pamięci podręcznej, a nie na dysku. Programiści baz danych poświęcają ogromną uwagę na optymalizację tego rodzaju rzeczy.
Craig,

27

Z powodu tego problemu odeszliśmy od ORM w Django. Zasadniczo, jeśli spróbujesz

for p in person:
    print p.car.colour

ORM z przyjemnością zwróci wszystkie osoby (zwykle jako instancje obiektu Person), ale wtedy będzie musiał zapytać o tablicę samochodową dla każdej Osoby.

Proste i bardzo skuteczne podejście do tego nazywam „ rozkładaniem wachlarzy ”, co pozwala uniknąć nonsensownego pomysłu, że wyniki zapytania z relacyjnej bazy danych powinny być odwzorowane z powrotem na oryginalne tabele, z których składa się zapytanie.

Krok 1: Szeroki wybór

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

To zwróci coś w rodzaju

  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

Krok 2: Objectify

Ssaj wyniki do ogólnego twórcy obiektów z argumentem do podzielenia po trzecim elemencie. Oznacza to, że obiekt „jones” nie zostanie wykonany więcej niż raz.

Krok 3: Renderuj

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

Zobacz tę stronę internetową dla implementacji Fanfolding dla Pythona.


10
Cieszę się, że natknąłem się na twój post, ponieważ myślałem, że oszalałem. kiedy dowiedziałem się o problemie z N + 1, natychmiast pomyślałem: dlaczego nie stworzysz widoku zawierającego wszystkie potrzebne informacje i nie wyciągniesz go z tego widoku? potwierdziłeś moje stanowisko. Dziękuję Panu.
programista

14
Z powodu tego problemu odeszliśmy od ORM w Django. Co? Django ma select_related, co ma rozwiązać ten problem - w rzeczywistości jego dokumenty zaczynają się od przykładu podobnego do twojego p.car.colourprzykładu.
Adrian17

8
To jest stara odpowiedź, mamy select_related() i prefetch_related()się teraz Django.
Mariusz Jamro,

1
Fajne. Ale select_related()przyjaciel nie wydaje się robić żadnej z oczywistych użytecznych ekstrapolacji połączenia, takich jak LEFT OUTER JOIN. Problem nie jest problemem interfejsu, ale problemem związanym z dziwnym pomysłem, że obiekty i dane relacyjne są możliwe do zmapowania .... moim zdaniem.
rorycl

26

Ponieważ jest to bardzo częste pytanie, napisałem ten artykuł , na którym opiera się ta odpowiedź.

Na czym polega problem zapytania N + 1

Problem zapytania N + 1 występuje, gdy struktura dostępu do danych wykonała N dodatkowych instrukcji SQL w celu pobrania tych samych danych, które mogły zostać pobrane podczas wykonywania podstawowego zapytania SQL.

Im większa wartość N, tym więcej zapytań zostanie wykonanych, tym większy wpływ na wydajność. I w przeciwieństwie do dziennika powolnych zapytań, które mogą pomóc Ci znaleźć wolno działające zapytania, problem N + 1 nie zostanie wykryty, ponieważ każde dodatkowe zapytanie działa wystarczająco szybko, aby nie wyzwalać dziennika wolnych zapytań.

Problem polega na wykonywaniu dużej liczby dodatkowych zapytań, które ogólnie wymagają wystarczającej ilości czasu, aby spowolnić czas odpowiedzi.

Rozważmy, że mamy następujące tabele bazy danych post i post_comments, które tworzą relację jeden do wielu :

Tabele <code> post </code> i <code> post_comments </code>

Utworzymy następujące 4 postrzędy:

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)

Stworzymy również 4 post_comment rekordy potomne:

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)

Problem zapytania N + 1 ze zwykłym SQL

Jeśli wybierzesz post_commentsużycie tego zapytania 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();

A później decydujesz się pobrać powiązane post titledla każdego 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
    );
}

Wywołujesz problem zapytania N + 1, ponieważ zamiast jednego zapytania SQL wykonałeś 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'

Naprawienie problemu zapytania N + 1 jest bardzo łatwe. Wszystko, co musisz zrobić, to wyodrębnić wszystkie dane, których potrzebujesz w oryginalnym zapytaniu SQL:

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

Tym razem wykonywane jest tylko jedno zapytanie SQL, aby pobrać wszystkie dane, których jesteśmy dalej zainteresowani.

Problem zapytania N + 1 z JPA i Hibernacją

Podczas korzystania z JPA i Hibernacji istnieje kilka sposobów na wywołanie problemu zapytania N + 1, dlatego bardzo ważne jest, aby wiedzieć, jak można uniknąć takich sytuacji.

W kolejnych przykładach rozważmy, że mapujemy tabele posti post_commentsna następujące elementy:

Elementy <code> Post </code> i <code> PostComment </code>

Odwzorowania JPA wyglądają tak:

@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

Używanie FetchType.EAGERniejawnie lub jawnie dla stowarzyszeń JPA jest złym pomysłem, ponieważ masz zamiar pobrać znacznie więcej potrzebnych danych. Więcej,FetchType.EAGER strategia jest również podatna na problemy z zapytaniami N + 1.

Niestety, skojarzenia @ManyToOnei @OneToOneużywają FetchType.EAGERdomyślnie, więc jeśli twoje odwzorowania wyglądają tak:

@ManyToOne
private Post post;

Używasz FetchType.EAGERstrategii i za każdym razem, gdy zapomnisz użyć jej JOIN FETCHpodczas ładowania niektórych PostCommentencji za pomocą zapytania JPQL lub Criteria API:

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

Wywołujesz problem zapytania 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

Zwróć uwagę na dodatkowe SELECT, które są wykonywane, ponieważ poststowarzyszenie musi być pobrana przed zwróceniem Listod PostCommentpodmiotów.

W przeciwieństwie do domyślnego planu pobierania, którego używasz podczas wywoływania findmetody EnrityManagerzapytania, JPQL lub Criteria API definiuje jawny plan, którego Hibernacja nie może zmienić, automatycznie wstrzykuj JOIN FETCH. Musisz to zrobić ręcznie.

Jeśli w ogóle nie potrzebujesz postskojarzenia, nie masz szczęścia, FetchType.EAGERponieważ nie ma sposobu, aby go nie pobrać. Dlatego lepiej jest używać FetchType.LAZYdomyślnie.

Ale jeśli chcesz użyć postpowiązania, możesz użyć, JOIN FETCHaby uniknąć problemu z zapytaniem 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()
    );
}

Tym razem Hibernacja wykona jedną instrukcję 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'

Aby uzyskać więcej informacji o tym, dlaczego należy unikać FetchType.EAGERstrategii pobierania, zapoznaj się również z tym artykułem .

FetchType.LAZY

Nawet jeśli przejdziesz na używanie FetchType.LAZYjawnie dla wszystkich skojarzeń, nadal możesz natknąć się na problem N + 1.

Tym razem postpowiązanie jest mapowane w następujący sposób:

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

Teraz, gdy pobierasz PostCommentpodmioty:

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

Hibernacja wykona jedną instrukcję SQL:

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

Ale jeśli później odniesiesz się do leniwie załadowanego postskojarzenia:

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

Otrzymasz problem z zapytaniem 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'

Ponieważ postskojarzenie jest pobierane leniwie, podczas uzyskiwania dostępu do leniwego skojarzenia zostanie wykonana dodatkowa instrukcja SQL w celu zbudowania komunikatu dziennika.

Ponownie, poprawka polega na dodaniu JOIN FETCHklauzuli do zapytania 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()
    );
}

I podobnie jak w FetchType.EAGERprzykładzie, to zapytanie JPQL wygeneruje pojedynczą instrukcję SQL.

Nawet jeśli używasz FetchType.LAZYpowiązania podrzędnego dwukierunkowego @OneToOneJPA i nie odwołujesz się do niego , nadal możesz wywołać problem zapytania N + 1.

Aby uzyskać więcej informacji na temat sposobu rozwiązania problemu zapytania N + 1 generowanego przez @OneToOnepowiązania, sprawdź ten artykuł .

Jak automatycznie wykryć problem zapytania N + 1

Jeśli chcesz automatycznie wykryć problem zapytania N + 1 w warstwie dostępu do danych, w tym artykule wyjaśniono, jak to zrobić za pomocą projektu typu db-utilopen source.

Najpierw musisz dodać następującą zależność Maven:

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

Następnie wystarczy użyć SQLStatementCountValidatornarzędzia do potwierdzenia wygenerowanych instrukcji SQL:

SQLStatementCountValidator.reset();

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

SQLStatementCountValidator.assertSelectCount(1);

Jeśli używasz FetchType.EAGERi uruchamiasz powyższy przypadek testowy, otrzymasz następujący błąd przypadku testowego:

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!

Aby uzyskać więcej informacji na temat projektu typu db-utilopen source, sprawdź ten artykuł .


Ale teraz masz problem z paginacją. Jeśli masz 10 samochodów, każdy samochód ma 4 koła i chcesz paginować samochody z 5 samochodami na stronę. Więc w zasadzie masz SELECT cars, wheels FROM cars JOIN wheels LIMIT 0, 5. Ale dostajesz 2 samochody z 5 kołami (pierwszy samochód ze wszystkimi 4 kołami i drugi samochód tylko z 1 kołem), ponieważ LIMIT ograniczy cały zestaw wyników, nie tylko klauzulę root.
CappY,

2
Mam też artykuł na ten temat.
Vlad Mihalcea,

Dziękuję za artykuł. Przeczytam to. Przez szybkie przewijanie - zobaczyłem, że rozwiązaniem jest funkcja okna, ale są one całkiem nowe w MariaDB - więc problem utrzymuje się w starszych wersjach. :)
CappY

@VladMihalcea, wskazałem albo z twojego artykułu, albo z postu za każdym razem, gdy odwołujesz się do sprawy ManyToOne, wyjaśniając problem N + 1. Ale tak naprawdę ludzie najbardziej interesują się sprawą OneToMany dotyczącą problemu N + 1. Czy możesz polecić i wyjaśnić sprawę OneToMany?
JJ Beam

18

Załóżmy, że masz SPÓŁKĘ i PRACOWNIKA. FIRMA ma wielu PRACOWNIKÓW (tj. PRACOWNIK ma pole COMPANY_ID).

W niektórych konfiguracjach O / R, gdy masz zmapowany obiekt Firmy i masz dostęp do jego obiektów Pracownika, narzędzie O / R dokona jednego wyboru dla każdego pracownika, a jeśli robisz tylko proste SQL, możesz to zrobić select * from employees where company_id = XX. Zatem N (liczba pracowników) plus 1 (firma)

Tak działały początkowe wersje EJB Entity Beans. Wierzę, że takie rzeczy jak Hibernacja zniosły to, ale nie jestem zbyt pewien. Większość narzędzi zwykle zawiera informacje dotyczące ich strategii mapowania.


18

Oto dobry opis problemu

Teraz, gdy rozumiesz problem, zwykle można go uniknąć, wykonując połączenie dołączenia w zapytaniu. To zasadniczo wymusza pobranie leniwie załadowanego obiektu, więc dane są pobierane w jednym zapytaniu zamiast n + 1 zapytań. Mam nadzieję że to pomoże.


17

Sprawdź post Ayende na ten temat: Zwalczanie problemu wyboru N + 1 w NHibernate .

Zasadniczo, jeśli używasz ORM, takiego jak NHibernate lub EntityFramework, jeśli masz relację jeden do wielu (główny-szczegółowy) i chcesz wyświetlić wszystkie szczegóły dla każdego rekordu głównego, musisz wykonać wywołania zapytania N + 1 do baza danych, gdzie „N” oznacza liczbę rekordów głównych: 1 zapytanie, aby uzyskać wszystkie rekordy główne, i N zapytań, po jednym na rekord główny, aby uzyskać wszystkie szczegóły na rekord główny.

Więcej wywołań zapytań do bazy danych → dłuższy czas oczekiwania → zmniejszona wydajność aplikacji / bazy danych.

Jednak ORM mają opcje pozwalające uniknąć tego problemu, głównie za pomocą JOIN.


3
złączenia nie są dobrym rozwiązaniem (często), ponieważ mogą powodować iloczyn kartezjański, co oznacza, że ​​liczba wierszy wyników jest liczbą wyników tabeli głównej pomnożoną przez liczbę wyników w każdej tabeli podrzędnej. szczególnie źle na wielu poziomach herarchii. Wybranie 20 „blogów” ze 100 „postami” na każdym i 10 „komentarzy” na każdym postu spowoduje wygenerowanie 20000 wierszy wyników. NHibernate ma obejścia, takie jak „wielkość partii” (wybierz dzieci z klauzulą ​​w nadrzędnych identyfikatorach) lub „podselekcja”.
Erik Hart

14

O wiele szybciej jest wydać 1 zapytanie, które zwraca 100 wyników, niż wydać 100 zapytań, z których każde zwraca 1 wynik.


13

Moim zdaniem artykuł napisany w Hibernacji Pitfall: Dlaczego relacje powinny być leniwe jest dokładnie odwrotny do prawdziwego problemu N + 1.

Jeśli potrzebujesz poprawnego wyjaśnienia, zapoznaj się z Hibernacją - Rozdział 19: Poprawa wydajności - Pobieranie strategii

Wybierz pobieranie (domyślne) jest bardzo podatne na problemy z wyborem N + 1, więc możemy chcieć włączyć pobieranie dołączania


2
czytam stronę hibernacji. Nie mówi, co to właściwie jest problem z wyborem N + 1 . Ale mówi, że możesz użyć złączeń, aby to naprawić.
Ian Boyd

3
rozmiar partii jest wymagany do pobierania select, aby wybrać obiekty potomne dla wielu rodziców w jednej instrukcji select. Podselekcja może być inną alternatywą. Połączenia mogą być bardzo złe, jeśli masz wiele poziomów hierarchii i tworzony jest produkt kartezjański.
Erik Hart

10

Podany link zawiera bardzo prosty przykład problemu n + 1. Jeśli zastosujesz go do Hibernacji, to w zasadzie mówi o tym samym. Podczas zapytania o obiekt jednostka jest ładowana, ale wszelkie skojarzenia (o ile nie skonfigurowano inaczej) będą ładowane z opóźnieniem. Stąd jedno zapytanie dotyczące obiektów głównych i drugie zapytanie w celu załadowania powiązań dla każdego z nich. Zwrócone 100 obiektów oznacza jedno zapytanie początkowe, a następnie 100 dodatkowych zapytań, aby uzyskać skojarzenie dla każdego, n + 1.

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


9

Jeden milioner ma N samochodów. Chcesz zdobyć wszystkie (4) koła.

Jedno (1) zapytanie ładuje wszystkie samochody, ale dla każdego (N) samochodu przesyłane jest osobne zapytanie dotyczące ładowania kół.

Koszty:

Załóżmy, że indeksy pasują do pamięci RAM.

Analiza składni i planowania 1 + N + wyszukiwanie indeksu ORAZ 1 + N + (N * 4) dostęp do płyty w celu załadowania ładunku.

Załóżmy, że indeksy nie pasują do pamięci RAM.

Dodatkowe koszty w najgorszym przypadku dostęp do płyt 1 + N dla indeksu obciążenia.

Podsumowanie

Szyjka butelki ma dostęp do płyty (około 70 razy na sekundę dostępu losowego na dysku twardym). Chętny wybór połączenia również uzyskałby dostęp do płyty 1 + N + (N * 4) razy dla ładunku. Jeśli więc indeksy mieszczą się w pamięci RAM - nie ma problemu, jest wystarczająco szybki, ponieważ dotyczą tylko operacji pamięci RAM.


9

Problem wyboru N + 1 to ból i sensowne jest wykrywanie takich przypadków w testach jednostkowych. Opracowałem małą bibliotekę do weryfikacji liczby zapytań wykonanych za pomocą danej metody testowej lub po prostu dowolnego bloku kodu - JDBC Sniffer

Po prostu dodaj specjalną regułę JUnit do swojej klasy testowej i umieść adnotację z oczekiwaną liczbą zapytań w metodach testowych:

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

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

5

Problem, jak inni stwierdzili bardziej elegancko, polega na tym, że albo masz kartezjański produkt z kolumn OneToMany, albo robisz selekcje N + 1. Możliwie gigantyczny zestaw wyników lub odpowiednio rozmowa z bazą danych.

Dziwię się, że nie wspomniano o tym, ale poradziłem sobie z tym problemem ... Tworzę pół-tymczasową tabelę identyfikatorów . Robię to również, gdy masz IN ()ograniczenie klauzuli .

Nie działa to we wszystkich przypadkach (prawdopodobnie nawet nie w większości), ale działa szczególnie dobrze, jeśli masz wiele obiektów potomnych, tak że produkt kartezjański wymknie się spod kontroli (tj. Wiele OneToMany kolumn, liczba wyników będzie pomnożenie kolumn) i jest to bardziej zadanie wsadowe.

Najpierw wstawiasz identyfikatory obiektów nadrzędnych jako partię do tabeli identyfikatorów. Ten batch_id to coś, co generujemy w naszej aplikacji i którego trzymamy.

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

Teraz dla każdego OneToMany kolumny po prostu wykonaj a SELECTna tabeli ids, porównując tabelę INNER JOINpodrzędną za pomocą WHERE batch_id=(lub odwrotnie). Musisz tylko upewnić się, że sortujesz według kolumny id, ponieważ ułatwi to scalanie kolumn wyników (w przeciwnym razie będziesz potrzebować HashMap / Table dla całego zestawu wyników, co może nie być takie złe).

Następnie okresowo czyścisz tabelę identyfikatorów.

Działa to również szczególnie dobrze, jeśli użytkownik wybierze powiedzmy 100 lub więcej różnych elementów do pewnego rodzaju przetwarzania masowego. Umieść 100 różnych identyfikatorów w tabeli tymczasowej.

Teraz liczba zapytań zależy od liczby kolumn OneToMany.


1

Weźmy na przykład Matta Solnita, wyobraź sobie, że definiujesz powiązanie między samochodem a kołami jako LAZY i potrzebujesz niektórych pól Wheels. Oznacza to, że po pierwszym wybraniu hibernacja wykona „Wybierz * z kół, gdzie car_id =: id” DLA KAŻDEGO samochodu.

To sprawia, że ​​pierwszy wybór i więcej 1 wybór dla każdego samochodu N, dlatego nazywa się to problemem n + 1.

Aby tego uniknąć, spraw, aby skojarzenie było pobierane tak chętnie, aby hibernacja ładowała dane z łączeniem.

Ale uwaga, jeśli wiele razy nie uzyskujesz dostępu do powiązanych Kół, lepiej jest pozostawać ODPORNY lub zmienić typ pobierania za pomocą kryteriów.


1
Ponownie, sprzężenia nie są dobrym rozwiązaniem, szczególnie gdy można załadować więcej niż 2 poziomy hierarchii. Zamiast tego zaznacz „podselekcja” lub „wielkość partii”; ostatni załaduje dzieci według nadrzędnych identyfikatorów w klauzuli „in”, na przykład „wybierz ... z kół, gdzie car_id w (1,3,4,6,7,8,11,13)”.
Erik Hart
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.