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

Що робить анотація @BatchSize

Анотація @BatchSize в Hibernate — це інструмент оптимізації лінивого завантаження (LAZY), який вказує Hibernate завантажувати пов'язані сутності або колекції пакетами (батчами)...


🟢 Junior Level

Що таке @BatchSize простими словами

Анотація @BatchSize в Hibernate — це інструмент оптимізації лінивого завантаження (LAZY), який вказує Hibernate завантажувати пов’язані сутності або колекції пакетами (батчами) через конструкцію WHERE ... IN (?, ?, ...), замість того щоб виконувати окремий SQL-запит на кожен окремий об’єкт.

Це один із найефективніших і найбезпечніших засобів боротьби з проблемою N+1:

\[\text{Кількість SQL-запитів} = 1 + \left\lceil \frac{N}{\text{batchSize}} \right\rceil\]
Без @BatchSize (Проблема N+1):
1. SELECT * FROM orders;                                -- 1 запит, повернув 100 замовлень
2. SELECT * FROM order_items WHERE order_id = 1;        -- Запит для замовлення 1
3. SELECT * FROM order_items WHERE order_id = 2;        -- Запит для замовлення 2
...
101. SELECT * FROM order_items WHERE order_id = 100;    -- 100 запитів! Разом: 101 запит.

З анотацією @BatchSize(size = 25):
1. SELECT * FROM orders;                                -- 1 запит (100 замовлень)
2. SELECT * FROM order_items WHERE order_id IN (?, ..., ?); -- 1 запит на 25 замовлень одразу!
3. SELECT * FROM order_items WHERE order_id IN (?, ..., ?); -- ще 25 замовлень
4. SELECT * FROM order_items WHERE order_id IN (?, ..., ?); -- ще 25 замовлень
5. SELECT * FROM order_items WHERE order_id IN (?, ..., ?); -- ще 25 замовлень
РАЗОМ: 5 запитів замість 101 (скорочення кількості звернень до бази на 95%)!

Де розміщується анотація

Анотацію можна розмістити у двох місцях із різним ефектом:

1. Над колекцією (@OneToMany / @ManyToMany)

Завантажує пакет дочірніх колекцій для кількох батьківських сутностей одночасно:

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

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 30) // Підвантажувати позиції пачками по 30 замовлень
    private List<OrderItem> items = new ArrayList<>();
}

2. Над класом сутності (@Entity)

Пакетно ініціалізує неініціалізовані проксі батьківських сутностей (@ManyToOne / @OneToOne):

@Entity
@BatchSize(size = 30) // Ініціалізувати проксі User пачками по 30 штук
public class User {
    @Id
    private Long id;
    private String name;
}

🟡 Middle Level

Як Hibernate знаходить сутності для об’єднання в батч

При першому зверненні до лінивого поля одного об’єкта (наприклад, orders.get(0).getItems().size()):

  1. Hibernate перехоплює звернення через обгортку PersistentCollection.
  2. Hibernate заглядає в поточний контекст збереження (PersistenceContext / L1-кеш) сесії.
  3. Він знаходить усі інші наявні в пам’яті екземпляри сутностей того ж типу (Order), у яких колекція items ще не ініціалізована.
  4. Hibernate бере ідентифікатори знайдених сутностей (до ліміту size = N) і конструює єдиний SQL-запит з оператором IN.
  5. Отриманий результат розкладається по колекціях відповідних об’єктів Order в оперативній пам’яті.
  6. Коли цикл доходить до orders.get(1).getItems(), дані вже знаходяться в пам’яті — другий SQL-запит взагалі не відправляється!

Глобальне налаштування: default_batch_fetch_size

Замість того щоб вручну розставляти @BatchSize над десятками сутностей і колекцій, у Spring Boot прийнято вмикати глобальне налаштування в application.yml:

spring:
  jpa:
    properties:
      hibernate:
        # Рекомендований діапазон для продакшену: від 20 до 50
        default_batch_fetch_size: 30

Цей рядок конфігурації автоматично вмикає пакетну вибірку для абсолютно всіх лінивих зв’язків і колекцій у всьому застосунку. Якщо над конкретним полем стоїть @BatchSize, її локальне значення перевизначить глобальне.


