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

Які стратегії fetch існують в Hibernate

В екосистемі JPA/Hibernate існує два принципово різних рівні керування вибіркою пов'язаних даних, які часто плутають на співбесідах:


🟢 Junior Level

Фундаментальний поділ: «Коли» проти «Як»

В екосистемі JPA/Hibernate існує два принципово різних рівні керування вибіркою пов’язаних даних, які часто плутають на співбесідах:

  1. JPA FetchType (Відповідає на запитання «КОЛИ завантажувати?»):
    • Стандартний параметр анотацій JPA (@OneToMany, @ManyToOne тощо).
    • LAZY — завантажити відкладено при першому зверненні до поля/колекції.
    • EAGER — завантажити негайно разом із батьківською сутністю.
  2. Hibernate FetchMode (Відповідає на запитання «ЯК (яким SQL) завантажувати?»):
    • Специфічна для Hibernate анотація @Fetch(FetchMode.xxx).
    • Визначає структуру генерованих SQL-запитів: через окремий SELECT, через об’єднання таблиць JOIN або через підзапит SUBSELECT.
JPA FetchType:
LAZY  ──> Завантажити потім при зверненні.
EAGER ──> Завантажити відразу при читанні батька.

Hibernate FetchMode:
SELECT    ──> Виконати окремий вторинний SELECT WHERE id = ?
JOIN      ──> Виконати один запит із конструкцією LEFT/INNER JOIN
SUBSELECT ──> Виконати один вторинний запит WHERE id IN (SELECT id FROM ...)

Основні стратегії Hibernate FetchMode

@Entity
public class Order {
    @Id
    private Long id;

    // 1. SELECT (поведінка за замовчуванням для LAZY)
    // При зверненні до колекції генерується окремий SQL-запит для кожного замовлення
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @Fetch(FetchMode.SELECT)
    private List<OrderItem> itemsSelect;

    // 2. JOIN
    // При виклику find() генерується один SQL-запит із LEFT OUTER JOIN
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @Fetch(FetchMode.JOIN)
    private List<OrderItem> itemsJoin;

    // 3. SUBSELECT
    // При першому зверненні підтягує колекції для всіх раніше вибраних замовлень одним запитом
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @Fetch(FetchMode.SUBSELECT)
    private List<OrderItem> itemsSubselect;
}

🟡 Middle Level

Детальний розбір стратегій FetchMode

1. FetchMode.SELECT

  • Як працює: При зверненні до неініціалізованого проксі або колекції Hibernate виконує окремий параметризований запит:
    SELECT * FROM order_items WHERE order_id = ?;
    
  • Плюси: Мінімальний початковий запит, простота кешування в L1/L2.
  • Мінуси: Головне джерело проблеми N+1, якщо батьківські сутності завантажуються списком.

2. FetchMode.JOIN

  • Як працює: Hibernate об’єднує таблиці через LEFT OUTER JOIN у момент завантаження батька:
    SELECT o.*, i.* FROM orders o 
    LEFT OUTER JOIN order_items i ON o.id = i.order_id 
    WHERE o.id = ?;
    
  • Критичний нюанс: Анотація @Fetch(FetchMode.JOIN) повністю пригнічує та ігнорує FetchType.LAZY! Навіть якщо ви вказали fetch = FetchType.LAZY, за наявності @Fetch(FetchMode.JOIN) пов’язані дані будуть завантажені негайно.
  • Обмеження: Працює тільки при прямій вибірці сутності за первинним ключем (entityManager.find()). При виконанні JPQL-запитів ця анотація ігнорується!

3. FetchMode.SUBSELECT

  • Як працює: Доступний тільки для колекцій. Hibernate запам’ятовує вихідний JPQL/SQL-запит, яким були завантажені батьківські сутності, і при першому зверненні до колекції будь-якого з батьків виконує рівно один підзапит для всієї пачки:
    -- Вихідний запит:
    SELECT * FROM orders WHERE status = 'COMPLETED';
      
    -- Запит за колекціями (SUBSELECT):
    SELECT * FROM order_items 
    WHERE order_id IN (SELECT id FROM orders WHERE status = 'COMPLETED');
    
  • Плюси: Вирішує проблему N+1 без декартового добутку (Cartesian Product), оскільки дані батьків і дітей повертаються роздільними результуючими наборами (ResultSet).
  • Мінуси: Якщо початковий запит був важким, повторне обчислення підзапиту може навантажити процесор СУБД.

