EAGER, OSIV і репозиторії

Hibernate deep-dive
Рівень 29 , Лекція 2
Відкрита

1. Контроль: EAGER, OSIV, репозиторії

EAGER, OSIV і механічні виклики репозиторія мають спільне одне: вони передають контроль за формою даних, моментом виконання SQL і межею роботи не сценарію використання, а інфраструктурі. Ззовні це виглядає як спрощення, а всередині перетворює систему на лотерею: один і той самий метод може поводитися по-різному за іншого обсягу даних, іншого порядку викликів або «нешкідливого» логування.

Після entity leakage, oversized transaction і chatty repository це вже наступний шар проблеми. Окремий сервіс можна підчистити, але якщо втрата контролю зашита в EAGER, open-in-view або звичку довіряти будь-якому CRUD-методу, SQL усе одно поїде в неочікуване місце.

Hibernate — штука дуже потужна, але він не екстрасенс. Він не знає, який екран ви зараз обслуговуєте: список замовлень на 50 рядків чи картку одного замовлення, експорт у CSV чи фонову перевірку статусів. Коли ми вмикаємо EAGER «про всяк випадок», коли залишаємо OSIV «щоб не падало», коли викликаємо saveAndFlush() «щоб точно збереглося» — ми по суті кажемо: «дорога інфраструктуро, будь ласка, спроєктуй архітектуру за мене». І, як будь-який добрий джин із пляшки, інфраструктура виконує прохання буквально. Потім ми дивуємося, що бажання було сформульоване дивно.

У наскрізному проєкті Commerce Persistence Lab це особливо видно: домен начебто простий — товари, замовлення, залишки, — але звʼязків достатньо, щоб будь-яке «універсальне» налаштування перетворювалося на снігову кулю запитів. І найнеприємніше: ця куля росте не там, де ви її очікуєте.

2. EAGER за замовчуванням: «все й одразу»

EAGER виглядає як дуже людська ідея: «Якщо звʼязок мені потрібен часто, давайте завантажувати його одразу, щоб потім не було сюрпризів». Це як узяти на пікнік увесь холодильник, бо «раптом захочеться і сиру, і ковбаси, і соусу, і ще кавуна». Формально ви підготувалися до всього. Практично — тепер ви несете холодильник.

Важливо памʼятати базову механіку: fetch = EAGER означає не «Hibernate обов’язково зробить JOIN», а «на момент, коли сутність вважається завантаженою, звʼязок має бути доступним без додаткових ледачих звернень». Як саме це буде досягнуто — уже рішення провайдера. І ось тут починається магія… не завжди добра.

У JPA є ще один неприємний «подарунок»: @ManyToOne і @OneToOne за замовчуванням EAGER. Тобто навіть якщо ви не писали слово EAGER, воно вже поруч, просто в сірому плащі та з капюшоном. Тому хороший інженерний дефолт у Hibernate-heavy проєкті — явно робити to-one звʼязки LAZY і піднімати потрібний fetch-plan на рівні конкретного use case.

Подивіться на типову точку, де все починається. Припустімо, розробник робить замовленню «зручне» поле customer і вирішує: «А що там, нехай завжди буде завантажений»:

package com.example.commerce.orders.entity;

import jakarta.persistence.*;

@Entity
public class PurchaseOrder {

    // Важливо: це "глобальне" рішення на рівні мапінгу,
    // воно впливатиме на списки, експорти та будь-які читання цієї сутності.
    @ManyToOne(fetch = FetchType.EAGER) // виглядає як "щоб було простіше"
    private Customer customer;

    // На практиці частіше безпечніше явно робити to-one зв'язки LAZY,
    // а потрібну форму даних піднімати запитом/EntityGraph під конкретний use case.
}

На малих обсягах даних це майже завжди «нормально». Але потім ви робите список замовлень. І тут з’являється підступний момент: список замовлень — це не сценарій «мені потрібен увесь клієнт», а сценарій «мені потрібні номер замовлення, статус, сума і, можливо, email клієнта». І саме «можливо» — ключове слово. EAGER не вміє розуміти «можливо». Він розуміє тільки «завжди».

