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

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

Параметр fetch (стратегія вибірки даних) в анотаціях зв'язків JPA визначає, в який момент часу пов'язані сутності завантажуються з бази даних в оперативну пам'ять:


🟢 Junior Level

Суть концепцій простими словами

Параметр fetch (стратегія вибірки даних) в анотаціях зв’язків JPA визначає, в який момент часу пов’язані сутності завантажуються з бази даних в оперативну пам’ять:

  • FetchType.EAGER (жадібне / негайне завантаження): Пов’язана сутність або колекція завантажується відразу ж у момент отримання батьківського об’єкта, незалежно від того, чи знадобляться ці дані застосунку надалі.
  • FetchType.LAZY (ліниве / відкладене завантаження): У момент завантаження батьківського об’єкта пов’язане поле заповнюється легковажною «заглушкою» (проксі-об’єктом або спеціальною колекцією-обгорткою PersistentCollection). Реальний SQL-запит до бази даних виконується лише в момент першого фактичного звернення до вмісту цього поля (виклик бізнес-гетера, звернення до методів колекції).
EAGER:
SELECT o FROM Order o WHERE o.id = 1
├── Замовлення завантажено
└── Пов'язаний User завантажений ВІДРАЗУ Ж (навіть якщо нам потрібен був лише номер замовлення)

LAZY:
SELECT o FROM Order o WHERE o.id = 1
├── Замовлення завантажено
└── Поле user = User$$ByteBuddy$Proxy (SQL-запиту за User НЕМАЄ)
          │
          └── При виклику order.getUser().getName() ──> Виконується SQL: SELECT * FROM users WHERE id = ?

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

У специфікації Jakarta Persistence (JPA) стратегії за замовчуванням залежать від кардинальності зв’язку:

Зв’язок За замовчуванням Рекомендація в Production
@OneToOne EAGER Перевизначати на LAZY
@ManyToOne EAGER Перевизначати на LAZY
@OneToMany LAZY Залишати LAZY
@ManyToMany LAZY Залишати LAZY

Мнемонічне правило: Усі зв’язки, що закінчуються на *ToOne, за замовчуванням жадібні (EAGER), а всі зв’язки на *ToMany — ліниві (LAZY). У сучасному ентерпрайз-коді абсолютно всі зв’язки повинні бути явно перевизначені на LAZY.

Базовий приклад налаштування в Java

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // ВАЖЛИВО: Перевизначаємо дефолтний EAGER на LAZY
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    // За замовчуванням уже LAZY, але рекомендується вказувати явно для прозорості
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();
}

🟡 Middle Level

Механіка роботи Lazy: Проксі та PersistentCollection

Коли Hibernate завантажує сутність із лінивими полями:

  1. Для зв’язків *ToOne створюється динамічний проксі-клас за допомогою бібліотеки ByteBuddy (наприклад, User$ByteBuddy$a1b2c3d4), який успадковує реальний клас User.
  2. Для колекцій *ToMany стандартний ArrayList або HashSet підміняється службовою реалізацією Hibernate: PersistentBag, PersistentSet або PersistentList.
  3. Проксі містить ідентифікатор сутності (id), прапорець ініціалізації initialized = false та посилання на поточну відкриту сесію (SessionImplementor).
order.getUser();           // Повертає посилання на проксі. SQL-запит НЕ виконується!
order.getUser().getId();   // Повертає ID з проксі. SQL-запит НЕ виконується! (якщо AccessType.FIELD)
order.getUser().getEmail();// initialized == false! Hibernate робить SQL-запит до таблиці users.

Головна пастка: LazyInitializationException

Якщо звернення до лінивого поля відбувається після закриття сесії Hibernate (поза транзакцією), проксі не може виконати SQL-запит до БД, оскільки фізичне з’єднання вже повернено в пул з’єднань (HikariCP):

@Service
public class OrderService {
    @Transactional(readOnly = true)
    public Order getOrder(Long id) {
        return orderRepository.findById(id).orElseThrow();
    } // Транзакція завершена, Session закрито!
}

