Питання 1 · Розділ 16

Що таке проблема N+1 і як її вирішити

Проблема N+1 — одна з найпоширеніших проблем продуктивності в Hibernate. Вона виникає при неправильному завантаженні пов'язаних сутностей і призводить до лінійного зростання кіл...

Мовні версії: English Russian Ukrainian

Огляд

Проблема 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-запитів
  • Додаток працює повільно при завантаженні колекцій
  • Кількість запитів пропорційна кількості записів

Базові способи вирішення

  1. JOIN FETCH — об’єднати в один запит
  2. @BatchSize — завантажити пакетами
  3. 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]]