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

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

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

21 ответ

Решение

Допустим, у вас есть коллекция Car объекты (строки базы данных), и каждый Car имеет коллекцию Wheel объекты (также строки). Другими словами, Car -> Wheel это отношение 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.

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

List<PostComment> comments = entityManager.createQuery(
    "select pc " +
    "from PostComment pc " +
    "where pc.review = :review", PostComment.class)
.setParameter("review", review)
.getResultList();

LOGGER.info("Loaded {} comments", comments.size());

for(PostComment comment : comments) {
    LOGGER.info("The post title is '{}'", comment.getPost().getTitle());
}

Который генерирует следующие операторы SQL:

SELECT pc.id AS id1_1_, pc.post_id AS post_id3_1_, pc.review AS review2_1_
FROM   post_comment pc
WHERE  pc.review = 'Excellent!'

INFO - Loaded 3 comments

SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM   post pc
WHERE  pc.id = 1

INFO - The post title is 'Post nr. 1'

SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM   post pc
WHERE  pc.id = 2

INFO - The post title is 'Post nr. 2'

SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM   post pc
WHERE  pc.id = 3

INFO - The post title is 'Post nr. 3'

Во-первых, Hibernate выполняет запрос JPQL и список PostComment объекты извлечены.

Тогда для каждого PostComment, связанный post свойство используется для создания сообщения журнала, содержащего Post заглавие.

Поскольку post ассоциация не инициализирована, Hibernate должен получить Post сущность со вторичным запросом, а для N PostComment сущности, будет выполнено еще N запросов (отсюда проблема N+1 запросов).

Во-первых, вам нужна правильная регистрация и мониторинг SQL, чтобы вы могли обнаружить эту проблему.

Во-вторых, этот тип проблемы лучше поймать интеграционными тестами. Вы можете использовать автоматическое утверждение JUnit для проверки ожидаемого количества сгенерированных операторов SQL. Проект db-unit уже предоставляет эту функциональность и является открытым исходным кодом.

Когда вы определили проблему с запросом N+1, вам нужно использовать JOIN FETCH, чтобы дочерние ассоциации выбирались в одном запросе вместо N. Если вам нужно получить несколько дочерних ассоциаций, лучше выбрать одну коллекцию в начальном запросе, а вторую - вторичный SQL-запрос.

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 в том, что сложность запроса снижается, и вы можете использовать отложенную загрузку, когда дочерние наборы результатов загружаются только при первом запросе.

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

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

Я не могу комментировать другие ответы, потому что мне не хватает репутации. Но стоит отметить, что проблема, по сути, возникает только потому, что исторически, многие 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, попробуйте еще раз последние версии. Вы, вероятно, будете приятно удивлены.

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

Из-за этой проблемы мы отошли от 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.

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

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

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

Вот хорошее описание проблемы - https://web.archive.org/web/20160310145416/http://www.realsolve.co.uk/site/tech/hib-tip-pitfall.php?name=why-lazy

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Расходы:

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

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

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

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

Резюме

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

Проблема N+1 в Hibernate и Spring Data JPA

Проблема N+1 - это проблема производительности в реляционном сопоставлении объектов, которая запускает несколько запросов выбора (точнее N+1, где N = количество записей в таблице) в базе данных для одного запроса выбора на уровне приложения. Hibernate и Spring Data JPA предоставляет несколько способов отловить и решить эту проблему с производительностью.

Что такое проблема N+1?

Чтобы понять проблему N+1, давайте рассмотрим сценарий. Допустим, у нас есть набор объектов User, сопоставленных с таблицей DB_USER в базе данных, и у каждого пользователя есть коллекция или роль, сопоставленная с таблицей DB_ROLE с использованием таблицы соединения DB_USER_ROLE. На уровне ORM Пользователь имеет многие ко многим отношений с ролью .

      Entity Model
@Entity
@Table(name = "DB_USER")
public class User {

    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;
    private String name;

    @ManyToMany(fetch = FetchType.LAZY)                   
    private Set<Role> roles;
    //Getter and Setters 
 }

@Entity
@Table(name = "DB_ROLE")
public class Role {