// Контролер або мапер:
Order order = orderService.getOrder(1L);
// ВИКИДАННЯ ВИНЯТКУ:
// org.hibernate.LazyInitializationException: could not initialize proxy [com.example.User#42] - no Session
String email = order.getUser().getEmail(); 

Як ПРАВИЛЬНО боротися з LazyInitializationException

Метод Опис Оцінка
JOIN FETCH у JPQL Завантажити необхідні зв’язки жадібно в межах однієї конкретної транзакції ⭐⭐⭐ Рекомендується
@EntityGraph Перевизначити LAZY на EAGER декларативно в репозиторії ⭐⭐⭐ Рекомендується
DTO-проєкція Вибрати тільки потрібні поля відразу в DTO / Record ⭐⭐⭐ Найкраща практика
Hibernate.initialize(proxy) Примусово ініціалізувати проксі всередині @Transactional методу сервісу ⭐⭐ Допустимо
Зміна на fetch = EAGER Зробити зв’язок жадібним глобально в Entity ❌ Антипатерн (породжує N+1)
Open Session in View (OSIV) Тримати сесію БД відкритою протягом усього HTTP-запиту ❌ Антипатерн (витік конектів)

🔴 Senior Level

Чому FetchType.EAGER — архітектурна помилка в JPA

У специфікації JPA 1.0 розробники припустили, що якщо сутність посилається на одного батька (@ManyToOne), то цей батько майже напевно знадобиться. На практиці це створило системні проблеми:

  1. EAGER неможливо скасувати в JPQL-запиті: Якщо над полем в Entity написано FetchType.LAZY, ви завжди можете перетворити його на EAGER для конкретного сценарію за допомогою JOIN FETCH. Але якщо над полем оголошено FetchType.EAGER, ви не зможете зробити його лінивим у жодному JPQL-запиті! Навіть якщо викликається SELECT o.id FROM Order o, Hibernate буде зобов’язаний завантажити всі пов’язані EAGER-сутності.
  2. Гарантоване породження проблеми N+1: При виклику orderRepository.findAll() EAGER не об’єднує таблиці в JOIN, а виконує $N$ додаткових запитів для кожної сутності.
  3. Каскадне перевантаження пам’яті (EAGER Chain Reaction): Сутність Order має EAGER-зв’язок з User, User має EAGER-зв’язок з Company, Company — з Address. Читання одного замовлення непомітно витягує половину бази даних у пам’ять JVM.

Анатомія Open Session in View (OSIV) та вичерпання пулу HikariCP

У Spring Boot за замовчуванням параметр spring.jpa.open-in-view встановлено в true.

  • Режим реєструє перехоплювач OpenEntityManagerInViewInterceptor, який відкриває Session на самому початку HTTP-запиту (при вході у фільтр сервлета) і закриває її лише після завершення рендерингу JSON/HTML у відповіді контролера.
  • Це дозволяє розробникам звертатися до лінивих гетерів безпосередньо в маперах (MapStruct, Jackson) без виникнення LazyInitializationException.
                    НЕБЕЗПЕКА OPEN SESSION IN VIEW (OSIV = true)
                    
HTTP Request ──> [Filter: Session Open] ──> [Controller] ──> [Service: @Transactional]
                                                                        │
                                                           Захоплення конекту з HikariCP
                                                                        │
HTTP Response <── [Filter: Session Close] <── [Jackson JSON] <── [Service: Кінець транзакції]
       ▲                                              ▲
       │                                              │
Сесія закривається тут!            Якщо Jackson смикає LAZY гетер під час серіалізації,
                                   конект утримується під час мережевої віддачі клієнту!

