JavaRush /Курси /Hibernate deep-dive /JDBC batching у Hibernate<...

JDBC batching у Hibernate

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

1. Вступ

Коли ви пишете звичайний CRUD, один INSERT або один UPDATE рідко кого лякає. Але щойно з’являється сценарій на кшталт «імпортувати 10 000 товарів», Hibernate раптово перетворюється на дуже чесного друга: він робить рівно те, що ви попросили… просто робить це 10 000 разів. А кожна така операція — це окреме звернення до бази даних, з усіма накладними витратами.

До цього нас переважно цікавило, щоб Hibernate поводився коректно: де проходить транзакція, коли стається flush, чому об’єкт managed або detached. У блоці про продуктивність питання змінюється не радикально, а приземлено: той самий цикл запису, орієнтований на сутності, треба змусити ходити в базу не по одному запиту за раз.

Якщо говорити простими словами, проблема масового запису майже завжди не в тому, що PostgreSQL «не вміє вставляти швидко», а в тому, що застосунок робить занадто багато окремих кроків: готує statement, надсилає його мережею, чекає на відповідь, повторює. Навіть якщо кожен крок займає мілісекунди, 10 000 кроків перетворюються на помітний час. JDBC batching — це спроба скоротити кількість таких окремих походів, згрупувавши однотипні операції в партії.

Важливий момент: batching — це не про бізнес-логіку і не про «правильні анотації». Це інженерна оптимізація на рівні того, як саме Hibernate надсилає SQL у JDBC.

2. Що таке JDBC batching

JDBC batching звучить майже як «увімкнути turbo-режим». На практиці все значно прозаїчніше: Hibernate намагається використати стандартний механізм JDBC, у якому один і той самий SQL-statement багато разів виконується з різними параметрами, а драйвер отримує їх пакетно. По суті, це «зібрати кілька однакових операцій і надіслати разом», щоб зменшити накладні витрати на комунікацію.

Корисно одразу зняти кілька хибних очікувань. JDBC batching не перетворює ваші 10 000 вставок на одну велику «атомарну» супер-вставку. Він також не робить із Hibernate інструмент для масових операцій рівня «одна команда оновила мільйон рядків» — це вже інший клас задач. І найважливіше: batching не виникає з повітря. Він потребує, щоб Hibernate взагалі міг сформувати багато однакових SQL-операцій, які можна згрупувати.

Batching — не один великий INSERT

Коли розробник чує «batch insert», часто виникає картинка: «Ага, значить буде один SQL на кшталт INSERT INTO ... VALUES (...), (...), (...)». Іноді драйвери справді можуть переписати пакетні вставки у багаторядковий INSERT (наприклад, PostgreSQL-драйвер уміє таке за певних налаштувань), але це вже вторинна оптимізація на боці драйвера. Hibernate передусім мислить так: «У мене один SQL-шаблон і багато наборів параметрів, давайте надішлемо пачкою через addBatch() / executeBatch()».

Тобто batching — це все ще серія окремих вставок і оновлень, просто згрупованих на рівні JDBC. База даних і надалі має обробити кожен рядок, перевірити обмеження, індекси, зовнішні ключі. Тому batching — це про зменшення накладних витрат на комунікацію, а не про скасування законів фізики.

JDBC batching і @BatchSize — різні речі

У нашому курсі вже був модуль про fetching, де траплявся @BatchSize і ідея «підвантажити ліниві зв’язки партіями». Це теж називається batching, але це batching читання (SELECT), а сьогоднішня тема — batching запису (INSERT/UPDATE/DELETE) на JDBC-рівні.

Щоб не заплутатися, тримайте просту ментальну межу. @BatchSize намагається зменшити кількість SELECT’ів під час лінивого завантаження асоціацій. JDBC batching намагається зменшити кількість окремих звернень до бази під час масового запису. Ці механізми живуть у різних місцях, розв’язують різні проблеми й налаштовуються по-різному. Якщо ви їх змішаєте, отримаєте чудову комбінацію: «нічого не прискорилося, але стало незрозуміліше».

3. Де batching у життєвому циклі Hibernate

Найчастіша причина, чому batching «не працює», — розробник шукає його не там. Він дивиться на код, бачить цикл із persist() і думає: «Ну все, це ж і є масовий запис». А Hibernate в цей момент тихо посміхається, тому що в циклі ви робите… зміни в пам’яті, а не надсилання SQL.

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

