Що таке "N + 1 вибирає проблему" в ORM (об'єктно-реляційне картографування)?


1596

"Проблема вибору N + 1" зазвичай називається проблемою в дискусіях "Об'єктно-реляційне відображення (ORM)", і я розумію, що це має відношення до того, що потрібно робити багато запитів до бази даних для чогось, що здається простим в об'єкті світ.

Хтось має більш детальне пояснення проблеми?


2
Це чудова посилання з приємним поясненням щодо розуміння проблеми n + 1 . Він також охоплює рішення для вирішення цього питання: architects.dzone.com/articles/how-identify-and-resilve-n1
тузи.

Є кілька корисних публікацій, які розповідають про цю проблему та можливе виправлення. Найпоширеніші проблеми програми та як їх виправити: вибір проблеми N + 1 , (срібна) куля для проблеми N + 1 ,
ледача

Для всіх, хто шукає рішення цієї проблеми, я знайшов публікацію, в якій описав її. stackoverflow.com/questions/32453989 / ...
damndemon

2
Розглядаючи відповіді, чи не слід це називати проблемою 1 + N? Оскільки це здається термінологією, я конкретно не прошу ОП.
користувач1418717

Відповіді:


1015

Скажімо, у вас є колекція 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 із сплячки , глава 13.


139
Щоб уточнити "Це погано" - ви можете отримати всі колеса за допомогою 1 select ( SELECT * from Wheel;), а не N + 1. З великим N ударний показник може бути дуже значним.
tucuxi

211
@tucuxi Я здивований, що ти отримав так багато грошей за помилки. База даних дуже добре стосується індексів, і запит на певний CarID повернеться дуже швидко. Але якщо ви отримали всі колеса один раз, вам доведеться шукати CarID у вашій програмі, яка не індексується, це повільніше. Якщо у вас є основні проблеми із затримкою, коли ваша база даних переходить на n + 1 насправді швидше - і так, я визначив це великою різноманітністю реального коду світу.
Аріель

73
@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 повинен заповнити екземпляр дому з ідентифікатором = 1, адресою = "22 Valley St", а потім заповнити масив мешканців екземплярами People для Дейва, Джона та Майка лише одним запитом.

Запит 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
@Ariel - я думаю, я це прекрасно зрозумів :). Я просто намагаюся зазначити, що ваш результат стосується лише одного набору умов. Я міг легко побудувати зустрічний приклад, який показав протилежне. Чи має це сенс?
whitneyland

13
Знову повторюю, проблема SELECT N + 1 полягає в своїй суті: у мене є 600 записів для отримання. Чи швидше отримати всі 600 за один запит, або 1 за один раз у 600 запитів. Якщо ви не в MyISAM та / або у вас погано нормалізована / погано індексована схема (у цьому випадку ORM не проблема), правильно налаштований db поверне 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      |
+-----+-----------+--------------------+-------+------------+

Фактори:

  • Лінивий режим для Постачальника встановлено на "вірно" (за замовчуванням)

  • Режим завантаження, який використовується для запиту на продукт, є Select

  • Режим завантаження (за замовчуванням): доступ до інформації про постачальника

  • Кешування не грає ролі вперше

  • Доступ до постачальника

У режимі "Вибрати" вибрано Вибрати

// 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
FYI, один з 3 поширених JOINалгоритмів, використовуваних в RDBMS ', називається вкладеними петлями. По суті це вибір N + 1 під кришкою. Єдина відмінність полягає в тому, що БД зробив розумний вибір використовувати його на основі статистики та індексів, а не клієнтський код категорично змушуючи його пройти цей шлях.
Брендон

2
@Brandon Так! Так само, як і підказки JOIN та підказки INDEX, примушування до певного шляху виконання у всіх випадках рідко обіграє базу даних. База даних майже завжди дуже і дуже добре підбирає оптимальний підхід до отримання даних. Можливо, в перші дні dbs вам потрібно було «сформулювати» своє запитання своєрідним способом, щоб придушити db, але через десятиліття інженерного світового класу ви можете зараз отримати найкращу ефективність, задавши свою базу даних реляційним питанням і відпустивши її розібратися, як отримати та зібрати ці дані для вас.
Собаки

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

27

Ми віддалилися від ОРМ у Джанго через цю проблему. В основному, якщо спробувати і зробити

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
Ми віддалилися від ОРМ у Джанго через цю проблему. Так? Django має select_related, що має на меті вирішити це - насправді, його документи починаються з прикладу, подібного до вашого p.car.colourприкладу.
Адріан17

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> </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 або критеріїв:

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 або критеріїв визначає явний план, який в режимі глибокого сну не може змінити, автоматично вводячи JOIN FETCH. Отже, робити це потрібно вручну.

Якщо вам взагалі не потрібна 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
У мене є стаття і для цього.
Влад Михальча

Дякую за статтю Я прочитаю. Швидкою прокруткою - я побачив, що рішення - це функція Window Function, але вони є досить новими в MariaDB - тому проблема зберігається у старих версіях. :)
CappY

@VladMihalcea, я вказував або зі своєї статті, або з посту кожного разу, коли ви звертаєтесь до справи ManyToOne, пояснюючи проблему N + 1. Але насправді людей найбільше цікавить справа OneToMany, що стосується питання N + 1. Чи можете ви зверніться та поясніть випадок OneToMany?
JJ Промінь