Чому OSIV заборонений у Highload: Якщо клієнт повільно вичитує HTTP-відповідь через нестабільний мобільний інтернет, потік утримує фізичне з’єднання з БД із пулу HikariCP увесь цей час. Пул з 10–30 конектів вичерпується за лічені секунди, і весь застосунок перестає відповідати (Connection is not available, request timed out after 30000ms). Production-стандарт: завжди встановлювати spring.jpa.open-in-view: false.

Тонкощі проксі ByteBuddy: instanceof проти getClass()

Оскільки LAZY-зв’язок представлений проксі-об’єктом User$ByteBuddy$...:

  • Порівняння через order.getUser().getClass() == User.class поверне false!
  • Перевірка через order.getUser() instanceof User поверне true (оскільки проксі успадковує клас User).
  • Саме тому в класах Entity реалізація методу equals() ніколи не повинна використовувати getClass() — тільки instanceof, а звернення до полів іншого об’єкта повинно відбуватися через гетери (other.getId()), щоб проксі міг перехопити виклик!

Ліниве завантаження базових колонок (@Basic(fetch = FetchType.LAZY))

Іноді виникає потреба зробити лінивим не зв’язок, а важку колонку таблиці (наприклад, byte[] documentScan або великий JSON/TEXT String description):

@Lob
@Basic(fetch = FetchType.LAZY)
@Column(name = "document_scan")
private byte[] documentScan;

Важливо: У чистому вигляді анотація @Basic(fetch = FetchType.LAZY) ігнорується Hibernate! Динамічні проксі огортають лише зв’язки між таблицями. Щоб ліниве завантаження колонок запрацювало, необхідно ввімкнути інструментацію байткоду під час збірки проєкту (Bytecode Enhancement) через плагін hibernate-enhance-maven-plugin:

<plugin>
    <groupId>org.hibernate.orm.tooling</groupId>
    <artifactId>hibernate-enhance-maven-plugin</artifactId>
    <version>${hibernate.version}</version>
    <executions>
        <execution>
            <configuration>
                <enableLazyInitialization>true</enableLazyInitialization>
            </configuration>
            <goals>
                <goal>enhance</goal>
            </goals>
        </execution>
    </executions>
</plugin>

4 Tricky Questions

1. Чому виклик order.getUser().getId() не ініціює SQL-запит до бази даних, якщо поле user оголошено як LAZY?

Відповідь: Поведінка залежить від типу доступу (Access Type), визначеного в сутності:

  1. Property Access (@Id над гетером getId()): Hibernate знає, що виклик гетера ідентифікатора повертає сам первинний ключ, який уже відомий проксі (він був збережений у проксі при зчитуванні зовнішнього ключа user_id із таблиці orders). Тому виклик getId() повертає значення з локального поля проксі без звернення до БД.
  2. Field Access (@Id над полем id): Починаючи з Hibernate 5, Hibernate оптимізує виклик getId() навіть при доступі за полями, якщо метод розпізнаний як стандартний гетер ідентифікатора.
  3. Проте будь-який інший виклик (наприклад, order.getUser().getName(), hashCode() або пряме звернення до поля) негайно тригерить ініціалізацію проксі та відправку SQL-запиту в базу даних.

2. Чи може зв’язок @OneToOne(fetch = FetchType.LAZY) проігнорувати налаштування і завантажитися жадібно?

Відповідь: Так, і це класична пастка на не-власницькому боці зв’язку (non-owning side).

  • У зв’язку @OneToOne сторона, що містить атрибут mappedBy, не має колонки зовнішнього ключа у своїй таблиці (зовнішній ключ лежить у таблиці партнера).
  • Проксі-об’єкт у Java може представляти лише не-null посилання. Але якщо у пов’язаній таблиці рядка немає, поле в Java має містити значення null.
  • Щоб дізнатися, що саме покласти в поле — null чи проксі-об’єкт, Hibernate зобов’язаний заздалегідь знати про факт існування запису.
  • Оскільки в локальному рядку зовнішнього ключа немає, Hibernate виконує негайний SQL-запит SELECT у пов’язану таблицю. Ліниве завантаження перетворюється на негайне (EAGER), породжуючи N+1.
  • Рішення: Або використовувати Bytecode Enhancement, або моделювати зв’язок як @ManyToOne з унікальним обмеженням (unique = true).

