1. Batch fetching замість великого JOIN
Після JOIN FETCH легко зловити ейфорію: «Ура, один SQL замість двадцяти». Але реальність швидко нагадує, що один запит теж може стати монстром: він повертає більше рядків, дає ширший результат і часом ламає природний сценарій читання, особливо якщо ви читаєте список і хочете ліміт або пагінацію. У таких випадках хочеться рішення посередині: не один величезний JOIN, але й не N+1.
До цього ми розбирали два способи сказати Hibernate: «ці зв’язки потрібні наперед у цьому читанні» — через JOIN FETCH і через EntityGraph. Але бувають сценарії, де наперед тягнути граф зовсім не хочеться: LAZY нас улаштовує, проблема лише в тому, що вторинні SELECT ідуть по одному. Batch fetching відповідає саме на це: він залишає ледачу модель як є, але групує вторинні SELECT.
Batch fetching — це якраз такий інженерний компроміс. Він не обіцяє «усе в одному SQL» і не вдає, ніби список із 20 замовлень із 200 позиціями — це «маленькі дані». Він чесно каже: нехай буде кілька запитів, але зробімо так, щоб вторинні читання не відбувалися за тим самим шаблоном «по одному батьківському запису за раз».
Якщо провести аналогію, JOIN FETCH — це як замовити доставку всього світу однією фурою: швидко, але фура може не проїхати у вузькі ворота. Batch fetching — це як замовити кілька мікроавтобусів замість 100 поїздок на самокаті: не «одним махом», зате значно розумніше.
2. Ідея batch fetching: IN(...)
Batch fetching у Hibernate — це механізм, який зменшує кількість вторинних SELECT під час ледачого завантаження. Суть проста: коли Hibernate бачить, що ви починаєте ініціалізувати ледачий зв’язок (проксі або колекцію), він намагається не просто завантажити «одну штуку», а завантажити кілька таких самих штук пачкою — за кількома id одразу, використовуючи SQL на кшталт where id in(?, ?, ?).
Тобто N+1 перетворюється на приємнішу форму: 1 + ceil(N / batchSize). Це важливо: «плюс один» нікуди не зникає, просто замість «ще N запитів» з’являється «ще кілька запитів, але пачками».
Невелика схема того, що відбувається в одній транзакції:
flowchart TD
A[Запит: завантажили список PurchaseOrder] --> B[У контексті є 20 замовлень]
B --> C["Код торкається order.getItems()"]
C --> D[Hibernate бачить: колекції items ще не ініціалізовані]
D --> E["Обирає пачку order_id (наприклад, 10)"]
E --> F["SQL: select * from order_item where order_id in (...10 id...)"]
F --> G[Ініціалізує items одразу для 10 замовлень]
G --> H[Наступний доступ до items у цих замовленнях уже без SQL]
Дуже важлива межа: batch fetching — це про читання (secondary selects, lazy initialization). Це не про JDBC batching під час запису (коли INSERT/UPDATE надсилаються пачками). Слова схожі, але сенс різний, і плутати їх — класична студентська пастка.
Щоб не переплутати, зручно тримати невелику таблицю в голові:
| Термін | Про що | Де видно |
|---|---|---|
| Batch fetching | про групування lazy-завантажень (secondary selects) | select ... where id in (...) під час читання |
| JDBC batching | про пакетне надсилання INSERT/UPDATE/DELETE | пакети SQL-операторів під час запису |
3. Batch fetching для to-one
Коли говорять про N+1, багато хто думає лише про колекції (OneToMany). Але на практиці значна частина N+1 прилітає від to-one зв’язків: ManyToOne і OneToOne, які за замовчуванням часто LAZY. Проксі виглядає як звичайний об’єкт, але перша ж спроба прочитати поле перетворюється на SELECT.
У Commerce Persistence Lab дуже природний приклад — OrderItem → Product. Позиції замовлення (OrderItem) майже завжди мають посилання на товар (Product), але ми не хочемо тягнути весь товарний граф одразу, тому що читань багато й вони різні. Отже, product у позиції — LAZY. А далі простий цикл по позиціях починає стріляти запитами по одному.
Найпростіший приклад (наївний, але життєвий):
import jakarta.persistence.EntityManager;
import java.util.List;
// Завантажуємо позиції замовлення: тут читаємо лише OrderItem, а Product лишається LAZY.
List<OrderItem> items = entityManager.createQuery(
"select i from OrderItem i where i.order.id = :orderId",
OrderItem.class
).setParameter("orderId", orderId)
.getResultList();
// Під час першого звернення до product Hibernate може виконати secondary SELECT (і так для кожного item).
items.forEach(i -> System.out.println(i.getProduct().getSku())); // SKU-123...
Якщо product — LAZY і batch fetching не увімкнено, ви побачите приблизно такий профіль: один запит на OrderItem, а потім ще по одному запиту на Product для кожного item.
Як увімкнути @BatchSize для to-one зв’язку
Для to-one зв’язку можна поставити @BatchSize прямо на поле асоціації. Це локально й зрозуміло: «коли будемо ледачо підтягувати product, роби це пачками».
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
import org.hibernate.annotations.BatchSize;
public class OrderItem {
@ManyToOne(fetch = FetchType.LAZY) // Ледача асоціація: без оптимізації легко отримати N+1
@BatchSize(size = 20) // Просимо Hibernate підвантажувати Product пачками (secondary SELECT з IN)
private Product product;
// ...
}
Що зміниться в SQL? Під час першого звернення до getProduct().getSku() Hibernate може виконати запит приблизно такого вигляду (спрощено):
-- Batch fetching: один запит підвантажує кілька Product за id.
select p.*
from product p
where p.id in (?, ?, ?, ?, ?)
Зверніть увагу на «може». Batch fetching — це не кнопка «гарантувати один запит». Hibernate збирає пачку з тих проксі, які вже є в persistence context і ще не ініціалізовані. Якщо в контексті лише один item — пачки не буде, і запит буде звичайним.
І маленька деталь, яка часто дивує новачків: batch fetching спрацьовує, коли ви торкнулися однієї проксі, а завантажилися одразу кілька. Тобто ви відкриваєте одну шафу, а Hibernate заодно приносить речі ще з девʼятнадцяти шаф, бо «все одно зараз знадобиться». У звичайному житті це виглядає як магія; в ORM — це просто оптимізація secondary SELECTів.
4. Batch fetching для колекцій
Колекції — друга велика зона N+1. І тут компроміс «кілька запитів, але пачками» буває навіть кориснішим, ніж для to-one, тому що JOIN FETCH колекції дуже швидко роздуває число рядків результату. Особливо якщо замовлень багато, а items у кожного не по одному.
Візьмімо типовий backoffice-сценарій: ви читаєте 20 останніх замовлень і десь у коді рахуєте кількість позицій або виводите «перші N позицій» у логіці формування відповіді. Навіть якщо ви свідомо не робите JOIN FETCH, ви можете випадково ініціалізувати items у кожного замовлення й отримати N+1.
Наївний код:
import jakarta.persistence.EntityManager;
import java.util.List;
// Завантажуємо лише замовлення: колекція items лишається LAZY.
List<PurchaseOrder> orders = entityManager.createQuery(
"select o from PurchaseOrder o order by o.createdAt desc",
PurchaseOrder.class
).setMaxResults(20) // Типовий розмір сторінки списку
.getResultList();
// Перший виклик getItems() для кожного замовлення може викликати secondary SELECT (і так N разів).
orders.forEach(o -> System.out.println(o.getItems().size())); // 3, 5, 1...
Без batch fetching це часто перетворюється на 21 запит: 1 на замовлення і 20 на колекції.
Як увімкнути @BatchSize на колекції
Для колекції (OneToMany) @BatchSize ставиться прямо на поле колекції. Тоді Hibernate, коли почне ініціалізувати items в одного замовлення, підтягне items пачкою для кількох замовлень.
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.BatchSize;
public class PurchaseOrder {
@OneToMany(mappedBy = "order") // Зв’язок залишається LAZY, але secondary SELECT будемо групувати
@BatchSize(size = 10) // Підвантаження items одразу для кількох замовлень (за order_id IN (...))
private List<OrderItem> items = new ArrayList<>();
// ...
}
Якщо у вас 20 замовлень, а batch size 10, то замість 20 запитів на items вийде приблизно 2 запити, якщо в межах однієї транзакції ви справді ініціалізуєте items у різних замовленнях.
SQL-профіль буде схожим на:
select i.*
from order_item i
where i.order_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Тут важливо зрозуміти ціну рішення. Ви не зробили один великий join, отже база не множить рядки «orders × items» в одному запиті. Але ви все одно завантажуєте items — просто робите це розумніше: двома запитами замість двадцяти.
Чому це не те саме, що JOIN FETCH
JOIN FETCH намагається «все одразу» в одному SQL. Batch fetching залишає завантаження ледачим, але оптимізує ситуацію, коли вам усе одно довелося б ініціалізувати цю ледачу асоціацію, і ви робите це серійно для набору батьків.
Тобто batch fetching — це не «я заздалегідь вирішив, що items потрібні». Це радше «я все одно опинився в коді, який торкається items у багатьох замовленнях; давайте хоча б не робити 20 однакових запитів підряд».
5. Налаштування: анотація vs глобально
Коли ви зрозуміли ідею batch fetching, наступне логічне запитання — «де саме це вмикати, щоб не розповзлося по проєкту, як пліснява?». І тут важливо зробити налаштування керованим: нам не потрібна «магія всюди», нам потрібен свідомий інструмент.
У Hibernate зазвичай є два основні важелі: локальне налаштування через @BatchSize (на асоціації/колекції або на сутності) і глобальне налаштування через property hibernate.default_batch_fetch_size. Локальне налаштування виграє тим, що документує рішення просто поруч із моделлю: «ось тут ми очікуємо типовий патерн доступу». Глобальне — тим, що ви можете одним місцем увімкнути базовий рівень оптимізації, а точково перевизначати.
Локально: @BatchSize на асоціації або колекції
Для колекції, як ми вже зробили:
import org.hibernate.annotations.BatchSize;
@BatchSize(size = 10)
private List<OrderItem> items;
Для to-one:
import org.hibernate.annotations.BatchSize;
@BatchSize(size = 20)
private Product product;
На рівні сутності: @BatchSize на Product
Іноді зручніше сказати не «у цієї зв’язки batch size 20», а «загалом Product часто підвантажуватиметься пачками, коли трапляється в проксі». Тоді можна поставити @BatchSize на сутність:
import jakarta.persistence.Entity;
import org.hibernate.annotations.BatchSize;
@Entity
@BatchSize(size = 20) // Будь-які lazy-проксі Product прагнутимуть завантажуватися групами по 20
public class Product {
// ...
}
Це особливо корисно, коли Product трапляється як lazy ManyToOne у кількох місцях (OrderItem.product, InventoryItem.product, assignments тощо), і ви не хочете повторювати одне й те саме на кожному полі.
Глобально: hibernate.default_batch_fetch_size в application.yml
Якщо ви хочете ввімкнути batch fetching як базовий страховий механізм — не замість проєктування читань, а як розумне значення за замовчуванням — можна задати глобальний розмір пачки.
spring:
jpa:
properties:
hibernate:
# Глобальний розмір пачки для batch fetching (secondary selects під час lazy-init)
default_batch_fetch_size: 20
Важливий нюанс: глобальне налаштування — це компроміс. Якщо ви поставите занадто маленьке значення, ефект буде слабшим. Якщо занадто велике — почнете генерувати величезні IN(...), а це теж не безкоштовно.
6. Підбір розміру batch size
Найчастіша помилка під час тюнінгу ORM — вибирати числа на око й потім захищати їх вірою, а не спостереженням. У batch fetching це особливо помітно: поставили @BatchSize(100), стало «менше запитів», але раптом запити стали важчими, а база почала напружуватися через великі IN. Або навпаки: поставили 5, ефект є, але слабкий, і ви не розумієте, чому все одно «багато SQL».
Правильний шлях тут нудний, але чесний: ви вибираєте batch size як гіпотезу, вмикаєте SQL trace і вимірюєте профіль запитів на конкретному сценарії читання. У нашому курсі це особливо легко, бо є профілі sql-trace і, за бажанням, статистика Hibernate.
Проста арифметика, яка допомагає думати
Якщо ви завантажуєте 20 замовлень і точно знаєте, що код торкатиметься items у всіх 20, то:
— без batch fetching ви потенційно отримаєте 1 + 20 запитів;
— з @BatchSize(10) ви очікуєте 1 + 2 запити (в ідеальному сценарії);
— з @BatchSize(20) ви очікуєте 1 + 1 запит на items.
Але «в ідеальному сценарії» — ключові слова. Batch fetching залежить від того, що є в persistence context і як саме відбувається ініціалізація. Іноді ви торкнулися items лише у 3 замовленнях — і тоді batch size 20 вам ніяк не допоможе, бо батчити нічого.
Міні-замір через Hibernate Statistics
Якщо у вас увімкнена статистика Hibernate, можна зробити дуже простий замір кількості SQL statementів. Це не повноцінний performance audit, але як навчальний інструмент — чудово.
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
// Отримуємо статистику з SessionFactory: це саме лічильники Hibernate, а не метрика бази.
Statistics stats = entityManager.getEntityManagerFactory()
.unwrap(SessionFactory.class)
.getStatistics();
stats.clear(); // Скидаємо лічильники перед запуском сценарію
// ... тут виконуємо сценарій читання ...
System.out.println("Підготовлені оператори = " + stats.getPrepareStatementCount());
// Prepared statements = 3
Так, це грубо. Так, у реальному проєкті ви дивитиметеся і на форму SQL, і на кількість рядків, і на план виконання. Але на етапі навчання статистика допомагає спіймати базовий факт: «у нас стало 3 запити замість 21» — і далі вже розбиратися, чому.
Як зрозуміти за SQL trace, що batch fetching увімкнувся? Ви хочете побачити, що замість повторюваних запитів по одному id з’явилися запити з IN(...). Якщо ви читаєте PurchaseOrder.items, то побачите where order_id in(...). Якщо читаєте OrderItem.product, то побачите where product_id in(...) або where id in(...) по Product.
І ще один нюанс: batch size — це tuning knob, а не сакральне число. Часто розумні значення збігаються з типовим розміром «порції», яку ви й так читаєте: наприклад, з розміром сторінки списку (20), або з «половиною сторінки» (10), щоб не робити занадто великий IN і залишатися гнучким. Але обирати це потрібно не зі стелі, а для конкретного сценарію читання.
7. Типові помилки при batch fetching
Помилка №1: очікувати, що @BatchSize перетворить усе на один запит.
@BatchSize — не JOIN FETCH. Він не зобов’язаний завантажувати зв’язок одразу під час виконання першого запиту. Він оптимізує вторинні читання, які відбуваються під час lazy initialization. Тому «один запит» ви отримаєте лише в окремих випадках, коли вторинний запит справді зможе завантажити одразу все потрібне, а batch size збіжиться з обсягом даних.
Помилка №2: ставити @BatchSize скрізь підряд, бо «так менше запитів».
Batch fetching — це компроміс із ціною. Він зменшує кількість запитів, але може збільшити розмір кожного запиту і кількість даних, які ви матеріалізуєте. Якщо ввімкнути його всюди, ви ризикуєте отримати ситуацію «запитів менше, але кожен важчий і менш передбачуваний». У навчальному проєкті це ще терпимо, а в реальному — легко перетворюється на туман неявних підвантажень.
Помилка №3: вибирати занадто великий batch size і радіти, поки база не почне плакати.
Великі IN(...) не безкоштовні. У них є вартість на боці планувальника, є обмеження на кількість параметрів (і, загалом, здоровий глузд). Крім того, величезний batch може призвести до того, що ви підтягуватимете багато даних «про всяк випадок», хоча насправді код торкнеться лише частини. Batch size — це не «що більше, то краще», а «достатньо, щоб згрупувати типовий патерн доступу».
Помилка №4: перевіряти ефект @BatchSize не в тому сценарії (або не в тій транзакції).
Batch fetching працює всередині persistence context. Якщо ви завантажили список замовлень в одній транзакції, а потім десь в іншому шарі або поза транзакцією торкаєтеся lazy-зв’язків, у вас або буде LazyInitializationException, або зовсім інша картина SQL. Перевіряти batch fetching треба на відтворюваному сценарії, де «батьки» й подальша ініціалізація зв’язків відбуваються в межах однієї unit of work.
Помилка №5: думати, що @BatchSize «не працює», бо запит все одно не IN(...).
Іноді в контексті реально немає «пачки» однакових неініціалізованих асоціацій. Ви ініціалізували одну колекцію і більше жодну — тоді Hibernate немає з чого формувати batch. Або ви вже завантажили зв’язок іншим способом (наприклад, JOIN FETCH чи EntityGraph) — тоді batch fetching і не має проявлятися. У таких ситуаціях відсутність IN — не баг, а логіка.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