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

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

У результаті замість одного швидкого SQL-запиту зі з'єднанням таблиць (JOIN) застосунок лавиноподібно надсилає в базу даних десятки або сотні дрібних запитів:


🟢 Junior Level

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

Проблема N+1 (N+1 Query Problem) — це класична архітектурна пастка об’єктно-реляційного відображення (ORM), за якої для завантаження $N$ батьківських сутностей та пов’язаних із ними дочірніх об’єктів Hibernate виконує 1 початковий запит для вибірки списку батьків і потім $N$ додаткових SQL-запитів (по одному на кожного батька) для підвантаження дочірніх записів.

У результаті замість одного швидкого SQL-запиту зі з’єднанням таблиць (JOIN) застосунок лавиноподібно надсилає в базу даних десятки або сотні дрібних запитів:

\[\text{Загальна кількість запитів} = 1 + N\]
Без оптимізації (Проблема 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:

  1. Реляційна СУБД при виконанні JOIN повертає декартовий добуток рядків: якщо замовлення має 5 позицій, база поверне 5 рядків для одного замовлення.
  2. Якщо застосувати SQL-ліміт LIMIT 10 OFFSET 0, база даних відітне перші 10 рядків результатів джойну, тобто поверне 2 повних замовлення і половину позицій третього замовлення! Hibernate не зможе коректно зібрати сутність.
  3. Тому Hibernate змушений видалити LIMIT і OFFSET з SQL-запиту і завантажити ВСЮ таблицю цілком у пам’ять JVM!
  4. У логах з’являється фатальне попередження: WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
  5. Якщо в таблиці 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, але й гарантовано її посилює?

Відповідь:

  1. Якщо зв’язок позначений як FetchType.EAGER, анотація повідомляє Hibernate: «Ці пов’язані дані мають завантажуватися завжди при читанні сутності».
  2. Коли сутність завантажується через entityManager.find(Order.class, 1L), Hibernate може згенерувати LEFT OUTER JOIN одразу.
  3. Але коли виконується запит на JPQL/HQL (SELECT o FROM Order o), парсер JPQL буквально виконує написаний розробником запит: «Вибрати сутності Order».
  4. Після того як початковий запит повернув $N$ замовлень, Hibernate бачить анотацію EAGER і розуміє, що зобов’язаний підвантажити залежності для кожного з них.
  5. Оскільки у вихідному JPQL не було інструкції JOIN FETCH, Hibernate виконує $N$ додаткових SELECT-запитів для кожного рядка.
  6. На відміну від LAZY, де розробник може уникнути додаткових запитів, просто не звертаючись до гетера, при EAGER ці $N$ запитів будуть виконані завжди й безумовно, навіть якщо дочірні дані взагалі непотрібні викликаючому коду.

2. Як виявити проблему N+1 в автоматичних тестах (CI/CD pipeline) до потрапляння коду в production?

Відповідь: Покладатися на ручний перегляд логів SQL ненадійно. В індустрії застосовуються три підходи до автоматизації:

  1. Бібліотека QuickPerf: Дозволяє вішати анотації поверх JUnit-тестів із лімітом запитів:
    @Test
    @ExpectSelect(1) // Тест впаде, якщо виконається 2 або більше SELECT
    void shouldFindAllOrdersWithoutNPlusOne() {
        orderService.findAllOrders();
    }
    
  2. Перехоплювач DataSource (datasource-proxy): Обгортає реальний DataSource тестового профілю і рахує виклики executeQuery. Наприкінці тесту assertions перевіряють, що кількість запитів не перевищує очікувану константу.
  3. 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 до БД.

Пов’язані теми