Що таке проблема N+1 і як її вирішити
Проблема N+1 — одна з найпоширеніших проблем продуктивності в Hibernate. Вона виникає при неправильному завантаженні пов'язаних сутностей і призводить до лінійного зростання кіл...
Огляд
Проблема N+1 — одна з найпоширеніших проблем продуктивності в Hibernate. Вона виникає при неправильному завантаженні пов’язаних сутностей і призводить до лінійного зростання кількості SQL-запитів: 1 запит на батька + N запитів на дочірні сутності.
🟢 Junior Level
Що таке проблема N+1
Це ситуація, коли Hibernate виконує один запит для отримання списку об’єктів, а потім N додаткових запитів (по одному на кожен об’єкт) для завантаження пов’язаних даних.
@Entity
public class Order {
@Id
private Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
}
// Проблема N+1:
List<Order> orders = entityManager.createQuery("SELECT o FROM Order o", Order.class)
.getResultList(); // 1 запит: SELECT * FROM orders
for (Order order : orders) {
order.getItems().size(); // N запитів: SELECT * FROM order_items WHERE order_id = ?
}
// Разом: 1 + N = N+1 запитів!
// Якщо 100 замовлень → 101 запит до БД
Як розпізнати
- У логах видно безліч однакових SELECT-запитів
- Додаток працює повільно при завантаженні колекцій
- Кількість запитів пропорційна кількості записів
Базові способи вирішення
- JOIN FETCH — об’єднати в один запит
- @BatchSize — завантажити пакетами
- EntityGraph — динамічно вказати що завантажувати
Коли НЕ використовувати кожен підхід до вирішення N+1
- JOIN FETCH — не використовувати при кількох колекціях (Cartesian product)
- @BatchSize — не використовувати коли дані потрібні ЗАВЖДИ (краще JOIN FETCH)
- EntityGraph — не використовувати в простих CRUD-операціях (overhead на створення графа)
- Subselect — не використовувати коли батьки завантажувались по одному (Hibernate fallback до SELECT)
🟡 Middle Level
Детальні рішення
1. JOIN FETCH в JPQL
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();
// Генерує один запит:
// SELECT DISTINCT o.* FROM orders o
// JOIN order_items oi ON o.id = oi.order_id
Важливо використовувати DISTINCT — без нього Hibernate поверне дублікати Order (по одному на кожен OrderItem).
2. @BatchSize
@Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 10)
private List<OrderItem> items;
}
// Замість N запитів: N/10 запитів
// Hibernate: SELECT * FROM order_items WHERE order_id IN (?,?,?,?,?,?,?,?,?,?)
3. EntityGraph
@EntityGraph(attributePaths = {"items"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithItems();
// Або програмно:
EntityGraph<Order> graph = entityManager.createEntityGraph(Order.class);
graph.addSubgraph("items");
List<Order> orders = entityManager.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.fetchgraph", graph)
.getResultList();
Порівняння підходів
| Підхід | Коли використовувати | Запитів |
|---|---|---|
| JOIN FETCH | Завжди потрібні пов’язані дані | 1 |
| @BatchSize | Пов’язані дані потрібні іноді | N/batchSize |
| EntityGraph | Динамічне завантаження | 1 |
| EAGER | Ніколи (антипатерн) | N |
Типові помилки
// ❌ EAGER завантаження для колекцій
@OneToMany(fetch = FetchType.EAGER)
private List<OrderItem> items;
// Завжди завантажує всі елементи → проблеми з продуктивністю
// ❌ Кілька JOIN FETCH → Cartesian Product
@Query("SELECT o FROM Order o JOIN FETCH o.items JOIN FETCH o.payments")
// Якщо 10 замовлень × 5 items × 3 payments = 150 рядків!
🔴 Senior Level
Внутрішня реалізація
Проблема N+1 виникає через те, як Hibernate працює з lazy loading proxy:
1. Order.getItems() повертає PersistentCollection proxy
Proxy — це спеціальний підклас, який Hibernate створює замість реального об'єкта. При зверненні до його полів Hibernate перехоплює виклик і завантажує дані з БД.
2. При першому зверненні до proxy Hibernate перевіряє чи ініціалізована колекція
3. Якщо ні — виконує SELECT для конкретного order_id
4. Для кожного Order — окремий SELECT, оскільки кожен proxy ініціалізується індивідуально
Просунуті стратегії
1. Subselect fetching
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items;
// Один підзапит для всіх:
// SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders)
SUBSELECT vs @BatchSize: SUBSELECT виконує один запит для ВСІХ завантажених батьків, а @BatchSize розбиває на групи. SUBSELECT вигідніше коли батьків мало, BatchSize — коли багато.
2. Покращення Hibernate 6
// Hibernate 6.2+ підтримує окремий SELECT batching за замовчуванням
// Більше не потрібен @BatchSize для колекцій
// Але для @ManyToOne все ще корисний
3. Виявлення в production
// p6spy — логування всіх SQL
// datasource-proxy — програмне перехоплення
// Spring Boot Actuator + Hibernate Statistics
@Bean
public DataSource dataSource() {
return ProxyDataSourceBuilder.create(actualDataSource)
.logQueryBySlf4j()
.countQuery()
.build();
}
Архітектурні рекомендації
✅ LAZY за замовчуванням для всіх зв'язків
✅ JOIN FETCH коли пов'язані дані потрібні завжди
✅ @BatchSize (10-50) для колекцій
✅ DTO projection для read-only сценаріїв
✅ Моніторинг SQL-запитів в production
✅ Регулярний review slow query log
❌ EAGER без вагомої причини
❌ Ігнорування N+1 в code review
❌ Відсутність моніторингу запитів
❌ Використання сутностей замість DTO для API
Коли N+1 допустимий
У рідкісних випадках N+1 може бути прийнятним:
- Маленькі дані (< 10 записів)
- Пов’язані дані кешуються в L2 cache
- Використовується з @BatchSize з великим розміром
🎯 Шпаргалка для співбесіди
Обов’язково знати:
- Проблема N+1: 1 запит на батьків + N запитів на дочірні сутності
- Основне рішення — JOIN FETCH (один запит з JOIN)
- @BatchSize завантажує пакетами: N запитів → N/batchSize
- EntityGraph для динамічного завантаження зв’язків
- EAGER для колекцій — антипатерн, викликає N+1
- DISTINCT обов’язковий з JOIN FETCH для усунення дублікатів
- Кілька JOIN FETCH → Cartesian product (уникайте)
- Моніторинг SQL-запитів в production (p6spy, datasource-proxy)
Часті уточнюючі запитання:
- Коли N+1 допустимий? Маленькі дані (<10 записів), L2 cache, або з @BatchSize
- Чому DISTINCT потрібен? JOIN повертає по одному рядку на кожен child, DISTINCT прибирає дублікати root-об’єктів в пам’яті
- Що краще — JOIN FETCH чи @BatchSize? JOIN FETCH коли дані потрібні завжди, @BatchSize коли іноді
- Як виявити N+1? Увімкнути логування SQL,рахувати запити, використовувати p6spy
Червоні прапорці (НЕ говорити):
- «Використовую EAGER щоб уникнути N+1» — EAGER сам викликає N+1
- «N+1 не проблема для сучасних ORM» — проблема є в будь-якому ORM
- «Завжди використовую JOIN FETCH для всього» — Cartesian product при кількох колекціях
- «Не моніторю SQL-запити» — без моніторингу N+1 непомітний
Пов’язані теми:
- [[2. У чому різниця між Lazy та Eager завантаженням]]
- [[5. Які стратегії fetch існують в Hibernate]]
- [[6. Що робить анотація @BatchSize]]
- [[28. Як використовувати JOIN FETCH для вирішення проблеми N+1]]
- [[29. Що таке projection в JPA]]