    @Id
    @GeneratedValue(strategy= GenerationType.AUTO)
    private Long id;

    private String name;
    //Getter and Setters
 }

У пользователя может быть много ролей. Роли загружаются Лениво. Теперь предположим, что мы хотим получить всех пользователей из этой таблицы и распечатать роли для каждого из них . Очень наивная объектно-реляционная реализация могла быть - UserRepository с методом findAllBy

      public interface UserRepository extends CrudRepository<User, Long> {

    List<User> findAllBy();
}

Эквивалентные SQL-запросы, выполняемые ORM, будут:

Сначала получите всех пользователей (1)

      Select * from DB_USER;

Затем получите роли для каждого пользователя, выполненного N раз (где N - количество пользователей)

      Select * from DB_USER_ROLE where userid = <userid>;

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

Как это определить?

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

Если вы видите несколько записей для SQL для данного запроса выбора, то высока вероятность, что это связано с проблемой N+1.

Разрешение N+1

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

Fetch Join SQL, который извлекает все (пользователей и роли) в одном запросе

ИЛИ Обычный SQL

      select user0_.id, role2_.id, user0_.name, role2_.name, roles1_.user_id, roles1_.roles_id from db_user user0_ left outer join db_user_roles roles1_ on user0_.id=roles1_.user_id left outer join db_role role2_ on roles1_.roles_id=role2_.id

Hibernate и Spring Data JPA предоставляют механизм для решения проблемы N+1 ORM.

1. Подход Spring Data JPA:

Если мы используем Spring Data JPA, то у нас есть два варианта достижения этого - использование EntityGraph или использование запроса выбора с соединением выборки.

      public interface UserRepository extends CrudRepository<User, Long> {

    List<User> findAllBy();             

    @Query("SELECT p FROM User p LEFT JOIN FETCH p.roles")  
    List<User> findWithoutNPlusOne();

    @EntityGraph(attributePaths = {"roles"})                
    List<User> findAll();
}

Запросы N+1 выдаются на уровне базы данных с использованием выборки с левым соединением, мы решаем проблему N+1 с помощью attributePaths, Spring Data JPA позволяет избежать проблемы N+1

2. Подход к гибернации:

Если это чистый спящий режим, следующие решения будут работать.

Используя HQL :

      from User u *join fetch* u.roles roles roles

Использование Criteria API:

      Criteria criteria = session.createCriteria(User.class);
criteria.setFetchMode("roles", FetchMode.EAGER);

Все эти подходы работают одинаково, и они выдают аналогичный запрос к базе данных с выборкой с левым соединением.

Проблема, как говорили другие более элегантно, заключается в том, что у вас либо декартово произведение столбцов 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.

Не вдаваясь в подробности реализации технического стека, с архитектурной точки зрения существует как минимум два решения проблемы N + 1:

  • Есть только 1 - большой запрос - с объединениями. Это позволяет переносить большой объем информации из базы данных на уровень приложения, особенно при наличии нескольких дочерних записей. Типичный результат базы данных - это набор строк, а не граф объектов (есть решения для разных систем БД)
  • Имейте два (или более, если необходимо объединить больше дочерних элементов) запросов - 1 для родителя и после того, как они у вас появятся - запрос по идентификаторам детей и сопоставьте их. Это минимизирует передачу данных между уровнями DB и APP.

Обобщение N+1

Проблема N+1 — это специфическое для ORM название проблемы, в которой вы перемещаете циклы, которые могли бы разумно выполняться на сервере, на клиент. Общая проблема не специфична для ORM, вы можете столкнуться с ней с любым удаленным API. В этой статье я показал, как обходятся JDBC очень дорого , если вы вызываете API N раз, а не только 1 раз. Разница в примере заключается в том, вызываете ли вы процедуру Oracle PL/SQL:

  • (вызовите один раз, получите N предметов)
  • (назовите это N раз, каждый раз получайте 1 предмет)

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

Случай ОРМ

Фактически, проблема ORM-y N+1 даже не является специфичной для ORM, вы можете решить ее, запустив свои собственные запросы вручную, например, когда вы делаете что-то подобное в PL/SQL:

      -- This loop is executed once
