Что такое «проблема выбора N + 1» в ORM (объектно-реляционное отображение)?


1599

«Проблема выбора N + 1» обычно указывается как проблема в обсуждениях объектно-реляционного отображения (ORM), и я понимаю, что это связано с необходимостью выполнять множество запросов к базе данных для чего-то, что кажется простым в объекте Мир.

У кого-нибудь есть более подробное объяснение проблемы?


2
Это отличная ссылка с хорошим объяснением понимания проблемы n + 1 . Он также охватывает решения для решения этой проблемы: architects.dzone.com/articles/how-identify-and-resilve-n1
aces.

Есть несколько полезных постов, рассказывающих об этой проблеме и возможных исправлениях. Распространенные проблемы с приложениями и способы их решения: проблема выбора N + 1 , (серебряная) пуля для задачи N + 1 ,
отложенная

Для всех, кто ищет решение этой проблемы, я нашел пост, описывающий ее. stackoverflow.com/questions/32453989/…
damndemon

2
Учитывая ответы, не следует ли это назвать проблемой 1 + N? Поскольку это, кажется, терминология, я, в частности, не спрашиваю OP.
user1418717

Ответы:


1019

Допустим, у вас есть коллекция Carобъектов (строк базы данных), и у каждого Carесть коллекция Wheelобъектов (также строк). Другими словами, CarWheelэто отношение 1-ко-многим.

Теперь предположим, что вам нужно пройтись по всем машинам, и для каждого из них распечатать список колес. Наивная реализация O / R сделала бы следующее:

SELECT * FROM Cars;

И тогда для каждого Car:

SELECT * FROM Wheel WHERE CarId = ?

Другими словами, у вас есть один выбор для автомобилей, а затем N дополнительных выборов, где N - общее количество автомобилей.

В качестве альтернативы можно получить все колеса и выполнить поиск в памяти:

SELECT * FROM Wheel

Это сокращает число обращений к базе данных с N + 1 до 2. Большинство инструментов ORM предоставляют несколько способов предотвратить выбор N + 1.

Ссылка: Java Persistence с Hibernate , глава 13.


140
Чтобы уточнить «Это плохо» - вы можете получить все колеса с 1 select ( SELECT * from Wheel;) вместо N + 1. С большим N, производительность может быть очень значительным.
Tucuxi

212
@tucuxi Я удивлен, что ты получил столько голосов за то, что был неправ. База данных очень хороша в отношении индексов, выполнение запроса для определенного CarID будет очень быстрым. Но если вы получили все колеса один раз, вам придется искать CarID в вашем приложении, которое не индексируется, это медленнее. Если у вас нет серьезных задержек при достижении вашей базы данных, n + 1 на самом деле быстрее - и да, я сравнил его с большим разнообразием реального кода.
Ариэль

74
@ariel «правильный» способ - получить все колеса, заказанные CarId (1 выбор), и, если требуется больше деталей, чем CarId, сделать второй запрос для всех автомобилей (всего 2 запроса). Распечатка данных теперь является оптимальной, и не требуется никаких индексов или вторичного хранилища (вы можете перебирать результаты, не загружая их все). Вы отметили не то. Если вы все еще уверены в своих критериях, не могли бы вы опубликовать более длинный комментарий (или полный ответ), объясняющий ваш эксперимент и результаты?
Tucuxi

92
«Hibernate (я не знаком с другими платформами ORM) дает вам несколько способов справиться с этим». а эти способы есть?
Тима

58
@Ariel Попробуйте запустить свои тесты с базами данных и серверами приложений на разных компьютерах. По моему опыту, поездки в базу данных обходятся дороже, чем сам запрос. Так что да, запросы действительно быстрые, но это кругосветные путешествия, которые наносят ущерб. Я конвертировал "WHERE Id = const " в "WHERE Id IN ( const , const , ...)" и получил из этого порядки увеличения.
Ганс

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