🔴 Senior Level

Чому @Fetch(FetchMode.JOIN) ігнорується в JPQL-запитах

Головна пастка для сеньйор-розробника:

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    @Fetch(FetchMode.JOIN)
    private User user;
}

// Запит у репозиторії:
List<Order> orders = entityManager.createQuery("SELECT o FROM Order o", Order.class)
        .getResultList();

Що станеться? Розробник очікує, що Hibernate виконає JOIN з таблицею users. Але Hibernate виконає N+1 запитів!

Причина такої поведінки ядра Hibernate:

  1. Специфікація JPA вимагає, щоб JPQL-запит виконувався суворо відповідно до тексту самого запиту. Якщо в запиті написано SELECT o FROM Order o, Hibernate зобов’язаний вибрати лише дані таблиці orders.
  2. Анотації @Fetch належать до статичних метаданих мапінгу і керують поведінкою методу entityManager.find() або завантаженням лінивих проксі за зовнішніми ключами.
  3. JPQL-парсер має найвищий пріоритет. Він повністю ігнорує анотацію @Fetch(FetchMode.JOIN) над полями сутності!
  4. Щоб змусити JPQL зробити об’єднання, розробник зобов’язаний написати явну динамічну директиву: SELECT o FROM Order o JOIN FETCH o.user.

Класифікація динамічних стратегій вибірки

Досвідчений архітектор розрізняє статичні стратегії (анотації в Entity) та динамічні (вибір стратегії під конкретний сценарій):

                                  СТРАТЕГІЇ ВИБІРКИ (FETCH)
                                              │
                    ┌─────────────────────────┴─────────────────────────┐
                    ▼                                                   ▼
         СТАТИЧНІ (Entity-level)                             ДИНАМІЧНІ (Query-level)
   (Задаються жорстко в коді моделі;                   (Вибираються під конкретний сценарій;
    не піддаються локальному переналаштуванню)          максимальна гнучкість і продуктивність)
         │                                                   │
         ├── FetchType (LAZY / EAGER)                        ├── JPQL JOIN FETCH
         ├── @Fetch (SELECT / JOIN / SUBSELECT)              ├── JPA EntityGraph (fetchgraph/loadgraph)
         └── @BatchSize (Пакетний IN-fetch)                  └── DTO Projections (SELECT new Dto)

Порівняння механізмів динамічного Fetching

Механізм Рівень оголошення Кількість SQL Обробка пагінації Cartesian Product
JOIN FETCH JPQL / HQL / Criteria 1 ❌ Небезпечно (вивантаження в пам’ять) ❌ Виникає при кількох колекціях
@EntityGraph Репозиторій / Запит 1 ❌ Небезпечно (вивантаження в пам’ять) ❌ Виникає при кількох колекціях
@BatchSize Entity / Глобально application.yml $1 + \lceil N / \text{size} \rceil$ ✅ Безпечно (LIMIT/OFFSET у БД) ✅ Відсутній (роздільні запити)
FetchMode.SUBSELECT Колекція в Entity 2 ⚠️ Залежить від діалекту SQL ✅ Відсутній (роздільні запити)

4 Tricky Questions

1. Чому комбінація FetchType.LAZY і @Fetch(FetchMode.JOIN) вважається взаємовиключною, і що вибере Hibernate?

Відповідь: Hibernate вибере негайне завантаження через JOIN, проігнорувавши FetchType.LAZY:

  1. Семантика FetchMode.JOIN наказує: «При завантаженні батьківського запису негайно об’єднати його з цільовою таблицею через зовнішній JOIN».
  2. Оскільки рядок таблиці-партнера вже вичитаний із результуючого набору JDBC ResultSet у першому ж запиті, створювати порожній проксі-об’єкт і відкладати читання на потім немає жодного сенсу.
  3. Таким чином, додавання @Fetch(FetchMode.JOIN) фактично перетворює лінивий зв’язок на жадібний (EAGER). Єдине місце, де лінивість формально збережеться — це JPQL-запити, де Hibernate проігнорує анотацію і виконає лінивий SELECT.

2. Як поводиться FetchMode.SUBSELECT, якщо вихідний список сутностей було відфільтровано складним предикатом з параметрами?