3. Чому при успадкуванні сутностей (InheritanceType.JOINED) ліниве завантаження батьківського проксі може зламати поліморфізм?

Відповідь:

  1. Якщо сутність Payment має нащадків CreditCardPayment та CashPayment, а замовлення містить зв’язок @ManyToOne(fetch = FetchType.LAZY) private Payment payment;.
  2. При завантаженні замовлення Hibernate створює проксі для базового класу Payment.
  3. Java — статично типізована мова. Проксі-об’єкт, згенерований ByteBuddy, є екземпляром класу Payment$ByteBuddy$Proxy. Він не може на льоту змінити свій клас на CreditCardPayment після того, як вичитає дані з бази!
  4. У результаті перевірка order.getPayment() instanceof CreditCardPayment поверне false, а явне зведення типів (CreditCardPayment) order.getPayment() завершиться винятком ClassCastException.
  5. Рішення: Використовувати утиліту Hibernate.unproxy(order.getPayment()) для вилучення реального екземпляра цільового підкласу, що лежить усередині проксі.

4. У чому різниця між Hibernate.initialize(proxy) і простим викликом гетера proxy.getName()?

Відповідь:

  • proxy.getName() — це неявна ініціалізація. Якщо метод getName() повертає null, розробник не завжди може бути впевнений, чи відбулася ініціалізація (хоча проксі перехопить виклик будь-якого методу, крім getId()).
  • Hibernate.initialize(proxy) — це офіційний декларативний API Hibernate. Метод безпечно перевіряє, чи є переданий об’єкт проксі або лінивою колекцією. Якщо об’єкт уже ініціалізований або дорівнює null, метод нічого не робить. Якщо це неініціалізований проксі, він примусово завантажує дані з поточної сесії. Якщо сесія закрита, він одразу кидає явний LazyInitializationException.

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

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

«LAZY та EAGER визначають момент вибірки пов’язаних даних із БД: при EAGER пов’язані сутності завантажуються відразу разом із батьком, а при LAZY поле заповнюється легковажним проксі (ByteBuddy) або PersistentCollection, а SQL-запит відкладається до першого фактичного звернення до даних. У продакшені діє суворе правило: всі зв’язки без винятку повинні оголошуватися як LAZY. Дефолтний EAGER для зв’язків @ManyToOne та @OneToOne — це історичний легасі JPA, що призводить до проблеми N+1 та деградації продуктивності. Для усунення LazyInitializationException використовуються JOIN FETCH, @EntityGraph або DTO-проєкції, а використання Open Session in View (OSIV) у мікросервісах вважається небезпечним антипатерном».

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

  • HikariCP: ActiveConnections vs PendingConnections: різке зростання очікування конектів при ввімкненому OSIV.
  • hibernate.stat.entity_fetch_count: кількість лінивих підвантажень сутностей.
  • org.hibernate.LazyInitializationException у логах моніторингу помилок (Sentry, ELK).

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

  • ❌ «Якщо вилітає LazyInitializationException, я просто змінюю зв’язок на FetchType.EAGER» — Фатальна помилка. Це маскує проблему, створюючи неусувний N+1 та витоки пам’яті.
  • ❌ «Зв’язки @ManyToOne за замовчуванням ліниві» — Ні, в JPA вони за замовчуванням EAGER, їх потрібно примусово оголошувати як LAZY.
  • ❌ «Open Session in View — чудова і рекомендована практика для мікросервісів» — OSIV призводить до вичерпання пулу конектів БД та непередбачуваних запитів на етапі серіалізації JSON.
  • ❌ «@Basic(fetch = LAZY) працює з коробки просто за анотацією» — Для базових колонок ліниве завантаження вимагає підключення плагіна Bytecode Enhancement під час збірки проєкту.

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