Это дает вам набор результатов, где дочерние строки в table2 вызывают дублирование, возвращая результаты table1 для каждой дочерней строки в table2. Операторы сопоставления O / R должны дифференцировать экземпляры table1 на основе уникального ключевого поля, а затем использовать все столбцы table2 для заполнения дочерних экземпляров.

SELECT table1.*

SELECT table2.* WHERE SomeFkId = #

N + 1 - это место, где первый запрос заполняет первичный объект, а второй запрос заполняет все дочерние объекты для каждого из возвращенных уникальных первичных объектов.

Рассматривать:

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

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

и таблицы с похожей структурой. Один запрос по адресу "22 Valley St" может вернуть:

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

O / RM должен заполнить экземпляр Home с ID = 1, Address = "22 Valley St", а затем заполнить массив Inhabitants экземплярами People для Dave, John и Mike одним запросом.

Запрос N + 1 для того же адреса, который использовался выше, приведет к:

Id Address
1  22 Valley St

с отдельным запросом, как

SELECT * FROM Person WHERE HouseId = 1

и в результате в отдельный набор данных, как

Name    HouseId
Dave    1
John    1
Mike    1

и окончательный результат будет таким же, как указано выше с одним запросом.

Преимущества единого выбора в том, что вы получаете все данные заранее, что может быть именно тем, что вы в конечном итоге желаете. Преимущество N + 1 в том, что сложность запроса снижена, и вы можете использовать отложенную загрузку, когда дочерние наборы результатов загружаются только при первом запросе.


4
Другое преимущество n + 1 состоит в том, что это быстрее, потому что база данных может возвращать результаты непосредственно из индекса. Выполнение объединения и последующей сортировки требует временной таблицы, которая медленнее. Единственная причина, по которой следует избегать n + 1, заключается в том, что у вас много задержек при обращении к вашей базе данных.
Ариэль

17
Объединение и сортировка могут быть довольно быстрыми (потому что вы будете объединяться в индексированных и, возможно, отсортированных полях). Насколько велика ваша 'n + 1'? Вы всерьез полагаете, что проблема n + 1 касается только соединений с базами данных с высокой задержкой?
Tucuxi

9
@ariel - Ваш совет о том, что N + 1 является «самым быстрым», неверен, хотя ваши тесты могут быть верными. Как это возможно? См. En.wikipedia.org/wiki/Anecdotal_evidence , а также мой комментарий в другом ответе на этот вопрос.
Whitneyland

7
@ Ариэль - кажется, я все прекрасно понял :). Я просто пытаюсь указать, что ваш результат относится только к одному набору условий. Я мог бы легко построить контрпример, который показал обратное. Имеет ли это смысл?
Whitneyland

13
Повторим еще раз: проблема SELECT N + 1 по своей сути: у меня есть 600 записей для извлечения. Быстрее ли получить все 600 из них за один запрос или по одному за 600 запросов? Если вы не используете MyISAM и / или у вас плохо нормализованная / плохо проиндексированная схема (в этом случае ORM не проблема), правильно настроенная БД вернет 600 строк за 2 мс, а отдельные строки вернут в около 1 мс каждый. Таким образом, мы часто видим, что N + 1 занимает сотни миллисекунд, а объединение занимает всего пару
Собаки

64

Поставщик, имеющий отношения один-ко-многим с продуктом. Один поставщик имеет (поставляет) много товаров.

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

Факторы:

  • Ленивый режим для поставщика установлен на «истина» (по умолчанию)

  • Режим выборки, используемый для запроса по продукту, - Выбор.

  • Режим выборки (по умолчанию): доступ к информации о поставщике

  • Кэширование не играет роли впервые

  • Доступ к поставщику

Режим выборки - «Выбрать выборку» (по умолчанию)

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

Результат:

  • 1 выберите заявление для продукта
  • N выбрать заявления для поставщика

Это проблема выбора N + 1!


3
Предполагается, что это 1 выбор для поставщика, а N выбор для продукта?
bencampbell_14

@bencampbell_ Да, изначально я чувствовал то же самое. Но с его примера это один продукт для многих поставщиков.
Мохд Файзан Хан