Далі ви вмикаєте SQL trace і бачите, що «завжди» може виглядати по-різному. Іноді Hibernate справді зробить join. Іноді — серію secondary select. Іноді — і те, і те, тому що провайдер обирає компроміс між шириною JOIN-запитів і кількістю запитів. І головне: ви вже не керуєте цим на рівні use case, бо зафіксували поведінку в мапінгу назавжди.

Щоб зафіксувати думку без філософії, ось корисна табличка, яка допомагає протверезіти після слова EAGER:

Підхід Що ви насправді фіксуєте Де «боляче» Чому це антипатерн як дефолт
fetch = EAGER в entity Глобальне правило «завжди завантажувати» Усюди: списки, фонові задачі, будь-які читання Один сценарій диктує ціну всім сценаріям
fetch = LAZY + явний fetch-plan (JOIN FETCH / EntityGraph) Локальне правило «завантажувати тут і зараз під цей кейс» Лише там, де ви це явно обрали Керування залишається в сценарії використання
Projection (DTO/record) «Читати лише потрібні колонки» Там, де ви хотіли «універсальну entity» Натомість зникають гігантські графи й накладні витрати dirty checking

Зверніть увагу: у цій таблиці немає «правильного назавжди». Є «правильне під задачу». EAGER як інструмент існує, але як дефолт мислення він майже завжди перетворюється на податок на кожен запит.

Ще одна класична проблема EAGER: він ніби обіцяє «жодного lazy-завантаження», але на практиці часто просто переносить запити в інше місце. А перенесення — це не розв’язання. Це як сказати: «Я не буду прокрастинувати ввечері, я буду прокрастинувати вранці». Роботу все одно не зроблено, просто кава ще не подіяла.

3. EAGER і secondary selects

Тут корисно зробити маленьку паузу й акуратно розкласти термін secondary select людською мовою. Secondary select — це коли ви очікуєте, що «все завантажилося одним запитом», а насправді Hibernate спочатку робить запит за кореневою сутністю, а потім — додаткові запити, щоб підвантажити EAGER-звʼязки. Це може виглядати невинно — «ну й що, ще один SELECT» — доки це не перетворюється на «ще один SELECT на кожен рядок списку».

Уявімо, що ви робите «звичайне» читання замовлення:

package com.example.commerce.orders.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderReadService {

    // Транзакція тут задає очікувану межу Unit of Work для читання.
    // Усередині неї Hibernate має право ліниво ініціалізувати зв'язки (якщо вони LAZY).
    @Transactional(readOnly = true)
    public PurchaseOrder loadOrder(Long id) {
        // findById() повертає реальні дані (на відміну від reference/proxy).
        return orderRepository.findById(id).orElseThrow();
    }
}

Якщо customer у вас EAGER, то в SQL-лозі ви можете побачити щось таке (спрощено, але за змістом вірно):

-- 1) Завантажуємо саме замовлення
select o.id, o.order_number, o.customer_id
from purchase_order o
where o.id = ?;

-- 2) Підвантажуємо EAGER customer окремим запитом
select c.id, c.email, c.first_name, c.last_name
from customer c
where c.id = ?;

Чому не JOIN? Тому що Hibernate обирає стратегію виконання, виходячи зі своїх внутрішніх правил, особливостей запиту, уже завантаженого контексту, і іноді просто з того, що «так безпечніше для коректності й мінімізації дублікатів». І так: це рішення може відрізнятися між різними запитами та різними формами доступу.

На списках це особливо помітно. Коли ви робите «дай мені 50 замовлень», secondary select перетворюється на «дай мені 50 клієнтів». І ось ви вже отримали класичний N+1, тільки він прийшов не з LAZY, а з «рятівного EAGER». І це дуже прикро психологічно: ви ж увімкнули EAGER, щоб «не було проблем із завантаженням», а отримали проблему із завантаженням, лише з іншим ароматом.