flowchart TD
  %% Це не «як написаний ваш for-цикл», а «де саме відбувається надсилання SQL».
  %% Batching проявляється в момент flush, коли Hibernate доходить до JDBC-операцій.
  A["Java-код: em.persist(entity)"] --> B["Сутність стає managed"]
  B --> C["Hibernate кладе операцію в чергу (ActionQueue)"]
  C --> D["Flush (перед commit або раніше)"]
  D --> E["Генерація SQL і підготовка PreparedStatement"]
  E --> F["statement.addBatch() для однотипних операцій"]
  F --> G["statement.executeBatch()"]
  G --> H["PostgreSQL виконує вставки та оновлення"]

persist() — це ще не SQL

Коли ви робите em.persist(product), Hibernate, грубо кажучи, записує собі: «Гаразд, цю сутність треба буде вставити». Але поки не відбулася flush-фаза, у базу може не піти жодного INSERT.

У навчальному проєкті Commerce Persistence Lab це особливо зручно спостерігати на сценарії імпорту: у вас може бути список Product, і ви їх викликаєте persist у циклі. Ззовні здається, що ви вже «вставляєте», але фактично лише накопичуєте намір вставити.

Мініприклад у стилі нашого проєкту — код короткий і максимально прямий:

package com.example.commerce.catalog.service;

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class ProductImportService {

    @Transactional
    public void importProducts(List<Product> products, EntityManager em) {
        // Важливо: persist() не надсилає SQL одразу.
        // Тут ми лише реєструємо нові сутності в persistence context.
        for (Product product : products) {
            em.persist(product);
        }

        // Реальні INSERT-и поїдуть у БД на flush/commit (якщо flush не був раніше).
    }
}

Цей код створює правильний контекст для batching: багато однотипних операцій в одній транзакції. Але він ще не доводить, що batching справді станеться. Чому? Тому що batching починається лише там, де Hibernate реально надсилає SQL.

Flush — «момент істини» для batching

Batching завжди пов’язаний із тим, як Hibernate виконує SQL під час flush. Можна запам’ятати просту формулу: batching живе всередині одного flush-циклу. Коли flush відбувається, Hibernate починає виконувати накопичені INSERT/UPDATE, і саме там він намагається групувати однотипні операції в batch.

Тому, якщо у вас транзакція дуже коротка й flush стається часто, або ви самі викликаєте flush() на кожен чих, batch може просто не встигнути накопичитися. Якщо ж flush станеться один раз наприкінці великої транзакції, Hibernate отримає шанс зібрати пристойну пачку.

Поки достатньо зафіксувати думку: batching — це оптимізація фази надсилання SQL, а не фази зміни об’єктів.

JDBC-рівень: addBatch() і executeBatch()

На найнижчому рівні Hibernate спілкується з базою через JDBC. Для вставок і оновлень він зазвичай використовує PreparedStatement, тому що це безпечніше й швидше, ніж постійно збирати SQL-рядок. І batching працює лише тоді, коли Hibernate може багаторазово виконати один і той самий PreparedStatement, змінюючи лише параметри.

Якщо в Hibernate виходять різні SQL-шаблони — різні таблиці, різні набори колонок, інший порядок дій — він фізично не зможе «зібрати їх в один batch». Тому batching не любить хаос. Він любить повторюваність. Прямо як ми всі в понеділок зранку: дайте однакові задачі — і ми швидші.

4. Умови для batching

Тепер найпрактичніше: що має збігтися, щоб batching узагалі мав шанс. Важливо сприймати це не як «чекліст магічних галочок», а як причинно-наслідкову модель. Hibernate не зобов’язаний робити batching, він лише може спробувати, якщо має відповідний матеріал.

Нижче — компактна таблиця, яка допомагає не плутатися. Після неї ми пройдемося по ключових пунктах текстом.