38

Я не могу комментировать другие ответы напрямую, потому что мне не хватает репутации. Но стоит отметить, что проблема, по сути, возникает только потому, что исторически, многие dbms были достаточно плохими, когда дело доходит до обработки соединений (MySQL является особенно заслуживающим внимания примером). Таким образом, n + 1 часто был значительно быстрее соединения. И тогда есть способы улучшить n + 1, но все еще без необходимости объединения, к чему относится исходная проблема.

Тем не менее, MySQL теперь намного лучше, чем раньше, когда дело доходит до объединений. Когда я впервые изучил MySQL, я часто использовал соединения. Затем я обнаружил, насколько они медленные, и вместо этого переключился на n + 1 в коде. Но недавно я вернулся к объединениям, потому что MySQL теперь намного лучше справляется с ними, чем когда я впервые начал его использовать.

В наши дни простое объединение с правильно проиндексированным набором таблиц редко является проблемой с точки зрения производительности. И если это дает снижение производительности, то использование подсказок индекса часто решает их.

Это обсуждается здесь одним из разработчиков MySQL:

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

Итак, подведем итоги: если в прошлом вы избегали объединений из-за ужасной производительности MySQL, попробуйте еще раз последние версии. Вы, вероятно, будете приятно удивлены.


7
Называть ранние версии MySQL реляционной СУБД довольно сложно ... Если бы люди, сталкивающиеся с этими проблемами, использовали настоящую базу данных, они бы не столкнулись с такими проблемами. ;-)
Крейг,

2
Интересно, что многие из этих типов проблем были решены в MySQL с введением и последующей оптимизацией механизма INNODB, но вы все равно столкнетесь с людьми, пытающимися продвигать MYISAM, потому что они думают, что это быстрее.
Крейг,

5
К вашему сведению, один из 3 распространенных JOINалгоритмов, используемых в СУБД, называется вложенными циклами. Это принципиально N + 1 выбор под капотом. Единственное отличие состоит в том, что БД сделала разумный выбор, чтобы использовать ее на основе статистики и индексов, а не кода клиента, форсирующего этот путь категорически.
Брэндон

2
@ Брэндон Да! Подобно подсказкам JOIN и INDEX, форсирование определенного пути выполнения во всех случаях редко побеждает базу данных. База данных почти всегда очень, очень хороша в выборе оптимального подхода для получения данных. Возможно, в первые дни существования базы данных вам нужно было «сформулировать» свой вопрос особым образом, чтобы убедить базу данных, но после десятилетий разработки мирового класса вы можете теперь добиться максимальной производительности, задавая своей базе данных реляционный вопрос и позволяя ей разобраться, как получить и собрать эти данные для вас.
собаки

3
База данных использует не только индексы и статистику, все операции также являются локальными операциями ввода-вывода, большая часть которых часто работает с высокоэффективным кешем, а не с диском. Программисты баз данных уделяют огромное внимание оптимизации подобных вещей.
Крейг

27

Из-за этой проблемы мы отошли от ORM в Джанго. В принципе, если вы попытаетесь сделать

for p in person:
    print p.car.colour

ORM с радостью вернет всех людей (обычно в виде экземпляров объекта Person), но затем потребуется запросить таблицу автомобилей для каждого человека.

Простой и очень эффективный подход к этому - это то, что я называю « фанфолдингом », что позволяет избежать бессмысленной идеи, согласно которой результаты запроса из реляционной базы данных должны отображаться обратно в исходные таблицы, из которых составлен запрос.

Шаг 1: Широкий выбор

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

Это вернет что-то вроде

  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

Шаг 2: Объективировать

Соси результаты в создателя универсального объекта с аргументом, чтобы разделить после третьего элемента. Это означает, что объект "jones" будет создан не более одного раза.

Шаг 3: Визуализация

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

Смотрите эту веб-страницу для реализации фанфолдинга для Python.