До речі, на @OneToMany вмикати EAGER зазвичай ще веселіше: ви ризикуєте роздути result set, отримати дублікати кореневих сутностей, зламати пагінацію, а іноді ще й натрапити на обмеження щодо одночасного fetch колекцій. Це не означає, що EAGER «заборонений законом». Це означає, що ставити його «за замовчуванням і всюди» — це все одно що в машині замінити ремені безпеки на бетонні подушки. Формально «безпечніше», але їздити ви перестанете.

4. OSIV: persistence context живе занадто довго

OSIV (Open Session in View) — це дуже старий і дуже спокусливий патерн. Він робить так, що EntityManager (і, відповідно, persistence context) живе не лише всередині сервісної транзакції, а майже весь web-request. І от тоді доступ до LAZY звʼязків у контролері, JSON-серіалізаторі, логуванні та інших «зовнішніх» місцях раптом починає працювати. Люди бачать: «помилка зникла» — і ставлять галочку «виправлено».

Проблема в тому, що OSIV найчастіше не виправляє, а маскує. Він робить межу відповідальності розмитою: замість «сервіс вирішив, які дані потрібні» отримуємо «дані підвантажуються там, де хтось випадково викликав геттер». Це перетворює SQL на некеровану побічну дію.

У Spring Boot це виглядає приблизно так. Увімкнення OSIV (на практиці це зазвичай «залишили за замовчуванням») можна побачити в конфігурації:

spring:
  jpa:
    open-in-view: true

А тепер порівняймо життєвий цикл запиту без OSIV і з OSIV. Схема спрощена, але за відчуттям дуже точна:

flowchart TD
    A[HTTP-запит] --> B[Контролер]
    B --> C["Сервіс @Transactional"]
    C --> D[Репозиторій / Hibernate]
    D --> C
    C --> B
    B --> E[Серіалізація JSON / логування]
    E --> F[HTTP-відповідь]

    subgraph "Без OSIV (open-in-view=false)"
      C1["Персистентний контекст живе лише всередині @Transactional"]:::good
    end

    subgraph "З OSIV (open-in-view=true)"
      O1["Персистентний контекст живе до кінця web-request"]:::bad
    end

classDef good fill:#e6ffed,stroke:#2f855a,color:#22543d;
classDef bad fill:#fff5f5,stroke:#c53030,color:#742a2a;

Що реально змінюється для розробника? Змінюється «місце, де допустимо думати про дані». Без OSIV ви змушені, у хорошому сенсі, спроєктувати читання: або завантажити потрібний граф у сервісі, або повернути проєкцію. З OSIV ви можете «сподіватися», що далі все підвантажиться само.

І найнеприємніше: з OSIV ви часто дізнаєтеся про проблему не тоді, коли вона зʼявилася, а коли вона стала дуже дорогою. Наприклад, на тестових даних усе було швидко. У проді даних стало більше, а серіалізація почала обходити більше полів, і ви раптово отримали десятки запитів на одну відповідь. І це може статися вже після релізу, а не в момент написання коду.

У нашому курсі та проєкті базова конфігурація — open-in-view=false не зі шкідливості, а тому що це єдиний чесний спосіб тримати межу транзакції та fetch-план в одному місці: у use case.

5. Сліпа віра в JpaRepository

Репозиторії Spring Data — чудова річ. Але це як швейцарський ніж: він зручний, доки ви не намагаєтеся ним побудувати будинок. JpaRepository дає вам CRUD і кілька зручних абстракцій, але не дає головного: осмисленого контракту читання та запису під конкретний сценарій.

«Сліпа віра в репозиторії» зазвичай виглядає так: «У нас же є findById()/findAll(), значить, ми вже зробили шар доступу до даних». А потім люди дивуються, чому список замовлень став повільним, чому зʼявився N+1, чому випадковий saveAndFlush() змінив момент SQL, чому getReferenceById() раптом вистрілив у ногу.

Подивімося на один дуже впізнаваний фрагмент. Розробник змінює імʼя товару і рефлекторно «підтверджує збереження» через saveAndFlush():