🔴 Senior Level

Головна плутанина: Batch Fetching проти JDBC Batch Writing

На співбесідах кандидати часто плутають два абсолютно різні налаштування Hibernate зі словом «batch»:

Параметр За що відповідає Напрямок даних Що генерує
@BatchSize / default_batch_fetch_size Читання (Fetching) З БД у застосунок SELECT ... WHERE id IN (?, ?, ...)
hibernate.jdbc.batch_size Запис (Flushing) Із застосунку в БД JDBC PreparedStatement.addBatch() (INSERT, UPDATE, DELETE)

Ці налаштування ніяк не пов’язані між собою і вирішують діаметрально протилежні завдання.

Стилі формування запитів: hibernate.batch_fetch_style

Hibernate вміє формувати оператор IN за допомогою різних стратегій (BatchFetchStyle):

  1. DYNAMIC: Hibernate формує точну кількість параметрів у IN (?, ...) під число залишку сутностей.
    • Мінус: Якщо в пам’яті залишилося 17 об’єктів, згенерується IN із 17 параметрів, якщо 23 — із 23. База даних змушена щоразу заново парсити SQL і компілювати план виконання (Execution Plan), засмічуючи кеш планів запитів (Prepared Statement Cache).
  2. PADDED (Стандарт продуктивності для Highload): Розміри батчів округлюються вгору до найближчого степеня двійки ($1, 2, 4, 8, 16, 32, \dots$):
    -- Якщо потрібно завантажити 23 об'єкти при батчі 32, сформується запит на 32 параметри:
    SELECT * FROM order_items WHERE order_id IN (?, ?, ... 32 параметри);
    -- Слоти, яких бракує, дублюють перший ID
    
    • Плюс: Кількість унікальних SQL-запитів суворо фіксована (лише кілька варіантів). СУБД кешує скомпільований план запиту, різко знижуючи навантаження на процесор бази даних.
  3. LEGACY: Ділить батч на фіксовані дільники (наприклад, для батчу 32 генеруються форми на 32, 16, 10, 9, … 1).

Увімкнення PADDED-стилю у Spring Boot:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 32
        batch_fetch_style: PADDED

Обмеження діалектів СУБД за кількістю параметрів

При виборі розміру батчу сеньйор зобов’язаний враховувати жорсткі ліміти реляційних баз даних:

  • Oracle DB: Жорстка межа у 1000 елементів усередині конструкції IN (...) (помилка ORA-01795: maximum number of expressions in a list is 1000).
  • Microsoft SQL Server: Ліміт на 2100 параметрів в одному запиті.
  • PostgreSQL: Ліміт на $65535$ параметрів (обмеження протоколу bind).

Рекомендація: Встановлювати @BatchSize більше 100 практично ніколи не має сенсу. Оптимальне емпіричне значення: 30–50. Воно дає 97–98% скорочення мережевих запитів без ризику вичерпання пам’яті та перевищення лімітів СУБД.


4 Tricky Questions

1. Чи працюватиме @BatchSize, якщо батьківські сутності завантажувалися не одним списком, а вибиралися окремо в різних місцях методу?

Відповідь: Так, працюватиме бездоганно. У цьому полягає фундаментальна перевага @BatchSize над FetchMode.SUBSELECT:

  1. FetchMode.SUBSELECT вимагає, щоб батьки були витягнуті суворо одним вихідним запитом (він перевикористовує вихідний текст SQL).
  2. @BatchSize абсолютно байдужий до того, як саме сутності потрапили в пам’ять. Він інспектує поточний стан PersistenceContext.
  3. Якщо ви виконали find(Order.class, 1L), потім find(Order.class, 2L), а потім додали сутності в сесію через merge(), при першому зверненні до order1.getItems() Hibernate знайде в сесії неініціалізований order2 і вичитає позиції для обох замовлень за один спільний SQL-запит WHERE order_id IN (1, 2).

2. Чи вирішує @BatchSize проблему пагінації з дочірніми колекціями, де пасує JOIN FETCH?