10
Я так рад, что наткнулся на твой пост, потому что думал, что схожу с ума. Когда я узнал о проблеме N + 1, я сразу подумал: ну почему бы вам просто не создать представление, содержащее всю необходимую информацию, и извлечь из него представление? Вы подтвердили мою позицию. Спасибо, сэр.
разработчик

14
Из-за этой проблемы мы отошли от ORM в Джанго. А? Django имеет select_related, что призвано решить эту проблему - на самом деле, его документы начинаются с примера, аналогичного вашему p.car.colourпримеру.
Adrian17

8
Это старый ответ, у нас есть select_related()и prefetch_related()в Джанго сейчас.
Мариуш Джамро

1
Круто. Но select_related()и друг, похоже, не делает каких-либо явно полезных экстраполяций объединения, таких как LEFT OUTER JOIN. Проблема не в интерфейсе, а в странной идее о том, что объекты и реляционные данные сопоставимы ... на мой взгляд.
rorycl

26

Поскольку это очень распространенный вопрос, я написал эту статью , на которой основан этот ответ.

В чем проблема N + 1?

Проблема запроса N + 1 возникает, когда структура доступа к данным выполнила N дополнительных операторов SQL для извлечения тех же данных, которые могли быть получены при выполнении основного запроса SQL.

Чем больше значение N, тем больше запросов будет выполнено, тем больше влияние на производительность. И, в отличие от медленного журнала запросов, который может помочь вам найти медленные запросы, проблема N + 1 не будет обнаружена, потому что каждый отдельный дополнительный запрос выполняется достаточно быстро, чтобы не вызывать медленный журнал запросов.

Проблема заключается в выполнении большого количества дополнительных запросов, которые в целом требуют достаточного времени для замедления времени отклика.

Давайте рассмотрим, что у нас есть следующие таблицы базы данных post и post_comments, которые образуют отношение таблицы «один ко многим» :

Таблицы <code> post </ code> и <code> post_comments </ code>

Мы собираемся создать следующие 4 postстроки:

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)

И мы также создадим 4 post_commentдочерние записи:

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 с простым SQL

Если вы выбрали post_commentsиспользование этого 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();

И позже вы решаете получить связанный post titleс каждым 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
    );
}

Вы собираетесь вызвать проблему запроса N + 1, потому что вместо одного запроса SQL вы выполнили 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'

Исправить проблему с запросом N + 1 очень просто. Все, что вам нужно сделать, это извлечь все данные, которые вам нужны в исходном запросе 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
    );
}

На этот раз выполняется только один SQL-запрос для извлечения всех данных, которые нам еще интересны.

Проблема запроса N + 1 с JPA и Hibernate

При использовании JPA и Hibernate есть несколько способов вызвать проблему с запросом N + 1, поэтому очень важно знать, как можно избежать этих ситуаций.

В течение следующих примеров мы рассмотрим КАРТОГРАФИРОВАНИЕ postи post_commentsтаблиц для следующих лиц:

сущности <code> Post </ code> и <code> PostComment </ code>

Отображения JPA выглядят так:

@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

Использование FetchType.EAGERнеявного или явного для ваших ассоциаций JPA - плохая идея, потому что вы собираетесь получать больше данных, которые вам нужны. Более того, FetchType.EAGERстратегия также подвержена проблемам с N + 1 запросами.

К сожалению, @ManyToOneи @OneToOneассоциации используют FetchType.EAGERпо умолчанию, поэтому , если ваши отображения выглядеть следующим образом :

@ManyToOne
private Post post;

Вы используете FetchType.EAGERстратегию и каждый раз, когда вы забываете использовать ее JOIN FETCHпри загрузке некоторых PostCommentсущностей с помощью запроса API JPQL или Criteria:

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

Вы собираетесь вызвать проблему запроса 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

Обратите внимание на дополнительном ЗЕЬЕСТ, которые выполняются , потому что postассоциация должна быть извлечены до возвращения Listиз PostCommentсубъектов.

