Що таке проблема N+1 і як її вирішити
У результаті замість одного швидкого SQL-запиту зі з'єднанням таблиць (JOIN) застосунок лавиноподібно надсилає в базу даних десятки або сотні дрібних запитів:
🟢 Junior Level
Що таке проблема N+1
Проблема N+1 (N+1 Query Problem) — це класична архітектурна пастка об’єктно-реляційного відображення (ORM), за якої для завантаження $N$ батьківських сутностей та пов’язаних із ними дочірніх об’єктів Hibernate виконує 1 початковий запит для вибірки списку батьків і потім $N$ додаткових SQL-запитів (по одному на кожного батька) для підвантаження дочірніх записів.
У результаті замість одного швидкого SQL-запиту зі з’єднанням таблиць (JOIN) застосунок лавиноподібно надсилає в базу даних десятки або сотні дрібних запитів:
Без оптимізації (Проблема N+1):
1. SELECT * FROM orders; -- (1 запит, повернув 100 замовлень)
2. SELECT * FROM order_items WHERE order_id = 1; -- (Запит для замовлення 1)
3. SELECT * FROM order_items WHERE order_id = 2; -- (Запит для замовлення 2)
...
101. SELECT * FROM order_items WHERE order_id = 100; -- (Запит для замовлення 100)
РАЗОМ: 101 мережевий round-trip до бази даних!
З правильним рішенням (JOIN FETCH):
1. SELECT o.*, i.* FROM orders o
LEFT JOIN order_items i ON o.id = i.order_id; -- РАЗОМ: РІВНО 1 ЗАПИТ!
Як це виглядає в Java-коді
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// За замовчуванням для зв'язків *ToMany fetch-стратегія є LAZY
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
// гетери, сетери
}
// Код сервісу:
List<Order> orders = entityManager.createQuery("SELECT o FROM Order o", Order.class)
.getResultList(); // Виконується 1 запит: SELECT * FROM orders
for (Order order : orders) {
// Кожне перше звернення до колекції order.getItems() ініціює окремий SQL-запит!
System.out.println("Замовлення " + order.getId() + " містить позицій: " + order.getItems().size());
}
Симптоми проблеми в застосунку
- Різке падіння продуктивності та зростання часу відповіді API (Latency) при збільшенні кількості записів у таблиці.
- Логи пулу з’єднань (HikariCP) показують вичерпання вільних конектів (
ConnectionTimeoutException). - У логах SQL-запитів (
spring.jpa.show-sql=true) з’являються нескінченні простирадла однаковихSELECT ... WHERE parent_id = ?.
🟡 Middle Level
Способи вирішення проблеми N+1
Існує п’ять основних способів усунення проблеми N+1, кожен з яких має свої межі застосування.
1. Використання JOIN FETCH у JPQL / HQL
Найпростіший і найпопулярніший спосіб — явна вказівка інструкції JOIN FETCH, яка інструктує Hibernate завантажити пов’язані сутності в тому самому SQL-запиті:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items")
List<Order> findAllWithItems();
}
Hibernate сформує єдиний запит:
SELECT o.id, o.created_at, i.id, i.order_id, i.price, i.product_name
FROM orders o
LEFT JOIN order_items i ON o.id = i.order_id;
Нюанс Hibernate 5 vs 6: У Hibernate 5 при
JOIN FETCHколекцій вимагалося писатиSELECT DISTINCT o, інакше результуючий списокList<Order>містив дублікати одного й того ж об’єктаOrder(за кількістю дочірніхOrderItem). Починаючи з Hibernate 6, дедуплікація кореневих сутностей у пам’яті застосунку виконується автоматично.
2. Динамічні графи сутностей (@EntityGraph)
Дозволяє перевизначити стратегію FetchType.LAZY на рівні конкретного методу репозиторію без дублювання JPQL-запитів:
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"items"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithGraph();
}
Властивості графа:
jakarta.persistence.fetchgraph— усі атрибути, вказані в графі, завантажуються якEAGER, а всі інші примусово стаютьLAZY.jakarta.persistence.loadgraph— атрибути графа завантажуються якEAGER, а не вказані атрибути зберігають свої дефолтні анотації в Entity.
3. Пакетне завантаження через @BatchSize
Якщо пов’язані дані потрібні не завжди, а лише за певних умов, JOIN FETCH надлишковий. Анотація @BatchSize вказує Hibernate об’єднувати ліниві запити в групи з використанням оператора IN:
@Entity
public class Order {
@Id
private Long id;
@BatchSize(size = 30)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
Замість 100 окремих запитів Hibernate виконає лише 4 запити (батчами по 30 і залишок 10):
SELECT * FROM order_items WHERE order_id IN (?, ?, ?, ... 30 параметрів);
Глобально для всього застосунку параметр можна ввімкнути в application.yml:
spring:
jpa:
properties:
hibernate.default_batch_fetch_size: 30
4. Підзапити @Fetch(FetchMode.SUBSELECT)
@Entity
public class Order {
@Id
private Long id;
@org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
}
При першому зверненні до колекції будь-якого замовлення Hibernate виконає рівно один додатковий запит, повторивши вихідний критерій вибірки батьків:
SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders);
5. DTO-проєкції (Best Practice для читання)
Для сценаріїв Read-Only (виведення списків, звітів, UI-таблиць) завантажувати повні керовані сутності (Managed Entities) непотрібно. Пряма проєкція в Java Record або DTO через конструктор працює найшвидше:
public record OrderSummaryDto(Long orderId, String customerName, Long totalItems) {}
@Query("""
SELECT new com.example.dto.OrderSummaryDto(o.id, o.customerName, COUNT(i.id))
FROM Order o
LEFT JOIN o.items i
GROUP BY o.id, o.customerName
""")
List<OrderSummaryDto> findSummaries();
🔴 Senior Level
Внутрішня будова: Механіка Proxies та PersistenceContext
Під капотом проблема N+1 обумовлена архітектурою відкладеної ініціалізації (Lazy Loading) через динамічні проксі (ByteBuddy в сучасному Hibernate):
1. Виклик entityManager.createQuery("SELECT o FROM Order o").getResultList():
- Hibernate наповнює PersistenceContext (L1 Cache) екземплярами Entity Order.
- Поле items ініціалізується не ArrayList, а PersistentBag (імплементація PersistentCollection).
- Всередині PersistentBag прапорець initialized = false, а посилання на Session збережено.
2. При виклику order.getItems().size() (або .get(0), або ітерації for-each):
- PersistentBag перехоплює виклик методу.
- Перевіряє: if (!initialized).
- Отримує поточну Session і генерує динамічний SQL-запит для поточного ідентифікатора owner.id.
- Заповнює внутрішню колекцію елементами і виставляє initialized = true.
- Оскільки у кожного Order свій власний екземпляр PersistentBag, кожен із них виконує цей цикл незалежно!
Критична пастка: Пагінація та JOIN FETCH (HHH000104)
Найнебезпечніша помилка сеньйор-рівня — спроба вирішити N+1 за допомогою JOIN FETCH за наявності постраничного виведення (Pageable / setFirstResult / setMaxResults):
// НЕБЕЗПЕЧНО ДЛЯ PRODUCTION!
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items")
Page<Order> findOrders(Pageable pageable);
Що відбувається в базі даних та пам’яті JVM:
- Реляційна СУБД при виконанні
JOINповертає декартовий добуток рядків: якщо замовлення має 5 позицій, база поверне 5 рядків для одного замовлення. - Якщо застосувати SQL-ліміт
LIMIT 10 OFFSET 0, база даних відітне перші 10 рядків результатів джойну, тобто поверне 2 повних замовлення і половину позицій третього замовлення! Hibernate не зможе коректно зібрати сутність. - Тому Hibernate змушений видалити
LIMITіOFFSETз SQL-запиту і завантажити ВСЮ таблицю цілком у пам’ять JVM! - У логах з’являється фатальне попередження:
WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory! - Якщо в таблиці 1 000 000 замовлень, застосунок викачає сотні мегабайтів мережею, викличе гігантську паузу GC і впаде з
OutOfMemoryError.
Правильне вирішення пагінації без N+1:
Використовувати двоетапний запит або @BatchSize / default_batch_fetch_size:
// 1. Вибираємо тільки ID замовлень з пагінацією (LIMIT/OFFSET виконується на рівні БД):
@Query("SELECT o.id FROM Order o")
Page<Long> findOrderIds(Pageable pageable);
// 2. Отримуємо повні замовлення за списком ID з JOIN FETCH (без пагінації):
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findOrdersWithItemsByIds(@Param("ids") List<Long> ids);
Або виконувати звичайний SELECT o FROM Order o з Pageable, налаштувавши hibernate.default_batch_fetch_size: 50.
Проблема декартового добутку (MultipleBagFetchException)
Спроба зробити одночасний JOIN FETCH для двох і більше колекцій типу List (Bag):
@Query("SELECT o FROM Order o JOIN FETCH o.items JOIN FETCH o.payments")
List<Order> findBroken();
Призводить до викидання винятку:
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags.
- Якщо у замовлення 10 позицій (
items) і 10 платежів (payments), результат джойну поверне $10 \times 10 = 100$ рядків на одне замовлення. - Передача надлишкових дублікатів забиває пропускну здатність мережі та пам’ять.
- Рішення: Завантажувати одну колекцію через
JOIN FETCH, а другу — через@BatchSize, або розділяти на два послідовні запити в межах однієї транзакції (Hibernate зв’яже сутності вPersistenceContextза посиланнями).
N+1 у зв’язках @OneToOne
Поширена помилкова думка: «FetchType.LAZY завжди захищає від N+1».
Для зв’язку @OneToOne з боку, що не є власником зовнішнього ключа (non-owning side, mappedBy), ліниве завантаження не працює за замовчуванням:
@Entity
public class User {
@Id
private Long id;
// У таблиці users немає колонки profile_id! Зовнішній ключ user_id знаходиться в таблиці profiles
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
private UserProfile profile;
}
Щоб створити проксі-об’єкт UserProfile, Hibernate зобов’язаний знати: існує профіль чи там null. Але в рядку таблиці users немає інформації про це! Тому Hibernate змушений негайно виконати SELECT * FROM profiles WHERE user_id = ?.
При вибірці 100 користувачів Hibernate виконає 100 запитів до таблиці profiles, незважаючи на явну вказівку FetchType.LAZY!
4 Tricky Questions
1. Чому FetchType.EAGER не лише не вирішує проблему N+1, але й гарантовано її посилює?
Відповідь:
- Якщо зв’язок позначений як
FetchType.EAGER, анотація повідомляє Hibernate: «Ці пов’язані дані мають завантажуватися завжди при читанні сутності». - Коли сутність завантажується через
entityManager.find(Order.class, 1L), Hibernate може згенеруватиLEFT OUTER JOINодразу. - Але коли виконується запит на JPQL/HQL (
SELECT o FROM Order o), парсер JPQL буквально виконує написаний розробником запит: «Вибрати сутності Order». - Після того як початковий запит повернув $N$ замовлень, Hibernate бачить анотацію
EAGERі розуміє, що зобов’язаний підвантажити залежності для кожного з них. - Оскільки у вихідному JPQL не було інструкції
JOIN FETCH, Hibernate виконує $N$ додаткових SELECT-запитів для кожного рядка. - На відміну від
LAZY, де розробник може уникнути додаткових запитів, просто не звертаючись до гетера, приEAGERці $N$ запитів будуть виконані завжди й безумовно, навіть якщо дочірні дані взагалі непотрібні викликаючому коду.
2. Як виявити проблему N+1 в автоматичних тестах (CI/CD pipeline) до потрапляння коду в production?
Відповідь: Покладатися на ручний перегляд логів SQL ненадійно. В індустрії застосовуються три підходи до автоматизації:
- Бібліотека QuickPerf: Дозволяє вішати анотації поверх JUnit-тестів із лімітом запитів:
@Test @ExpectSelect(1) // Тест впаде, якщо виконається 2 або більше SELECT void shouldFindAllOrdersWithoutNPlusOne() { orderService.findAllOrders(); } - Перехоплювач DataSource (
datasource-proxy): Обгортає реальнийDataSourceтестового профілю і рахує викликиexecuteQuery. Наприкінці тесту assertions перевіряють, що кількість запитів не перевищує очікувану константу. - Hypersistence Optimizer (автор — Vlad Mihalcea): Аналізує Hibernate Event Listeners та конфігурації JPA під час тестів і викидає асерти при виявленні типових антипатернів (N+1, EAGER-зв’язки, відсутність batching).
3. Чому заміна List на Set для колекцій усуває MultipleBagFetchException, але створює приховану загрозу продуктивності?
Відповідь:
Hibernate дозволяє завантажувати кілька зв’язків через JOIN FETCH, якщо колекції оголошені як Set замість List:
@OneToMany(mappedBy = "order")
private Set<OrderItem> items;
@OneToMany(mappedBy = "order")
private Set<Payment> payments;
- З формальної точки зору Hibernate компілює запит із кількома
JOIN FETCH, оскількиSetусуває невизначеність порядку елементів (Bag). - Прихована катастрофа (Cartesian Product): Якщо у замовлення 20 позицій і 20 платежів, база поверне $20 \times 20 = 400$ рядків. Якщо колекцій три по 20 елементів — повернеться $20 \times 20 \times 20 = 8000$ рядків на одне замовлення!
- Декартове множення роздуває мережевий трафік між БД і бекендом до сотень мегабайтів і змушує процесор витрачати секунди на обчислення
hashCode()таequals()для заповненняHashSet. - Висновок: Використання
Setдля обходуMultipleBagFetchException— грубий антипатерн; колекції слід розділяти на незалежні запити.
4. У чому різниця в поведінці jakarta.persistence.fetchgraph та jakarta.persistence.loadgraph?
Відповідь: Обидва хінти передаються при виконанні запиту через EntityGraph, але вони по-різному трактують атрибути сутності, не включені в граф:
fetchgraph(Суворий режим): Атрибути, явно зазначені в графі, завантажуються якEAGER(через JOIN). Усі інші атрибути сутності примусово трактуються якLAZY, навіть якщо в анотації над полем жорстко прописаноfetch = FetchType.EAGER.loadgraph(М’який режим): Атрибути, явно зазначені в графі, завантажуються якEAGER. Усі інші атрибути зберігають той режим завантаження, який вказано у них в Entity (тобто поля зEAGERусе одно завантажаться жадібно, а поля зLAZYзалишаться лінивими).
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«Проблема N+1 виникає в ORM, коли для вибірки $N$ батьківських сутностей виконується 1 вихідний запит, а потім ще $N$ додаткових запитів для вичитування пов’язаних даних при зверненні до неініціалізованих проксі-об’єктів. Головні способи вирішення: використання
JOIN FETCHу JPQL або@EntityGraphдля об’єднання в один SQL-запит, або ввімкнення@BatchSize/default_batch_fetch_sizeдля групування запитів черезIN (?, ?, ...). Для завдань тільки на читання найкращим патерном є використання DTO-проєкцій. Критична пастка: використанняJOIN FETCHз пагінацією призводить до вивантаження всієї таблиці в пам’ять JVM (HHH000104), тому пагінація колекцій повинна виконуватися у два кроки або через пакетну вибірку».
Ключові метрики для Production-моніторингу
hibernate.stat.query_execution_count: загальна кількість виконаних SQL-запитів на один HTTP-запит (норма в REST API: 1–5 запитів; якщо сотні — це N+1).HikariCP: PendingConnections: черга очікування з’єднань через їх швидку утилізацію лавиною N+1 запитів.- Метрики
p6spy/datasource-proxyза довжиною сесій і кількістю звернень до бази.
Червоні прапорці (Чого говорити НЕ МОЖНА)
- ❌ «Щоб не було N+1, я завжди ставлю fetch = FetchType.EAGER» — Це фундаментальна помилка: EAGER не усуває N+1 у JPQL, але гарантує, що запити виконуватимуться завжди, навіть коли дані не потрібні.
- ❌ «JOIN FETCH можна безпечно використовувати скрізь, навіть з Pageable» — Призводить до викачування всієї бази в пам’ять застосунку (
HHH000104) таOutOfMemoryError. - ❌ «Щоб завантажити кілька колекцій одразу, я просто замінюю List на Set» — Це призводить до вибухового декартового добутку (Cartesian Product) та деградації продуктивності.
- ❌ «Проблему N+1 можна повністю вирішити лише індексами в базі даних» — Індекси прискорюють одиничний запит, але не зменшують кількість мережевих round-trips від ORM до БД.