Відповідь:

  1. При використанні FetchMode.SUBSELECT Hibernate кешує контекст запиту, за допомогою якого була отримана батьківська пачка сутностей (включаючи SQL-текст умов WHERE, ORDER BY та застосовані параметри запиту).
  2. При першому зверненні до колекції будь-якого батька Hibernate генерує підзапит, вставляючи збережене тіло вихідного запиту всередину оператора IN:
    SELECT items.* FROM order_items items 
    WHERE items.order_id IN (
        SELECT o.id FROM orders o WHERE o.user_id = ? AND o.total_price > ?
    );
    
  3. Hibernate автоматично повторно біндить ті самі значення параметрів.
  4. Підводний камінь: Якщо вихідний запит містив пагінацію (LIMIT / OFFSET), деякі СУБД (наприклад, старі версії MySQL) не підтримують конструкції виду WHERE id IN (SELECT ... LIMIT N). У таких випадках Hibernate змушений або генерувати тимчасові таблиці, або завершуватися синтаксичною помилкою SQL.

3. Чим відрізняється пакетна вибірка через @BatchSize від стратегії FetchMode.SUBSELECT?

Відповідь:

  • Механізм генерації SQL:
    • @BatchSize(size = 20) бере до 20 фізичних первинних ключів об’єктів Order, що вже знаходяться в оперативній пам’яті, і підставляє їх у літеральний список: WHERE order_id IN (1, 2, 3, ... 20).
    • SUBSELECT не перераховує ID явно, а повторно виконує весь вихідний запит батьків у вигляді вкладеного підзапиту: WHERE order_id IN (SELECT id FROM ...).
  • Масштабованість:
    • @BatchSize працює передбачувано завжди, навіть якщо сутності були завантажені з різних джерел, додані в сесію вручну або отримані частинами.
    • SUBSELECT працює лише в тому випадку, якщо вся пачка батьків була отримана єдиним спільним запитом.

4. Що таке @LazyGroup в Hibernate, і як ця анотація розширює стандартні стратегії вибірки?

Відповідь: Анотація @LazyGroup використовується спільно з плагіном Bytecode Enhancement для точного налаштування лінивого завантаження базових колонок (@Basic(fetch = FetchType.LAZY)):

  1. За замовчуванням усі ліниві базові поля сутності ініціалізуються одночасно при першому зверненні до будь-якого з них.
  2. За допомогою @LazyGroup("groupName") розробник може розбити важкі поля на незалежні групи:
    @Lob @Basic(fetch = FetchType.LAZY)
    @LazyGroup("photos")
    private byte[] avatar;
    
    @Lob @Basic(fetch = FetchType.LAZY)
    @LazyGroup("documents")
    private byte[] passportScan;
    
  3. Звернення до getAvatar() завантажить із БД лише колонку аватара, залишивши скан паспорта незавантаженим другим лінивим полем, що дозволяє ювелірно контролювати витрати мережевого трафіку.

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

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

«В Hibernate стратегії завантаження розділяються на два рівні: FetchType у JPA визначає момент завантаження (LAZY — за вимогою, EAGER — негайно), а FetchMode в Hibernate визначає спосіб формування SQL-запиту (SELECT — вторинні запити, JOIN — вибірка через об’єднання таблиць, SUBSELECT — підзапит WHERE IN для колекцій). У продакшені статичний FetchMode майже не використовується, оскільки FetchMode.JOIN ламає ліниве завантаження та ігнорується в JPQL. Замість цього застосовується стратегія динамічної вибірки: базовий мапінг завжди LAZY, а в конкретних запитах використовуються JOIN FETCH, @EntityGraph, або глобальний batch fetching через hibernate.default_batch_fetch_size».

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

  • Кількість мережевих round-trips до СУБД на транзакцію.
  • Співвідношення запитів SELECT до об’єднаних JOIN у профайлері SQL (p6spy).
  • Розмір результуючого трафіку між базою даних та застосунком (JDBC Network Egress).

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

  • ❌ «FetchType і FetchMode — це синоніми, просто один із JPA, а інший з Hibernate» — FetchType визначає час вибірки (коли), а FetchMode — форму SQL-запиту (як).
  • ❌ «Якщо написати @Fetch(FetchMode.JOIN), то запит SELECT o FROM Order o зробить JOIN» — JPQL повністю ігнорує анотацію @Fetch(FetchMode.JOIN).
  • ❌ «FetchMode.SUBSELECT можна ставити на зв’язок @ManyToOne» — SUBSELECT застосовний виключно до колекцій (*ToMany).
  • ❌ «@BatchSize — це одне зі значень enum FetchMode» — Ні, це окремий механізм пакетного завантаження.

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