В отличие от плана выборки по умолчанию, который вы используете при вызове findметода EnrityManager, запрос API-интерфейса JPQL или Criteria определяет явный план, который Hibernate не может изменить, внедрив FETCH JOIN автоматически. Итак, вам нужно сделать это вручную.

Если вам вообще не нужна postассоциация, вам не повезло при использовании, FetchType.EAGERпотому что нет способа избежать ее получения. Вот почему лучше использовать FetchType.LAZYпо умолчанию.

Но, если вы хотите использовать postассоциацию, вы можете использовать ее JOIN FETCHдля решения проблемы 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()
    );
}

На этот раз Hibernate выполнит одну инструкцию 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'

Чтобы узнать больше о том, почему вы должны избегать FetchType.EAGERстратегии извлечения, ознакомьтесь с этой статьей .

FetchType.LAZY

Даже если вы переключитесь на использование FetchType.LAZYявно для всех ассоциаций, вы все равно можете столкнуться с проблемой N + 1.

На этот раз postассоциация отображается так:

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

Теперь, когда вы выбираете PostCommentобъекты:

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

Hibernate выполнит одну инструкцию SQL:

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

Но, если потом, вы будете ссылаться на ленивую postассоциацию:

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

Вы получите вопрос 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'

Поскольку postсвязь извлекается лениво, вторичный оператор SQL будет выполняться при доступе к ленивой ассоциации, чтобы построить сообщение журнала.

Опять же, исправление состоит в добавлении JOIN FETCHпредложения к запросу 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()
    );
}

И, как и в FetchType.EAGERпримере, этот запрос JPQL будет генерировать один оператор SQL.

Даже если вы используете FetchType.LAZYи не ссылаетесь на дочернюю ассоциацию двунаправленного @OneToOneотношения JPA, вы все равно можете вызвать проблему запроса N + 1.

Для получения дополнительной информации о том, как вы можете преодолеть проблему запроса N + 1, созданную @OneToOneассоциациями, ознакомьтесь с этой статьей .

Как автоматически определить проблему с запросом N + 1

Если вы хотите автоматически обнаружить проблему с запросом N + 1 на уровне доступа к данным, в этой статье объясняется, как это можно сделать с помощью проекта с db-utilоткрытым исходным кодом.

Во-первых, вам нужно добавить следующую зависимость Maven:

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

После этого вам просто нужно использовать SQLStatementCountValidatorутилиту для утверждения базовых операторов SQL, которые генерируются:

SQLStatementCountValidator.reset();

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

SQLStatementCountValidator.assertSelectCount(1);

Если вы используете FetchType.EAGERи запускаете приведенный выше тестовый пример, вы получите следующий тестовый случай:

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!

Чтобы узнать больше о проекте с db-utilоткрытым исходным кодом, ознакомьтесь с этой статьей .


Но теперь у вас есть проблемы с нумерацией страниц. Если у вас есть 10 автомобилей, каждая машина с 4 колесами, и вы хотите разбить автомобили на 5 машин на странице. Таким образом, вы в основном у вас есть SELECT cars, wheels FROM cars JOIN wheels LIMIT 0, 5. Но вы получаете 2 машины с 5 колесами (первый автомобиль со всеми 4 колесами и второй автомобиль только с 1 колесом), потому что LIMIT ограничит весь набор результатов, а не только корневой пункт.
CappY

2
У меня есть статья для этого тоже.
Влад Михальча

Спасибо за статью. Я прочитаю это. Благодаря быстрой прокрутке - я понял, что решение - это оконная функция, но они довольно новы в MariaDB - поэтому проблема сохраняется в более старых версиях. :)
CappY

@VladMihalcea, я указывал либо из вашей статьи, либо из поста каждый раз, когда вы ссылаетесь на дело ManyToOne, когда объясняете проблему N + 1. Но на самом деле людей больше всего интересует дело OneToMany, касающееся проблемы N + 1. Не могли бы вы сослаться и объяснить дело OneToMany?
Джей Джей Бим

18

