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

У чому різниця між Lazy та Eager завантаженням

FetchType визначає коли Hibernate завантажує пов'язані сутності: одразу разом з батьківською (EAGER) або лише при зверненні (LAZY). Це фундаментальне рішення, що впливає на прод...

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

Огляд

FetchType визначає коли Hibernate завантажує пов’язані сутності: одразу разом з батьківською (EAGER) або лише при зверненні (LAZY). Це фундаментальне рішення, що впливає на продуктивність та архітектуру додатку.

Аналогія: Уявіть, що ви завантажуєте книгу. EAGER — це одразу завантажити ще й усі рецензії, коментарі, історію змін. LAZY — завантажити лише книгу, а рецензії підтягнути коли користувач натисне на вкладку.


🟢 Junior Level

Визначення

LAZY (ліниве завантаження) — пов’язані дані завантажуються лише при першому зверненні до них.

EAGER (жадібне завантаження) — пов’язані дані завантажуються одразу разом з основним об’єктом.

// EAGER — завантажує items одразу
@OneToMany(fetch = FetchType.EAGER)
private List<OrderItem> items;

Order order = entityManager.find(Order.class, 1L);
// items ВЖЕ завантажений!

// LAZY — завантажує items лише при зверненні
@OneToMany(fetch = FetchType.LAZY)
private List<OrderItem> items;

Order order = entityManager.find(Order.class, 1L);
// items ЩЕ НЕ завантажений — це proxy

order.getItems().size();  // ЛИШЕ ТУТ відбувається запит до БД

Значення за замовчуванням

Анотація FetchType за замовчуванням
@ManyToOne EAGER
@OneToMany LAZY
@OneToOne EAGER
@ManyToMany LAZY

Значення за замовчуванням вказані для JPA 2.x. В Hibernate 5/6 можна перевизначити через @LazyToOne.

Коли що використовувати

  • LAZY — майже завжди, особливо для колекцій
  • EAGER — лише для маленьких обов’язкових зв’язків, які потрібні в 100% випадків

🟡 Middle Level

LazyInitializationException

Найчастіша проблема при роботі з LAZY:

@Transactional
public Order getOrder(Long id) {
    return entityManager.find(Order.class, id);
}

// Поза транзакцією:
Order order = service.getOrder(1L);
order.getItems().size();  // ❌ LazyInitializationException!
// Сесія Hibernate вже закрита, proxy не може завантажити дані

Рішення

// 1. JOIN FETCH — завантажити в транзакції
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Order findByIdWithItems(@Param("id") Long id);

// 2. Hibernate.initialize() — примусова ініціалізація
@Transactional
public Order getOrderWithItems(Long id) {
    Order order = entityManager.find(Order.class, id);
    Hibernate.initialize(order.getItems());
    return order;
}

// 3. @EntityGraph — динамічне завантаження
@EntityGraph(attributePaths = {"items"})
Order findById(Long id);

// 4. Ініціалізація в сервісному шарі
@Transactional(readOnly = true)
public OrderDto getOrderDto(Long id) {
    Order order = repository.findById(id).orElseThrow();
    order.getItems().size();  // force initialization
    return OrderDto.from(order);
}

Open Session In View — антипатерн

# ❌ Не рекомендується в production
spring.jpa.open-in-view: true

Цей режим тримає сесію відкритою до завершення рендерингу view, що призводить до:

  • Довгих транзакцій
  • Витоків з’єднань
  • Прихованих проблем продуктивності

Типові помилки

// ❌ EAGER для великих колекцій
@OneToMany(fetch = FetchType.EAGER)
private List<Order> orders;
// Завантажить ВСІ замовлення користувача → OutOfMemoryError

// ❌ EAGER "на всякий випадок"
@ManyToOne(fetch = FetchType.EAGER)
private User user;
// Навіть коли user не потрібен — зайвий JOIN

🔴 Senior Level

Внутрішня реалізація

Як працює LAZY loading:

1. Hibernate створює proxy для пов'язаної сутності/колекції
2. Для @ManyToOne — ByteBuddy proxy, що розширює клас
   ByteBuddy — бібліотека для створення підкласів на льоту. Hibernate використовує її щоб створити підклас вашої сутності, який перехоплює звернення до полів і завантажує дані з БД при першому зверненні.
3. Для @OneToMany — PersistentCollection (PersistentBag/PersistentSet)
4. При зверненні до proxy:
   - Перевіряє Session.isOpen()
   - Якщо відкритий → виконує SELECT
   - Якщо закритий → LazyInitializationException