Умова Що це означає по-людськи Чому без цього batching не станеться
hibernate.jdbc.batch_size > 0 batching увімкнено й є ліміт партії інакше Hibernate взагалі не використовуватиме executeBatch()
Багато однотипних операцій в одній Session/transaction є «сировина» для пачки batch просто немає з чого зібрати
Однакова форма SQL один SQL-шаблон багаторазово не можна групувати різні statements
Flush відбувається «порціями», а не після кожної операції встигає накопичитися партія batch розпадається на дрібноту
Генерація id не змушує робити INSERT негайно Hibernate може відкласти вставки якщо insert потрібен просто зараз, batching ламається
Драйвер і діалект нормально підтримують batching JDBC справді виконає batch іноді драйвер «уміє» лише на папері

Саме з цього списку видно, чому одного hibernate.jdbc.batch_size замало. Далі по ланцюжку нас гальмуватимуть три речі: id, який змушує вставляти одразу; роздутий persistence context у довгому циклі; і рваний SQL-потік, де однакові statements не встигають зібратися в нормальну пачку.

Однакові SQL-операції та рівний потік

Уявіть, що ви імпортуєте лише Product. Hibernate генерує один SQL-шаблон insert into product (...) values (...) і повторює його багато разів. Це ідеальна ситуація для batching.

А тепер уявіть, що ви в одному циклі чергуєте Product і Category. З погляду Java все красиво: один цикл, дві операції. Але з погляду batching ви отримали потік такого виду: INSERT product, INSERT category, INSERT product, INSERT category… І Hibernate просто не може зібрати 20 однакових INSERT product підряд, тому що між ними вкраплюються INSERT category.

Ось приклад, який зовні виглядає як масовий запис, але batching дається йому складніше:

import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Transactional
public void saveCatalog(List<Product> products, List<Category> categories, EntityManager em) {
    // Локально задаємо розмір batch (зручно для експериментів у лабораторній).
    em.unwrap(Session.class).setJdbcBatchSize(20);

    for (int i = 0; i < products.size(); i++) {
        // Потік дій «перемішаний»: INSERT Product, потім INSERT Category.
        // Це погіршує можливість зібрати довгу серію однакових statements.
        em.persist(products.get(i));
        em.persist(categories.get(i));
    }

    // INSERT-и все одно відбудуться на flush/commit, але batching може стати «рваним».
}

Так, тут є setJdbcBatchSize(20). Так, тут одна транзакція. Але потік дій перемішаний, і batching стає менш передбачуваним. Він може спрацьовувати частково, маленькими партіями, а може майже не дати ефекту — залежно від того, як у конкретній ситуації Hibernate впорядкує дії на flush-фазі.

Транзакція та межі flush

Batching майже завжди означає, що у вас є один unit of work, усередині якого ви робите багато вставок або оновлень. Саме транзакція дає Hibernate простір для маневру: він може накопичувати зміни й надсилати їх наприкінці або партіями.

Якщо транзакції немає або вона розбита на багато мікротранзакцій, batching буде мінімальним. Це схоже на спробу зібрати вантажівку з коробок, але вам кожні 30 с кажуть: «Виїжджайте негайно, навіть якщо там одна коробка». Вантажівка їде — batching не народжується.

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

Драйвер теж бере участь

У навчальному стеку ми працюємо з PostgreSQL, і драйвер PostgreSQL JDBC загалом batching підтримує. Але в реальному житті іноді з’ясовується, що «налаштування увімкнено», а ефекту майже немає, бо драйвер обробляє batch неефективно або сервер не отримує очікуваної оптимізації.

Тут корисно тримати спокійну інженерну позицію. Hibernate batching — це стратегія «зменшити накладні витрати». Якщо ви ввімкнули batching і не побачили різниці, це не привід сперечатися з реальністю. Це привід виміряти, подивитися логи, переконатися, що групування взагалі відбувається, і лише потім робити висновки.

5. Увімкнення batching у Spring Boot + Hibernate

На практиці batching вмикають через hibernate.jdbc.batch_size. Його можна задати глобально в Hibernate properties або локально на Session через setJdbcBatchSize(...), якщо ви хочете перевірити конкретний імпорт або тимчасово перевизначити поведінку для окремого write-flow.

Тут важливо зафіксувати лише дві думки. batch_size — це верхня межа партії, а не обіцянка «рівно по 20». І сам факт налаштування ще нічого не доводить: якщо IDENTITY, частий flush() або рваний потік SQL ламають grouping, Hibernate не зобов’язаний показати красивий batch лише тому, що ви написали число в конфігурації.