Предположим, у вас есть КОМПАНИЯ и СОТРУДНИК. У КОМПАНИИ много СОТРУДНИКОВ (т. Е. У СОТРУДНИКА есть поле COMPANY_ID).

В некоторых конфигурациях O / R, когда у вас есть сопоставленный объект Company и вы переходите к его объектам Employee, инструмент O / R будет делать один выбор для каждого сотрудника, тогда как, если вы просто работали с простым SQL, вы могли бы select * from employees where company_id = XX. Таким образом, N (количество сотрудников) плюс 1 (компания)

Вот как работали начальные версии EJB Entity Beans. Я считаю, что такие вещи, как Hibernate, покончили с этим, но я не слишком уверен. Большинство инструментов, как правило, содержат информацию о своей стратегии отображения.


18

Вот хорошее описание проблемы

Теперь, когда вы понимаете проблему, ее обычно можно избежать, выполнив выборку соединения в вашем запросе. Это в основном вызывает выборку загруженного объекта с отложенным доступом, поэтому данные извлекаются в одном запросе вместо n + 1 запросов. Надеюсь это поможет.


17

Посмотрите сообщение Ayende на тему: Борьба с проблемой N + 1 в NHibernate .

По сути, при использовании ORM, например NHibernate или EntityFramework, если у вас есть отношение «один ко многим» (master-detail), и вы хотите перечислить все детали для каждой основной записи, вы должны сделать N + 1 запросов на вызов к база данных, где «N» - это число основных записей: 1 запрос для получения всех основных записей и N запросов, по одному для каждой основной записи, для получения всех подробностей для основной записи.

Больше запросов к базе данных → больше времени ожидания → снижается производительность приложения / базы данных.

Однако у ORM есть варианты, чтобы избежать этой проблемы, в основном используя JOIN.


3
Объединения не являются хорошим решением (часто), поскольку они могут привести к декартовому произведению, то есть число строк результатов - это число результатов корневой таблицы, умноженное на количество результатов в каждой дочерней таблице. особенно плохо на множественных уровнях иерархии. Выбор 20 «блогов» со 100 «постами» в каждом и 10 «комментариями» в каждом посте приведет к 20000 строкам результатов. У NHibernate есть обходные пути, такие как «размер пакета» (выберите дочерние элементы с предложением in в родительских идентификаторах) или «отбор».
Эрик Харт

14

Гораздо быстрее выдать 1 запрос, который возвращает 100 результатов, чем выдать 100 запросов, каждый из которых возвращает 1 результат.


13

На мой взгляд, статья, написанная в Hibernate Pitfall: Почему отношения должны быть ленивыми , прямо противоположна реальной проблеме N + 1.

Если вам нужно правильное объяснение, пожалуйста, обратитесь к Hibernate - Глава 19: Повышение производительности - Выбор стратегий

Выборочная выборка (по умолчанию) чрезвычайно уязвима для N + 1 выбора проблем, поэтому мы можем захотеть включить выборочную выборку


2
я прочитал спящую страницу. Это не говорит о том, что на самом деле является проблемой выбора N + 1 . Но он говорит, что вы можете использовать соединения, чтобы исправить это.
Ян Бойд

3
Для выбора выборки необходим пакетный размер, чтобы выбрать дочерние объекты для нескольких родителей в одном операторе выбора. Отбор может быть другой альтернативой. Объединения могут быть очень плохими, если у вас несколько уровней иерархии и создан декартово произведение.
Эрик Харт

10

Приведенная ссылка имеет очень простой пример проблемы n + 1. Если вы примените его к Hibernate, то это в основном говорит об одном и том же. Когда вы запрашиваете объект, объект загружается, но любые ассоциации (если не указано иное) будут загружаться с отложенной загрузкой. Отсюда один запрос для корневых объектов и другой запрос для загрузки ассоциаций для каждого из них. 100 возвращенных объектов означают один начальный запрос, а затем 100 дополнительных запросов для получения ассоциации для каждого, n + 1.

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


9

У одного миллионера N машин. Вы хотите получить все (4) колеса.