5. Після завантаження proxy замінюється на реальний об'єкт

Чому @ManyToOne за замовчуванням EAGER:

@ManyToOne посилається на ОДНУ сутність (наприклад, Order -> User). Зазвичай разом із замовленням потрібен і користувач. @OneToMany посилається на КОЛЕКЦІЮ (Order -> OrderItems), яка може містити сотні записів — завантажувати їх усі завжди було б марнотратно.

Історичне рішення в JPA специфікації, засноване на припущенні, що батьківська сутність зазвичай потрібна разом з дочірньою. На практиці це рідко виправдано.

Архітектурні Trade-offs

LAZY EAGER
Гнучкіше і продуктивніше Пріше у розробці
Вимагає планування завантаження Завжди доступно
Може викликати LazyInitializationException Може викликати N+1 проблему
Підходить для високонавантажених систем Підходить для прототипів

Стратегія “LAZY за замовчуванням”

ПРАВИЛО: Всі зв'язки оголошувати як LAZY, завантажувати явно коли потрібно.

Чому це правильно:
1. EAGER не можна скасувати в конкретному запиті
2. LAZY можна "перетворити" на EAGER через JOIN FETCH
3. LAZY дає максимальну гнучкість

@Entity
public class Order {
    // Перевизначаємо дефолтний EAGER на LAZY
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    // LAZY — дефолт для колекцій
    @OneToMany(mappedBy = "order")
    private List<OrderItem> items;
}

DTO Projection замість EAGER

// Замість завантаження повних сутностей з EAGER зв'язками
@Query("""
    SELECT new com.example.OrderSummaryDto(
        o.id, o.date, o.status, u.name, COUNT(i)
    )
    FROM Order o
    JOIN o.user u
    LEFT JOIN o.items i
    WHERE o.id = :id
    GROUP BY o.id, u.name
    """)
OrderSummaryDto findOrderSummary(@Param("id") Long id);

Best Practices

✅ LAZY за замовчуванням для переважної більшості зв'язків. EAGER — лише коли ви точно виміряли і підтвердили, що зв'язок потрібен в 100% запитів.
✅ Перевизначення @ManyToOne та @OneToOne на LAZY
✅ JOIN FETCH коли пов'язані дані потрібні
✅ @EntityGraph для динамічного завантаження
✅ DTO projection для відповідей API
✅ Ініціалізація всередині @Transactional методів

❌ EAGER для колекцій (@OneToMany, @ManyToMany)
❌ Open Session In View в production
❌ Доступ до LAZY полів поза транзакцією
❌ EAGER "на всякий випадок"

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • LAZY — дані завантажуються при першому зверненні, EAGER — одразу
  • За замовчуванням: @ManyToOne = EAGER, @OneToMany = LAZY, @OneToOne = EAGER, @ManyToMany = LAZY
  • LAZY за замовчуванням — найкраща стратегія для всіх зв’язків
  • EAGER не можна скасувати в запиті, LAZY можна «перетворити» на EAGER через JOIN FETCH
  • LAZY працює через proxy (ByteBuddy) та PersistentCollection
  • LazyInitializationException виникає при доступі до LAZY поза транзакцією
  • DTO projection — найкраща альтернатива EAGER для API

Часті уточнюючі запитання:

  • Чому LAZY краще EAGER? LAZY гнучкий — можна завантажити коли потрібно; EAGER нав’язливий — не можна скасувати
  • Чому @ManyToOne за замовчуванням EAGER? Історичне рішення JPA — передбачалось що батьківська сутність зазвичай потрібна
  • Як вирішити LazyInitializationException? JOIN FETCH, EntityGraph, ініціалізація всередині @Transactional, DTO projection
  • Що таке Open Session In View? Сесія відкрита до рендерингу view — антипатерн для production

Червоні прапорці (НЕ говорити):

  • «EAGER простіше — використовую його» — створює N+1 і проблеми з пам’яттю
  • «Вирішую LazyInitializationException через EAGER» — лікує симптом, створює нову проблему
  • «LAZY тільки для великих колекцій» — LAZY для ВСІХ зв’язків
  • «Open Session In View — нормальна практика» — антипатерн для production

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

  • [[3. Коли використовувати Lazy, а коли Eager loading]]
  • [[4. Що таке LazyInitializationException і як її уникнути]]
  • [[1. Що таке проблема N+1 і як її вирішити]]
  • [[5. Які стратегії fetch існують в Hibernate]]
  • [[29. Що таке projection в JPA]]