18

Припустимо, у вас є КОМПАНІЯ та ПРАЦІВНИК. КОМПАНІЯ має багато ПРАЦІВНИКІВ (тобто EMPLOYEE має поле COMPANY_ID).

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

Так працювали початкові версії EJB Entity Beans. Я вважаю, що такі речі, як сплячка, це покінчили, але я не надто впевнений. Більшість інструментів, як правило, включають інформацію щодо своєї стратегії картографування.


18

Ось хороший опис проблеми

Тепер, коли ви розумієте проблему, її, як правило, можна уникнути, виконавши приєднання до запиту. Це в основному змушує отримати об'єкт із ледачим завантаженням, тому дані отримуються в одному запиті замість n + 1 запитів. Сподіваюсь, це допомагає.


17

Перевірте публікацію Ayende на тему: Боротьба з проблемою Select N + 1 в NHibernate .

В основному, коли ви використовуєте ORM, як NHibernate або EntityFramework, якщо у вас є відносини «багато-багато» (головна деталізація) і хочете перерахувати всі деталі для кожної основної записи, вам потрібно зробити N + 1 запитів на дзвінки до база даних, "N" - це кількість головних записів: 1 запит на отримання всіх основних записів, і N запитів, по одному на основний запис, для отримання всіх деталей на основний запис.

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

Однак у ORM є варіанти уникнути цієї проблеми, в основному, використовуючи JOIN.


3
з'єднання не є хорошим рішенням (часто), оскільки вони можуть призвести до отримання декартового продукту, тобто кількість рядків результатів - кількість результатів кореневої таблиці, помножене на кількість результатів у кожній дочірній таблиці. особливо погано на кількох рівнях іерархії. Вибір 20 "блогів" зі 100 "публікаціями" на кожен та 10 "коментарів" до кожної публікації призведе до 20000 рядків результатів. NHibernate має обхідні шляхи, як, наприклад, "розмір партії" (виберіть дітей із пунктом у батьківських ідентифікаторах) або "підмінити".
Ерік Харт

14

Набагато швидше видати 1 запит, який повертає 100 результатів, ніж 100 запитів, кожен з яких повертає 1 результат.


13

На мою думку, стаття, написана у сплячому сплячку: Чому стосунки слід ліниво, - це прямо протилежне реального питання N + 1.

Якщо вам потрібні правильні пояснення, зверніться до сплячого режиму - Глава 19: Підвищення продуктивності - Досягнення стратегій

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


2
я читаю сплячу сторінку. Він не говорить , що проблема N + 1 вибирає на насправді є . Але він говорить, що ви можете використовувати приєднання, щоб виправити це.
Ян Бойд

3
batch-size потрібен для вибору вибору, щоб вибрати дочірні об’єкти для декількох батьків в одному операторі вибору. Підбір може бути іншою альтернативою. Приєднання може стати дуже поганим, якщо у вас є кілька рівнів ієрархії та створений декартовий продукт.
Ерік Харт

10

Подане посилання містить дуже простий приклад проблеми n + 1. Якщо застосувати його до сплячого режиму, то це, в основному, те саме. Коли ви запитуєте об'єкт, об'єкт завантажується, але будь-які асоціації (якщо не налаштовано інше) будуть ледаче завантажені. Звідси один запит для кореневих об'єктів та інший запит для завантаження асоціацій для кожного з них. 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 разів за секунду випадковим доступом на hdd). Швидкий вибір приєднання також отримав би доступ до тарілки 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 Selects. Будь-який можливий гігантський набір результатів або чат із базою даних відповідно.

Я здивований, що це не згадується, але ось як я обійшов цю проблему ... Я складаю напівчасову таблицю ідентифікаторів . Я також це роблю, коли у вас є IN ()обмеження пункту .

Це працює не у всіх випадках (можливо, навіть не в більшості), але це працює особливо добре, якщо у вас є багато дочірніх предметів, які декартові вироби вийдуть з ладу (тобто багато OneToMany стовпців, кількість результатів буде множення стовпців) і більше, ніж подібне завдання.

Спочатку ви вставляєте ідентифікатори батьківського об’єкта як пакетні в таблицю id. Цей 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

Візьмемо для прикладу Мет Солніт, уявіть, що ви визначаєте асоціацію між Автомобілем та Колесами як ЛАЗІ, і вам потрібно кілька полів Wheels. Це означає, що після першого вибору сплячий режим зробить "Вибір * з колес, де car_id =: id" ДЛЯ КОЖНОГО Автомобіля.

Це робить перший вибір і ще 1 вибір кожного автомобіля N, тому його називають проблемою n + 1.

Щоб цього не сталося, зробіть вибір асоціації якою нетерплячою, щоб сплячий режим завантажував дані при з'єднанні.

Але зверніть увагу, якщо ви багато разів не отримуєте доступ до пов’язаних коліс, краще тримати його ЛЕГИЙ або змінити тип вибору з критеріями.


1
Знову ж таки, приєднання не є хорошим рішенням, особливо коли може бути завантажено більше 2 рівнів ієрархії. Установіть прапорець "під вибір" або "розмір партії"; останнє завантажить дітей за ідентифікаторами батьків у пункті "in", наприклад "вибрати ... з коліс, де car_id в (1,3,4,6,7,8,11,13)".
Ерік Харт
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.