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

Коли використовувати Lazy, а коли Eager loading

У сучасній корпоративній розробці на Java/Spring діє суворе інженерне правило:


🟢 Junior Level

Головний архітектурний вердикт

У сучасній корпоративній розробці на Java/Spring діє суворе інженерне правило:

«Завжди й безумовно оголошуйте FetchType.LAZY для всіх зв’язків в Entity. Статичний FetchType.EAGER в анотаціях зв’язків використовувати суворо заборонено».

Якщо пов’язані дані дійсно потрібні в конкретному сценарії (наприклад, при формуванні детального чека замовлення), вони мають підвантажуватися динамічно в місці виклику за допомогою JOIN FETCH, @EntityGraph або DTO-проєкцій, а не статично на рівні сутності.

Мапінг в Entity: ЗАВЖДИ LAZY
@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY) // Перевизначено дефолтний EAGER
    private User user;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items;
}

Запит у репозиторії: ДИНАМІЧНИЙ EAGER за необхідності
// Сценарій 1: Простий список замовлень (User та Items НЕ потрібні -> економимо ресурси)
List<Order> findByStatus(String status);

// Сценарій 2: Деталізація замовлення (User та Items потрібні -> підтягуємо через JOIN FETCH)
@Query("SELECT o FROM Order o JOIN FETCH o.user JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithDetails(@Param("id") Long id);

Порівняльна матриця застосування

Критерій FetchType.LAZY (за замовчуванням) FetchType.EAGER (виняткові випадки)
Колекції (@OneToMany, @ManyToMany) 100% випадків Ніколи (ризик OutOfMemoryError)
Одиночні зв’язки (@ManyToOne, @OneToOne) 99% випадків (стандарт) Вкрай рідкісні дрібні зв’язки 1-до-1 зі спільним ключем
Вбудовані компоненти (@Embedded) Не підтримується (завжди в тому самому рядку) Завжди завантажуються разом (частина тієї самої таблиці)
Довідники (Enums, статуси) Краще мапити через @Enumerated Якщо довідник — окрема таблиця, все одно LAZY

🟡 Middle Level

Дерево рішень (Decision Matrix) архітектора

Коли розробник вирішує, як спроєктувати завантаження даних для бізнес-сценарію, використовується такий алгоритм:

Чи потрібні пов'язані сутності для виконання поточної операції?
│
├── НІ ──> Використовується базовий метод findById() / findAll() з LAZY.
│          (Пов'язані дані не запитуються, мережевий трафік мінімальний).
│
└── ТАК ──> Яка мета сценарію?
            │
            ├── Операція ТІЛЬКИ НА ЧИТАННЯ (Read-Only: виведення в API, UI, звіт):
            │   └── Використовувати DTO Projection / Spring Data Interface Projection.
            │       (Вибираються тільки потрібні колонки, Entity в пам'ять не піднімаються).
            │
            └── Операція НА МОДИФІКАЦІЮ (Зміна бізнес-стану Aggregate):
                │
                ├── Завантажується ОДИН батьківський об'єкт за ID:
                │   └── Використовувати JOIN FETCH або @EntityGraph.
                │
                └── Завантажується СПИСОК батьків (особливо з пагінацією Pageable):
                    └── Використовувати пакетне завантаження @BatchSize / default_batch_fetch_size.

Чому EAGER для «маленьких довідників» — це самообман

Поширений аргумент розробників: «Категорія товару — це довідник із 5 записів. Чому б не поставити над @ManyToOne анотацію EAGER? Вона ж займає копійки пам’яті!».

У чому прихована небезпека:

  1. Припустимо, сутність Product має зв’язок category з FetchType.EAGER.
  2. Коли виконується вибірка 100 товарів через JPQL:
    List<Product> products = productRepository.findAll();
    
  3. Парсер JPQL не додає JOIN автоматично. Hibernate виконує:
    • 1 запит: SELECT * FROM products (повернув 100 товарів).
    • А потім 100 додаткових запитів: SELECT * FROM categories WHERE id = ?.
  4. Навіть якщо всі 5 категорій уже закешовані в L1-кеші сесії, Hibernate зобов’язаний перевірити наявність зовнішнього ключа для кожного зі 100 товарів.
  5. Результат: спроба заощадити призвела до класичної проблеми N+1 на рівному місці.

🔴 Senior Level

Принцип Aggregate Boundary в Domain-Driven Design (DDD)

На сеньйор-рівні вибір між Lazy та Eager диктується не технічними особливостями фреймворку, а межами транзакційної цілісності (Aggregate Boundary):

+───────────────────────────────────────────────────────────────+
|                 ORDER AGGREGATE (Root: Order)                 |
|                                                               |
|   Order (Корінь агрегата)                                     |
|     │                                                         |
|     ├── List<OrderItem> (Внутрішні сутності агрегата)         |
|     │   (Життєвий цикл суворо прив'язаний до Order;           |
|     │    завантажуються разом через JOIN FETCH при мутаціях)  |
|     │                                                         |
|     └── DeliveryAddress (Value Object, @Embedded)             |
|         (Фізично зберігається в тій самій таблиці orders)     |
+───────────────────────────────────────────────────────────────+
                               │
                Посилання суворо за ID (або LAZY)
                               ▼
+───────────────────────────────────────────────────────────────+
|                 CUSTOMER AGGREGATE (Root: Customer)           |
|                                                               |
|   Customer (Інший незалежний агрегат)                         |
|   (ЗАБОРОНЕНО завантажувати EAGER всередині Order!            |
|    Зв'язок моделюється як LAZY або просто Long customerId)    |
+───────────────────────────────────────────────────────────────+
  1. Всередині межі агрегата: Сутності, що забезпечують бізнес-інваріант (наприклад, Order та OrderItem), при операціях модифікації повинні бути узгоджені. Але навіть всередині агрегата їхній зв’язок оголошується як LAZY, а в репозиторії кореня агрегата створюється метод findByIdWithItems().
  2. Між різними агрегатами: Прямі посилання з EAGER категорично заборонені. Агрегати мають бути слабко зв’язаними. В ідеалі Order повинен зберігати тільки зовнішній ідентифікатор Long customerId, або суворо LAZY-посилання @ManyToOne(fetch = FetchType.LAZY) Customer customer.

Порівняльний аналіз альтернатив динамічного завантаження

Коли пов’язані дані дійсно потрібні, замість EAGER вибирається один із трьох інструментів:

Інструмент Кількість SQL-запитів Декартів добуток Підтримка пагінації Складність підтримки
JOIN FETCH Рівно 1 Так (при кількох колекціях) Небезпечно (HHH000104, OOM) Мінімальна
@EntityGraph Рівно 1 (LEFT JOIN) Так Небезпечно для колекцій Низька (декларативно)
@BatchSize / default_batch_fetch_size $1 + \lceil N / \text{batchSize} \rceil$ НІ (роздільні SQL) Ідеально (LIMIT/OFFSET у БД) Нульова (одне налаштування)
@Fetch(SUBSELECT) Рівно 2 НІ Небезпечно при складних WHERE Середня

Золотий стандарт Senior Engineer:

  • Для вибірки одного об’єкта за ID зі зв’язками — JOIN FETCH або @EntityGraph.
  • Для списків і пагінації з дочірніми колекціями — spring.jpa.properties.hibernate.default_batch_fetch_size = 50.
  • Для читання на фронтенд / API — проєкції в DTO / Java Records.

4 Tricky Questions

1. Чи можна зробити поля всередині @Embeddable об’єкта або сам @Embedded об’єкт лінивим (LAZY)?

Відповідь: За замовчуванням у стандартному JPA — ні.

  1. @Embeddable класи не мають власного життєвого циклу та первинного ключа (@Id). Їхні поля зберігаються як звичайні колонки в тій самій таблиці батьківської сутності.
  2. Коли Hibernate виконує SELECT для батьківського рядка, база даних повертає всі стовпчики рядка одночасно. Оскільки байти колонок вбудованого об’єкта вже вичитані з JDBC ResultSet, робити їх лінивими немає практичного сенсу.
  3. Виняток: Якщо всередині @Embeddable об’єкта оголошено зв’язок з іншою таблицею (наприклад, @ManyToOne(fetch = FetchType.LAZY)), цей зовнішній зв’язок буде лінивим. Також при підключенні плагіна Bytecode Enhancement (hibernate-enhance-maven-plugin) можна зробити лінивою групу колонок через @Basic(fetch = FetchType.LAZY).

2. У чому різниця між FetchType в анотаціях JPA та анотацією @Fetch(FetchMode.JOIN) в Hibernate?

Відповідь: Це часта плутанина між стандартом JPA та специфічними розширеннями Hibernate:

  • FetchType (JPA): Визначає коли дані мають бути завантажені (LAZY — при зверненні, EAGER — негайно).
  • FetchMode (Hibernate): Визначає як (якою SQL-конструкцією) дані мають бути отримані (JOIN — через зовнішній джойн, SELECT — окремим додатковим селектом, SUBSELECT — через підзапит).
  • Критичний нюанс: Якщо над зв’язком вказати @Fetch(FetchMode.JOIN), Hibernate примусово перевизначить FetchType на EAGER, навіть якщо ви явно прописали fetch = FetchType.LAZY! Це прихований спосіб випадково зламати ліниве завантаження.

3. Чому в мікросервісній архітектурі наявність EAGER-зв’язків завдає більшої шкоди, ніж у моноліті?

Відповідь:

  1. У мікросервісах бази даних зазвичай оптимізовані під вузькі точкові сценарії та горизонтальне масштабування. Зайві JOIN та N+1 запити перевантажують пул з’єднань і процесор СУБД.
  2. При передачі даних мережею (REST / gRPC / Kafka) сутності серіалізуються в JSON/Protobuf. Якщо в об’єкті присутні EAGER-зв’язки, серіалізатор Jackson рекурсивно вичитує всі пов’язані графи, роздуваючи корисне навантаження HTTP-відповіді з 2 КБ до сотень кілобайтів.
  3. Це призводить до зростання затримки міжсервісної взаємодії (p99 latency) та лавиноподібного споживання оперативної пам’яті мікросервісами під час серіалізації/десеріалізації.

4. В якому єдиному сценарії використання FetchType.EAGER технічно виправдане і не викличе проблем із продуктивністю?

Відповідь: Єдиний архітектурно чистий сценарій:

  • Двосторонній зв’язок @OneToOne, у якого батько і дитина ділять один і той самий первинний ключ через @MapsId:
    @Entity
    public class OrderReceipt {
        @Id
        private Long id;
    
        @OneToOne(fetch = FetchType.EAGER)
        @MapsId
        @JoinColumn(name = "id")
        private Order order;
    }
    
  • У цьому випадку відношення є суворо обов’язковим ($1:1$), запис гарантовано існує, у них спільний ідентифікатор, і декартів добуток фізично неможливий. Завантаження через єдиний INNER JOIN тут природне та безпечне. У всіх інших випадках слід обирати LAZY.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«У сучасній архітектурі діє безумовний стандарт: усі зв’язки в JPA Entity оголошуються як FetchType.LAZY. Статичний EAGER небезпечний тим, що його неможливо відключити в JPQL-запитах, він провокує проблему N+1 і декартове множення при з’єднанні зв’язків. Замість статичного EAGER у сутностях застосовується динамічне завантаження за вимогою: для детальної вибірки одного об’єкта використовується JOIN FETCH або @EntityGraph, для списків та пагінації — пакетне завантаження @BatchSize (hibernate.default_batch_fetch_size), а для сценаріїв тільки на читання (API, звіти) — легковажні DTO-проєкції».

Ключові метрики для Production-моніторингу

  • Середній час виконання SQL-запитів (SQL Query Execution Time).
  • hibernate.stat.entity_load_count: кількість завантажених об’єктів на один веб-запит.
  • Використання оперативної пам’яті (JVM Heap Allocation Rate) у моменти вивантаження списків.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Для довідників і зв’язків @ManyToOne краще ставити EAGER, щоб не писати зайвий код» — EAGER призведе до виконання N+1 селектів при будь-якому запиті через findAll().
  • ❌ «EAGER прискорює застосунок, тому що робить менше звернень до бази» — Навпаки, він уповільнює систему, витягуючи непотрібні графи даних і провокуючи N+1.
  • ❌ «Вибір між Lazy та Eager залежить від настрою розробника» — Це фундаментальне архітектурне рішення, що визначає масштабованість системи; дефолтом завжди має бути LAZY.

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