Один (1) запрос загружает все автомобили, но для каждого (N) автомобиля отправляется отдельный запрос на загрузку колес.

Расходы:

Предположим, что индексы вписываются в оперативную память.

Разбор и планирование запросов 1 + N + поиск по индексу И доступ к табличке 1 + N + (N * 4) для загрузки полезной нагрузки.

Предположим, что индексы не вписываются в оперативную память.

Дополнительные расходы в худшем случае 1 + N доступ к пластине для индекса загрузки.

Резюме

Горлышко бутылки - это доступ к платформе (около 70 раз в секунду при произвольном доступе по жесткому диску). При активном выборе соединения можно также получить доступ к платформе 1 + N + (N * 4) раз для полезной нагрузки. Так что, если индексы вписываются в оперативную память - нет проблем, это достаточно быстро, потому что задействованы только оперативные памяти.


9

Проблема выбора N + 1 - это боль, и имеет смысл выявлять такие случаи в модульных тестах. Я разработал небольшую библиотеку для проверки количества запросов, выполняемых данным методом тестирования или просто произвольным блоком кода - JDBC Sniffer

Просто добавьте специальное правило JUnit в ваш тестовый класс и поместите аннотацию с ожидаемым количеством запросов к вашим тестовым методам:

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

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

5

Проблема, как говорили другие более элегантно, заключается в том, что у вас либо декартово произведение столбцов OneToMany, либо вы выполняете N + 1 выбор. Возможен либо гигантский набор результатов, либо общение с базой данных соответственно.

Я удивлен, что это не упомянуто, но так я обошел эту проблему ... Я делаю таблицу временных идентификаторов . Я также делаю это, когда у вас есть IN ()ограничение пункта .

Это не работает для всех случаев (возможно, даже не для большинства), но особенно хорошо работает, если у вас много дочерних объектов, так что декартово произведение выйдет из-под контроля (т.е. много OneToManyстолбцов, число результатов будет умножение столбцов) и его более пакетной работы.

Сначала вы вставляете идентификаторы родительского объекта в виде пакета в таблицу идентификаторов. Этот batch_id - это то, что мы генерируем в нашем приложении и удерживаем.

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

Теперь для каждого OneToManyстолбца вы просто делаете SELECTв таблице идентификаторов INNER JOINдочернюю таблицу с WHERE batch_id=(или наоборот). Вы просто хотите убедиться, что вы упорядочиваете по столбцу id, поскольку это упростит объединение столбцов результатов (в противном случае вам понадобится HashMap / Table для всего набора результатов, что может быть не так уж плохо).

Тогда вы просто периодически очищаете таблицу идентификаторов.

Это также работает особенно хорошо, если пользователь выбирает, скажем, 100 или около того отдельных элементов для некоторой массовой обработки. Поместите 100 различных идентификаторов во временную таблицу.

Теперь количество запросов, которые вы делаете, зависит от количества столбцов OneToMany.


1

Возьмите пример Matt Solnit, представьте, что вы определяете связь между Car и Wheels как LAZY, и вам нужны некоторые поля Wheels. Это означает, что после первого выбора, hibernate будет делать «Выбрать * из колес, где car_id =: id» ДЛЯ КАЖДОГО автомобиля.

Это делает первый выбор и более 1 выбор на каждую N машину, поэтому это называется проблемой n + 1.

Чтобы избежать этого, заставьте ассоциацию извлекаться как активную, чтобы hibernate загружал данные с объединением.

Но обратите внимание, если много раз вы не получаете доступ к связанным колесам, лучше оставить их LAZY или изменить тип выборки с помощью Criteria.


1
Опять же, объединения не являются хорошим решением, особенно когда может быть загружено более 2 уровней иерархии. Вместо этого отметьте «subselect» или «batch-size»; последний будет загружать дочерние элементы по родительским идентификаторам в предложении «in», например «select ... from wheel, где car_id in (1,3,4,6,7,8,11,13)».
Эрик Харт
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.