for parent in (select * from parent) loop

  -- This loop is executed N times
  for child in (select * from child where parent_id = parent.id) loop
    ...
  end loop;
end loop;

Было бы намного лучше реализовать это с помощью соединения (в данном случае):

      for rec in (
  select *
  from parent p
  join child c on c.parent_id = p.id
)
loop
  ...
end loop;

Теперь цикл выполняется только один раз, а логика цикла перенесена с клиента (PL/SQL) на сервер (SQL), который может даже оптимизировать его по-другому, например, запустив хеш-соединение ( ), а не соединение с вложенным циклом ( с индексом)

Автоматическое обнаружение проблем N+1

Если вы используете JDBC, вы можете использовать jOOQ в качестве прокси-сервера JDBC за кулисами для автоматического обнаружения ваших проблем N+1. Анализатор jOOQ нормализует ваши SQL-запросы и кэширует данные о последовательном выполнении родительских и дочерних запросов. Это работает даже в том случае, если ваши запросы не совсем одинаковы, но семантически эквивалентны.

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

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

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

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

ORM «N плюс один» Проблема

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

Чтобы понять проблему «N плюс один», давайте рассмотрим пример сценария, в котором у вас есть две таблицы: и . У каждого клиента может быть несколько заказов, и между ними существует связь «один ко многим».истолы. В ORM вы определяете эти отношения, используя объектно-ориентированные концепции, такие как классы и ссылки.

Теперь предположим, что вы хотите получить всех клиентов вместе с их заказами. В ORM вы можете использовать такой запрос:

      customers = Customer.objects.all()

for customer in customers:
    orders = customer.orders.all()
    # Do something with the orders

В этом коде вы сначала получаете всех клиентов, используя. Затем для каждого клиента вы извлекаете его заказы, используя.

Проблема с этим подходом заключается в том, что он приводит к выполнению нескольких запросов к базе данных. Например, если у вас 100 клиентов, этот код выполнит 101 запрос: один для получения всех клиентов и еще 100 для получения заказов для каждого клиента (отсюда и название проблемы «N плюс один»). Это может существенно повлиять на производительность, особенно при работе с большими наборами данных.

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

Чтобы смягчить проблему «N плюс один», платформы ORM обычно предоставляют способы оптимизации извлечения данных, такие как быстрая загрузка или явные соединения . Оперативная загрузка позволяет получать необходимые данные за один запрос, сокращая количество обращений к базе данных. Указав отношения, которые вы хотите включить, платформа ORM может сгенерировать более эффективный запрос, который одновременно извлекает все необходимые данные.

В качестве демонстрации проблемы «N плюс один» и ее решения ниже показан фактический SQL, созданный из ORM с использованием SQLAlchemy.

Исходный запрос ORM с проблемой N плюс одна (1 запрос для клиентов и N для каждого заказа клиента):

      with Session(engine) as session:
    customers = session.scalars(select(Customer))
    for customer in customers:
        print(f"> Customer: #{customer.customer_id}")
        for order in customer.orders:
            print(f">   order #{order.order_id} at {order.order_datetime}")
      -- This query gets all customers:
SELECT customer.customer_id, ...
FROM customer

-- The following SQL is executed once for each customer:
SELECT "order".order_id AS order_order_id, ...
FROM "order"
WHERE "order".customer_id = %(param_1)s

После указания активной загрузки), требуется всего 2 запроса:

      with Session(engine) as session:
    customers = session.scalars(
        select(Customer).options(selectinload(Customer.orders)))
    for customer in customers:
        print(f"> Customer: #{customer.customer_id}")
        for order in customer.orders:
            print(f">   order #{order.order_id} at {order.order_datetime}")
      SELECT customer.customer_id, ...
FROM customer

-- This loads all the orders you need in one query:
SELECT "order".order_id AS order_order_id, ...
FROM "order"
WHERE "order".customer_id IN (%(primary_keys_1)s, %(primary_keys_2)s, ...)