package com.example.commerce.catalog.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductService {

    @Transactional
    public void renameProduct(Long id, String newName) {
        Product p = productRepository.findById(id).orElseThrow();

        // Зміни в managed entity буде виявлено dirty checking під час commit.
        p.setName(newName);

        // flush "прямо зараз" майже ніколи не потрібен для простого перейменування.
        // Він змінює момент відправки SQL і може зробити поведінку менш передбачуваною.
        productRepository.saveAndFlush(p); // рефлекс "про всяк випадок"
    }
}

Проблема тут не в тому, що код «не працює». Він працює. Проблема в тому, що ви додаєте до сценарію зайву семантику: змушуєте flush відбутися просто зараз. А flush — це не «підтвердження», а лише зміна моменту, коли Hibernate надішле SQL. Іноді це дає зайве навантаження. Іноді призводить до неочікуваних flush-before-query. Іноді ускладнює діагностику, бо SQL іде раніше, ніж ви очікуєте. І все це — заради звички «раптом без save не збережеться», хоча dirty checking уже робить роботу.

Інша часта історія — бажання «оптимізувати читання» через getReferenceById(), бо «він же не робить SELECT». І виходить ось так:

package com.example.commerce.orders.service;

import org.springframework.transaction.annotation.Transactional;

public class OrderReadService {

    @Transactional(readOnly = true)
    public PurchaseOrder loadOrderRef(Long id) {
        // Важливо: це не дані замовлення, а proxy на сутність.
        // SELECT станеться при першому зверненні до полів (ініціалізація proxy).
        return orderRepository.getReferenceById(id); // proxy, а не дані
    }
}

Це нормально, коли ви справді розумієте, що робите. Але «сліпа» заміна findById() на getReferenceById() часто означає: «я не проєктую форму даних, я просто сподіваюся, що стане швидше». Далі або прилітає LazyInitializationException (якщо OSIV вимкнено), або все «ніби працює» (якщо OSIV увімкнено), але SQL починає відбуватися в неочікуваному місці, бо proxy ініціалізується при першому зверненні до даних.

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

package com.example.commerce.catalog.service;

import org.springframework.transaction.annotation.Transactional;

public class CatalogExportService {

    @Transactional(readOnly = true)
    public List<Product> exportAll() {
        // findAll() повертає entity-граф, а не "готовий формат для експорту".
        // Далі поза цим методом легко почнеться обхід зв'язків і вистрілить N+1.
        return productRepository.findAll(); // "а далі розберемося"
    }
}

…то «далі» зазвичай означає, що десь зовні почнуться виклики getDetails(), getAssignments(), getCategory() та інші обходи графа. І це знову повертає нас до вихідної проблеми: entity leakage + гігантські графи.

Репозиторій — це інструмент. Він не скасовує необхідності вирішити, які дані потрібні, де проходить транзакція і який fetch-plan допустимий. Якщо репозиторій використовувати як «універсальну кнопку», у певний момент він стане універсальною кнопкою «зробити несподівано дорого».

6. Комбо-ефект: випадковий SQL

Найнебезпечніша частина цих антипатернів — те, що вони чудово підсилюють один одного. Окремо вони можуть виглядати терпимо. Разом вони перетворюють систему на набір «прихованих дверей», за якими сидить SQL і чекає, коли ви випадково смикнете ручку.

Уявімо цілком реалістичний сценарій (і в Commerce Persistence Lab його легко відтворити). Є тонкий контролер, який «просто віддає замовлення»:

package com.example.commerce.orders.web;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public PurchaseOrder get(@PathVariable Long id) {
        // Небезпечний момент: контролер повертає entity назовні.
        // Далі серіалізація/логування можуть пройти графом і раптово викликати SQL.
        return orderReadService.loadOrder(id);
    }
}

Сервіс повертає entity (leakage), OSIV увімкнено (сесія живе до серіалізації), частина звʼязків EAGER (щось підвантажується «гарантовано»), решта LAZY (підвантажується «коли знадобиться»). І далі, коли JSON-серіалізатор починає проходити обʼєктом, він може пройти графом приблизно так:

graph TD
  O[PurchaseOrder] --> C[Customer]
  O --> I[Позиції]
  I --> P[Product]
  P --> D[ProductDetails]
  P --> A[Призначення]
  A --> K[Category]