Відповідь: Так, @BatchSize — це еталонне рішення проблеми пагінації колекцій у JPA.

  • При використанні JOIN FETCH на колекції з Pageable Hibernate змушений викачувати всю таблицю в пам’ять JVM (HHH000104), оскільки LIMIT/OFFSET на рівні бази спотворюють кількість рядків через декартовий добуток.
  • При використанні @BatchSize запит за батьками виконується стандартно:
    SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0;
    

    База даних чесно й ефективно застосовує LIMIT та OFFSET за індексами.

  • Потім, коли бізнес-код звертається до order.getItems(), Hibernate виконує рівно один додатковий запит на 20 замовлень:
    SELECT * FROM order_items WHERE order_id IN (id1, id2, ... id20);
    
  • Підсумок: рівно 2 швидких SQL-запити, пагінація працює на стороні СУБД, пам’ять JVM не перевантажується.

3. Чому @BatchSize на класі @Entity не спрацьовує при пошуку через entityManager.find(User.class, id)?

Відповідь: Метод find(EntityClass, id) приймає рівно один конкретний первинний ключ.

  1. У момент виклику find() у Hibernate немає інших кандидатів для об’єднання в батч. Він виконує звичайний одиночний запит SELECT * FROM users WHERE id = ?.
  2. Анотація @BatchSize над класом спрацьовує тільки при ініціалізації неініціалізованих проксі, коли в поточній сесії вже накопичилося кілька посилань на різні сутності цього типу (наприклад, при завантаженні 50 замовлень, у кожного з яких є поле @ManyToOne User user, представлене проксі).

4. Чи може @BatchSize призвести до деградації продуктивності порівняно зі звичайним LAZY?

Відповідь: Так, у двох специфічних сценаріях:

  1. Випадковий одиничний доступ: Якщо в сесію завантажено 100 замовлень, але бізнес-логіці потрібно прочитати позиції тільки одного конкретного замовлення, Hibernate з @BatchSize(size = 50) завантажить позиції відразу для перших 50 замовлень. Виконання запиту займе більше часу, а вичитані 49 колекцій залишаться незатребуваними і даремно займатимуть пам’ять купи (JVM Heap).
  2. Занадто великий розмір батчу (size = 1000): Запит із гігантським списком параметрів IN довго парситься оптимізатором СУБД, а результуючий пакет мережевих даних може перевищити розмір буфера сокета, збільшуючи мережеву затримку.

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

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

«Анотація @BatchSize оптимізує ліниве завантаження (LAZY) в Hibernate, об’єднуючи запити до пов’язаних даних у пакети за допомогою оператора WHERE ... IN (?, ?, ...). Замість $N$ запитів при класичній проблемі N+1 виконується лише $1 + \lceil N / \text{batchSize} \rceil$ запитів. Анотація застосовується до колекцій (@OneToMany) або над класами сутностей (@ManyToOne проксі). У production-застосунках стандартом є ввімкнення глобального налаштування hibernate.default_batch_fetch_size = 30-50, що автоматично захищає весь застосунок від лавини N+1 запитів і є найкращим рішенням для пагінації пов’язаних колекцій».

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

  • Кількість SQL-запитів типу SELECT ... WHERE ... IN у метриках Hibernate Statistics.
  • Відсоток влучань у кеш планів СУБД (Prepared Statement Cache Hit Ratio при використанні PADDED).
  • Час відповіді ендпоінтів зі складними ієрархічними зв’язками.

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

  • ❌ «@BatchSize керує пакетною вставкою INSERT у базу даних» — Це фундаментальна помилка: @BatchSize керує виключно читанням (SELECT), а за вставку відповідає hibernate.jdbc.batch_size.
  • ❌ «Я ставлю batchSize = 10000, щоб завантажити все одним запитом» — Це призведе до помилок перевищення лімітів СУБД (наприклад, ORA-01795 в Oracle) та перевантаження пам’яті.
  • ❌ «@BatchSize замінює JOIN FETCH у всіх випадках» — Для одиничної вибірки об’єкта за ID зі зв’язками JOIN FETCH ефективніший, оскільки робить рівно 1 запит замість 2.
  • ❌ «@BatchSize не працює зі Spring Data JPA» — Він повністю підтримується, особливо через глобальну властивість default_batch_fetch_size.

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