Или явно соединитесь и запросите необходимые поля (требуется только 1 запрос):

      with Session(engine) as session:
    stmt = (
        select(
            Customer.customer_id,
            Order.order_id,
            Order.order_datetime,
        )
        .select_from(Customer)
        .join(Customer.orders)
        .order_by(Customer.customer_id)
    )
    results = session.execute(stmt)

    current_customer_id = None
    for row in results:
        customer_id = row.customer_id
        if current_customer_id != customer_id:
            current_customer_id = customer_id
            print(f"> Customer: #{current_customer_id}")
        print(f">   order #{row.order_id} at {row.order_datetime}")
      SELECT customer.customer_id, "order".order_id, ...
FROM customer
JOIN "order" ON customer.customer_id = "order".customer_id
ORDER BY customer.customer_id

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

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

Я создал библиотеку с открытым исходным кодом jplusone для решения этих проблем в Java-приложениях Spring Boot на основе JPA. Библиотека предоставляет две основные функции:

  1. Создает отчеты, связывающие операторы SQL с выполнением операций JPA, которые их инициировали, и помещает в исходный код вашего приложения, которые были задействованы в нем.
2020-10-22 18:41:43.236 DEBUG 14913 --- [           main] cajcore.report.ReportGenerator:
    Корень
        com.adgadev.jplusone.test.domain.bookshop.BookshopControllerTest.shouldGetBookDetailsLazily(BookshopControllerTest.java:65)
        com.adgadev.jplusone.test.domain.bookshop.BookshopController.getSampleBookUsingLazyLoading(BookshopController.java:31)
        com.adgadev.jplusone.test.domain.bookshop.BookshopService.getSampleBookDetailsUsingLazyLoading [PROXY]
            ГРАНИЦА СЕССИИ
                ОПЕРАЦИЯ [ПОДРАЗУМЕВАЕМЫЕ]
                    com.adgadev.jplusone.test.domain.bookshop.BookshopService.getSampleBookDetailsUsingLazyLoading(BookshopService.java:35)
                    com.adgadev.jplusone.test.domain.bookshop.Author.getName [ПРОКСИ]
                    com.adgadev.jplusone.test.domain.bookshop.Author [FETCHING ENTITY]
                        ЗАЯВЛЕНИЕ [ПРОЧИТАТЬ]
                            выберите из
                                автор author0_
                                левое внешнее соединение жанра genre1_ на author0_.genre_id=genre1_.id
                            где
                                author0_.id=1
                ОПЕРАЦИЯ [ПОДРАЗУМЕВАЕМЫЕ]
                    com.adgadev.jplusone.test.domain.bookshop.BookshopService.getSampleBookDetailsUsingLazyLoading(BookshopService.java:36)
                    com.adgadev.jplusone.test.domain.bookshop.Author.countWrittenBooks(Автор.java:53)
                    com.adgadev.jplusone.test.domain.bookshop.Author.books [ПОЛУЧЕНИЕ КОЛЛЕКЦИИ]
                        ЗАЯВЛЕНИЕ [ПРОЧИТАТЬ]
                            выберите из
                                книга books0_
                            где
                                books0_.author_id=1
  1. Предоставляет API, который позволяет писать тесты, проверяющие, насколько эффективно ваше приложение использует JPA (т.е. утверждает количество ленивых операций загрузки)
@SpringBootTest
class LazyLoadingTest {

    @Autowired
    private JPlusOneAssertionContext assertionContext;

    @Autowired
    private SampleService sampleService;

    @Test
    public void shouldBusinessCheckOperationAgainstJPlusOneAssertionRule() {
        JPlusOneAssertionRule rule = JPlusOneAssertionRule
                .within().lastSession()
                .shouldBe().noImplicitOperations().exceptAnyOf(exclusions -> exclusions
                        .loadingEntity(Author.class).times(atMost(2))
                        .loadingCollection(Author.class, "books")
                );

        // trigger business operation which you wish to be asserted against the rule,
        // i.e. calling a service or sending request to your API controller
        sampleService.executeBusinessOperation();

        rule.check(assertionContext);
    }
}
Другие вопросы по тегам