Якщо в цей момент ви думаєте: «Ну й що, мені ж це все треба», — це вже важливий сигнал. Тому що можливо, вам потрібно все це в одному конкретному сценарії — наприклад, у картці замовлення для адмінки. Але точно не «завжди й всюди». А комбо-ефект улаштований так, що «завжди й всюди» легко зʼявляється непомітно.

Найпідліша деталь: точка, де запити вистрелять, може бути взагалі не в коді бізнес-логіки. Це може бути логування:

log.info("Order={} items={}", order.getOrderNumber(), order.getItems().size());

Це може бути toString() — якщо хтось його «зручно» написав. Це може бути серіалізація відповіді. І ось тут OSIV робить «добро»: не дає впасти. Але разом із цим він робить «зло»: дозволяє цим місцям виконувати SQL.

І виходить дуже характерна картина в логах: use case «ніби прочитав замовлення», а потім починається феєрверк додаткових selectʼів. І проблема вже не локальна — не «тут lazy», а системна: ви втратили контроль над тим, де і чому завантажуються дані.

Тому тут замало запитати «чи є в нас lazy-звʼязок». Нормальне audit-питання звучить жорсткіше: у якій точці життєвого циклу запиту взагалі дозволено виконувати SQL — усередині use case чи вже в контролері, логері та серіалізації. І якщо запити починають відбуватися в JSON, логах або зовнішньому коді, майже напевно у вас уже спрацювала комбінація OSIV + entity leakage, а EAGER лише обтяжив картину.

7. Типові помилки при EAGER і OSIV

З цими трьома антипатернами є одна неприємна особливість: їх легко виправдати «практичністю». Тому помилки повторюються навіть у досвідчених людей, просто в красивішій обгортці. Нижче — кілька формулювань, які корисно впізнавати на слух, бо вони часто звучать на review та в обговореннях.

Помилка №1: «Давайте зробимо EAGER, щоб не було проблем із lazy».
Такий підхід лікує симптом (іноді) і майже завжди створює новий: надмірне завантаження, N+1 через secondary selects, роздуті JOIN-запити, непередбачувану ціну запитів для списків. Зазвичай це ще й закріплює «універсальний граф», який потім тягають в експорт, у списки, у фонові задачі й дивуються, що все стало важчим.

Помилка №2: «Увімкнемо OSIV — і LazyInitializationException зникне».
Так, зникне. Разом із ним зникне і чесна межа відповідальності. SQL почне відбуватися там, де ви його не проєктували: у контролері, серіалізації, логах, маперах. На малих даних це виглядає як перемога. На реальному навантаженні це зазвичай перетворюється на «чому один і той самий endpoint то швидкий, то повільний».

Помилка №3: saveAndFlush() — це як Ctrl+S, нехай буде для надійності.
saveAndFlush() — це не «зберегти файл», а зміна моменту flush, а отже — зміна моменту відправки SQL. У managed-flow це майже завжди зайве, а іноді й шкідливе: воно робить SQL ранішим і може спричиняти каскад неочікуваних flush-before-query. Якщо хочеться «надійності», її дають тести й зрозуміла транзакційна межа, а не зайвий flush.

Помилка №4: getReferenceById() швидше, давайте завжди використовувати його.
getReferenceById() не «швидше», він «інший»: це proxy замість даних. Він корисний, коли ви справді хочете посилання за id (наприклад, щоб поставити FK, не читаючи рядок). Але як універсальна заміна findById() він зазвичай означає, що ви просто переносите момент SQL кудись убік і ускладнюєте діагностику, особливо якщо OSIV приховує наслідки.

Помилка №5: «Репозиторій — це і є persistence layer».
Репозиторій — це API. Persistence layer — це рішення: що читати (entity чи projection), як читати (fetch-plan), де читати (transaction boundary), як писати (dirty checking vs merge vs bulk) і як це перевіряти (SQL trace, regression tests). Якщо замість рішень ви використовуєте лише універсальні CRUD-методи «у надії», ви рано чи пізно отримаєте код, який неможливо пояснити на review без фрази «ну Hibernate так вирішив».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