6. Сценарії в Commerce Persistence Lab

Щоб сьогоднішня тема не зависла в повітрі, прив’яжемо її до нашого домену. У нас є сутність Product, і імпорт товарів — природний сценарій із масовими вставками. Це ідеальний полігон для batching, тому що вставки однотипні й повторюються багато разів.

Почнемо з найчистішого варіанта — імпорту лише товарів. Він добрий тим, що навіть слабкий SQL-лог швидко показує повторюваність.

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Transactional
public void importOnlyProducts(List<Product> products, EntityManager em) {
    // Максимально «рівний» потік: тільки Product → повторюваний INSERT.
    for (Product p : products) {
        em.persist(p);
    }

    // Це найкращий кандидат на ефективний batching (за увімкненого налаштування).
}

Тепер контрастний приклад — імпорт товарів разом із чимось іншим (категорії, зв’язки призначення тощо). Навіть якщо код усе ще виглядає як масовий, потік SQL стає рваним, і batching може групувати гірше.

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Transactional
public void importProductsAndCategories(List<Product> products,
                                        List<Category> categories,
                                        EntityManager em) {
    for (int i = 0; i < products.size(); i++) {
        // Перемішування різних типів сутностей зазвичай означає різні INSERT-и.
        em.persist(products.get(i));
        em.persist(categories.get(i));
    }

    // На flush Hibernate буде складніше зібрати великі пачки однотипних операцій.
}

Другий приклад не «поганий» сам по собі. Він просто ілюструє головну думку лекції: batching не залежить від того, скільки у вас for-циклів. Він залежить від того, наскільки однотипний потік SQL може зібрати Hibernate на flush-фазі.

7. Перевірка batching за логами

Підтверджувати batching треба за логами, а не за відчуттям «ніби стало швидше». SQL-лог (org.hibernate.SQL) показує форму запитів, але не завжди сам факт пакетного надсилання: ви можете бачити ті самі insert into product ..., навіть якщо драйвер виконав їх batch-ами.

Прямий індикатор — org.hibernate.orm.jdbc.batch. Саме там видно, чи дійшов Hibernate до addBatch() / executeBatch() і чи почав він реально виконувати партії. Час виконання — корисна вторинна метрика, але не доказ сам по собі.

8. Типові помилки при JDBC batching

Помилка №1: вважати, що batching вмикається самим фактом існування циклу.
Цикл — це лише форма Java-коду. Hibernate цікавить не цикл, а те, які SQL-операції він зможе зібрати на flush-фазі. Один і той самий for може породити «чистий» потік однотипних INSERT, а може породити перемішаний набір операцій, який майже не батчиться.

Помилка №2: плутати batching запису з batching читання (@BatchSize).
Це дуже популярна плутанина, тому що слово те саме, а зміст різний. @BatchSize допомагає з лінивими SELECT’ами й зменшує кількість запитів під час завантаження асоціацій. JDBC batching допомагає надсилати INSERT/UPDATE/DELETE пачками. Якщо ви ввімкнули одне й чекаєте ефекту іншого, отримаєте лише розчарування й легку ненависть до абстракцій.

Помилка №3: ставити величезний hibernate.jdbc.batch_size, не розуміючи, що це ліміт, а не гарантія.
Якщо ви поставите 500 або 1000 «про всяк випадок», ви не зобов’язані отримати партії рівно по 1000. А от несподіване споживання пам’яті та дивну поведінку в довгих транзакціях — цілком можете. Для старту значно розумніше 2050 і спостереження за логами.

Помилка №4: «доводити» batching лише часом виконання.
Час — показник корисний, але надто шумний. Один прогін швидший за інший — не завжди через batching. Іноді це JIT, іноді кеш диска, іноді прогрів бази, іноді просто вдалий день. Якщо ви не підтвердили batching логами org.hibernate.orm.jdbc.batch, ви поки що вірите, а не знаєте.

Помилка №5: не відокремлювати фазу «накопичую зміни» від фази «надсилаю SQL».
Якщо ви очікуєте batching у момент persist(), ви шукатимете ефект не там. persist() реєструє сутність, batching проявляється на flush-фазі. Поки це не стало вашою внутрішньою моделлю, будь-які експерименти з масовим записом відчуватимуться як шаманство: «то працює, то ні».

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