1. Протокол безпеки bulk-операцій
Bulk-операції часто виглядають як чарівна кнопка: «один рядок JPQL — і все оновилося». Це справді так… але лише для бази даних. Hibernate при цьому не зобов’язаний автоматично синхронізувати ваш persistence context, і саме тут починаються тонкі, неприємні та дуже “виробничі” баги. У цьому розділі ми спокійно розкладемо проблему на частини й зрозуміємо, звідки береться розсинхрон.
Уявіть, що у вас є дві реальності: база даних і persistence context (кеш першого рівня всередині EntityManager/Session). У звичайному managed-flow Hibernate намагається тримати ці реальності в відносній гармонії: ви змінюєте поля в managed-об’єкта, Hibernate наприкінці unit of work надсилає UPDATE, і все збігається. Bulk-операція діє як «другий адміністратор у системі», який прийшов і безпосередньо переписав рядки в базі, не повідомивши вашим Java-об’єктам, що в них «змінилися факти біографії».
З практичної точки зору проблема виглядає так: ви читаєте Product, отримуєте managed-об’єкт, потім робите bulk update по цій самій таблиці, а потім знову дивитеся на product.getStatus() і бачите старе значення. І найпідступніше: у коді немає ні винятків, ні попереджень. Усе працює «нормально», тільки неправильно.
Щоб безпечно працювати з bulk, нам потрібен простий, але строгий протокол, який майже завжди вкладається у таку формулу:
(1) Спочатку зафіксуйте накопичені managed-зміни → flush
(2) Виконайте bulk update/delete
(3) Приберіть stale-об’єкти з persistence context → clear або refresh
(4) Якщо дані потрібні далі — перечитайте їх заново
Нижче розберемо кожен крок, і головне — чому він потрібен.
2. flush() перед bulk
На словах flush() багато хто сприймає як «ну це типу commit, тільки менший». І звідси народжуються дві помилки: або його бояться як вогню, або ігнорують, сподіваючись на магію AUTO flush. Насправді flush() — це ваш спосіб сказати Hibernate: «синхронізуй базу з тим, що вже накопичилося в persistence context, просто зараз, щоб подальші SQL-операції працювали з актуальними даними».
Що може піти не так без flush()
Уявімо цілком життєвий сценарій із Commerce Persistence Lab. У межах однієї транзакції ви з якихось причин змінили статус кількох товарів у пам’яті (managed-зміни), а потім хочете зробити bulk repricing лише для ACTIVE товарів.
Якщо ви не зробили flush(), то в базі товари ще можуть числитися ACTIVE (оскільки ваші зміни поки що «в голові» Hibernate). І bulk-запит, який працює по базі, оновить ціну й тим товарам, які ви вже «майже сховали» в межах цієї unit of work. Виходить дуже дивна логіка: ви в сервісі сховали товар, але він усе одно потрапив під bulk-правило, бо база про це ще не знала.
Отже, flush() перед bulk — це не «оптимізація», а захист від логічної розсинхронізації між умовами bulk-запиту й тим, що ви вже зробили в контексті.
Мініприклад: managed-зміна + bulk без flush() (поганий запах)
import jakarta.persistence.EntityManager;
// 1) Завантажуємо сутність у persistence context (вона стає managed)
Product p = entityManager.find(Product.class, productId);
// 2) Змінюємо поле — поки що ця зміна живе в пам'яті, а не в базі
p.setStatus(ProductStatus.HIDDEN); // у пам'яті вже HIDDEN
// 3) Bulk-запит працює по базі, а база могла ще "не знати" про p.setStatus(...)
int updated = entityManager.createQuery("""
update Product p
set p.status = :hidden
where p.status = :active
""")
.setParameter("hidden", ProductStatus.HIDDEN) // нове значення для масового оновлення
.setParameter("active", ProductStatus.ACTIVE) // умова вибирає рядки за станом БД
.executeUpdate();
Тут bulk дивиться на базу, а база могла ще не бачити ваше p.setStatus(...). Hibernate може зробити auto-flush перед запитом, але покладатися на це як на «контракт», особливо в навчальних лабораторіях, — не найкраща звичка. У світі deep-dive ми любимо явність.
Мініприклад: явний flush() як «точка синхронізації»
import jakarta.persistence.EntityManager;
// Явно синхронізуємо БД з накопиченими змінами в persistence context
entityManager.flush(); // фіксуємо накопичені UPDATE/INSERT/DELETE в БД
// Тепер bulk-запит працюватиме за станом БД, який враховує зроблений flush
int updated = entityManager.createQuery("""
update Product p
set p.status = :hidden
where p.status = :active
""")
.setParameter("hidden", ProductStatus.HIDDEN) // що проставляємо
.setParameter("active", ProductStatus.ACTIVE) // кого вибираємо
.executeUpdate();
Корисна думка: flush() не завершує транзакцію. Ви все ще можете відкотити зміни (rollback), якщо далі щось піде не так. Але після flush() база вже «в курсі» ваших змін у межах поточної транзакції, і bulk-запити працюватимуть із чеснішим станом.
3. clear() після bulk
Після bulk-операції в нас з’являється нова проблема: база вже оновлена, а persistence context — ні. І якщо в ньому були завантажені сутності з тих таблиць, по яких пройшов bulk, ці сутності майже гарантовано стають ненадійними. Із цим можна сперечатися лише тоді, коли ви точно знаєте, що bulk не зачепив саме ті рядки, а найчастіше ви якраз і робите bulk, щоб зачепити багато рядків.
EntityManager.clear() — це грубий, але ефективний «скид оперативної пам’яті Hibernate». Він робить так, що всі managed-сутності стають detached, а persistence context стає порожнім. Наступне читання піде в базу, а не поверне вам старі об’єкти з identity map.
Чому clear() після bulk — це нормально
У bulk-операцій зазвичай технічна, «адміністраторська» природа: масова зміна цін, масове приховування товарів, очищення історичних снапшотів. Це не той сценарій, де ви хочете тримати великий managed-граф і далі продовжувати тонко мутувати його в тій самій транзакції. Bulk найчастіше виграє саме тому, що ви кажете: «мені не потрібні сутності в пам’яті, мені потрібно змінити рядки».
clear() у цьому сенсі завершує думку: раз ми обрали database-driven зміну, то й далі працюємо вже з базою як із джерелом істини.
Мініприклад: bulk update → clear() → повторне читання
import jakarta.persistence.EntityManager;
// 1) Виконуємо масову зміну безпосередньо в БД
int updated = entityManager.createQuery("""
update Product p
set p.status = :hidden
where p.status = :active
""")
.setParameter("hidden", ProductStatus.HIDDEN)
.setParameter("active", ProductStatus.ACTIVE)
.executeUpdate();
// 2) Після bulk persistence context може містити stale-об'єкти — очищаємо його
entityManager.clear(); // прибрали все stale з контексту
Після цього будь-який find(Product.class, id) справді піде в базу (зробить SELECT), а не поверне вам «старого знайомого» з first-level cache.
Важливий нюанс: clear() — це не безплатна цукерка.
clear() не просто «оновлює дані», він викидає managed-стан. Це означає, що якщо ви після clear() продовжите змінювати старі посилання на сутності, то Hibernate вже не буде автоматично зберігати їх через dirty checking. Це будуть detached-об’єкти. І ви або втратите зміни, або випадково прийдете до merge() (а ми вже знаємо, що merge() — штука корисна, але підступна).
Ось чому bulk-методи часто проєктують так, аби вони повертали «прості результати» (кількість оновлених рядків, timestamp операції, що завгодно), а не працювали далі з entity-об’єктами так, ніби нічого не сталося.
4. refresh() і повторне читання
Іноді clear() здається надто радикальним. І він справді радикальний: викидає все. Якщо у вас у методі вже є один конкретний об’єкт, який потрібен далі (наприклад, ви тримаєте посилання на Product, і вам важливо продовжувати саме з ним), можна розглянути EntityManager.refresh(entity).
refresh() каже Hibernate: «забудь поточний стан цієї managed-сутності й перечитай її з бази». Якщо після bulk-операції саме цей рядок змінився, refresh() підтягне нові дані, і об’єкт знову стане «правдивим».
Мініприклад: stale-об’єкт → refresh() → актуальний стан
import jakarta.persistence.EntityManager;
// 1) Завантажуємо сутність — вона стає managed і потрапляє у first-level cache
Product product = entityManager.find(Product.class, productId);
// 2) Bulk змінює рядок у БД, але не чіпає об'єкт product у пам'яті
entityManager.createQuery("""
update Product p
set p.status = :hidden
where p.id = :id
""")
.setParameter("hidden", ProductStatus.HIDDEN) // що проставляємо
.setParameter("id", productId) // конкретний рядок
.executeUpdate();
// 3) Примусово перечитуємо стан managed-сутності з БД
entityManager.refresh(product); // заново читаємо рядок із БД
Коли refresh() небезпечний (і чому)
refresh() перезаписує стан об’єкта тим, що лежить у базі. Якщо в об’єкта були незбережені зміни, refresh() їх знищить. Тобто це буквально «перезавантаження з бази з втратою чернеток».
Тому безпечна логіка така: якщо ви збираєтеся робити refresh(), ви маєте бути впевнені, що або не змінювали цю сутність у поточному unit of work, або вже зробили flush(), і ваші зміни потрапили в базу. Тоді refresh не зітре нічого важливого.
Повторне читання як альтернатива refresh()
Дуже часто замість refresh() простіше й зрозуміліше зробити clear(), а потім звичайний find() заново. Так, ви отримаєте новий об’єкт (старий стане detached), але натомість не ризикуєте випадково стерти зміни в якоїсь сутності, і код буде легше читати.
// Очищаємо persistence context, щоб гарантовано не бачити stale managed-об'єкти
entityManager.clear();
// Перечитуємо сутність заново: буде реальний SELECT у базу
Product fresh = entityManager.find(Product.class, productId);
І психологічно корисно сприймати це так: після bulk-операції ви починаєте «новий розділ» роботи з даними. Старі managed-об’єкти — це минулий сезон серіалу, і сценаристи вже змінили сюжет.
5. Bulk у Spring Data JPA
У наших прикладах ми діяли через EntityManager, і це правильно для deep-dive: менше магії, більше розуміння. Але в реальному застосунку Commerce Persistence Lab (і в житті) bulk-операції часто живуть у репозиторіях Spring Data JPA. І там є важлива річ: анотація @Modifying, у якої є прапорці flushAutomatically та clearAutomatically.
Суть дуже проста: Spring Data може допомогти вам автоматично обрамити bulk-операцію потрібними кроками. Але важливо пам’ятати, що це не скасовує розуміння того, що саме станеться з persistence context.
Приклад: bulk update у репозиторії з clearAutomatically
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
// Репозиторій командного типу: метод робить масову зміну і повертає кількість рядків
interface ProductRepository extends Repository<Product, Long> {
// flushAutomatically=true: перед bulk Spring спробує скинути накопичені managed-зміни в БД
// clearAutomatically=true: після bulk Spring очистить persistence context, щоб не жити зі stale-об'єктами
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("""
update Product p
set p.status = :hidden
where p.status = :active
""")
int bulkHideActive(ProductStatus active, ProductStatus hidden);
}
Що тут важливо відчути. flushAutomatically = true зменшує шанс, що bulk-запит виконається «по старій базі», якщо у вас є накопичені managed-зміни. clearAutomatically = true зменшує шанс, що ви продовжите жити зі stale-об’єктами, ніби нічого не сталося.
Але ця «автоматика» — не чарівна кнопка. Якщо ви викликаєте цей метод посеред складного сервісного сценарію, clearAutomatically може викинути з контексту не лише Product, а й інші сутності, які були завантажені раніше в тій самій unit of work. Потім ви раптом виявите, що зміни не зберігаються (бо об’єкт став detached), або що lazy-зв’язки стали недоступними, або що ваш код спирався на identity map, а її більше немає.
Тому практична рекомендація в межах курсу така: bulk-операції краще робити у короткому, окремому сервісному методі, який явно оформлює фази роботи й не змішує bulk із довгою бізнес-оркестрацією.
6. Сценарій bulk-приховування товарів
Тепер зберемо все в один «зрозумілий людині» сценарій. Нехай у нас є сервіс каталогу, який масово приховує активні товари. Важливо: ми не хочемо повертати звідси список Product (це одразу підштовхне нас до entity-loop). Ми хочемо виконати bulk update і повернути кількість оновлених рядків, а якщо потрібно далі працювати з конкретним товаром — перечитати його окремо.
Шаблон bulk-методу в сервісі (з явними фазами)
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public int hideActiveProducts(EntityManager entityManager) {
// 1) Фіксуємо всі накопичені зміни managed-сутностей у межах поточної транзакції
entityManager.flush();
// 2) Робимо bulk-операцію: змінюємо рядки в БД в обхід persistence context
int updated = entityManager.createQuery("""
update Product p
set p.status = :hidden
where p.status = :active
""")
.setParameter("hidden", ProductStatus.HIDDEN) // що ставимо
.setParameter("active", ProductStatus.ACTIVE) // кому ставимо
.executeUpdate();
// 3) Очищуємо persistence context: інакше в ньому можуть залишитися stale managed-об'єкти
entityManager.clear();
// 4) Повертаємо "простий" результат, не затягуючи нас знову в entity-loop
return updated;
}
Код короткий, але в ньому є найцінніше: намір. Майбутній читач бачить, що ми свідомо завершили накопичені managed-зміни (flush()), потім зробили bulk, потім свідомо викинули persistence context (clear()), щоб не жити на stale-даних. Це як пристебнути ремінь безпеки: аварії ви не плануєте, але потім дякуєте собі.
Як побачити stale на практиці (навіть без профілювальника)
Якщо хочеться «відчути» stale руками, можна зробити дуже простий фрагмент у тесті або в лабораторному runner’і: прочитати сутність, зробити bulk update, вивести поле, потім зробити refresh() і знову вивести.
import jakarta.persistence.EntityManager;
// 1) Завантажуємо сутність — її стан кешується в persistence context
Product p = entityManager.find(Product.class, productId);
// 2) Bulk-операція змінює рядок у БД, але об'єкт p у пам'яті не оновлює
entityManager.createQuery("""
update Product p
set p.status = :hidden
where p.id = :id
""")
.setParameter("hidden", ProductStatus.HIDDEN) // нове значення в БД
.setParameter("id", productId) // який рядок змінюємо
.executeUpdate();
// 3) Перевіряємо: об'єкт у пам'яті все ще може бути stale
System.out.println(p.getStatus()); // усе ще старе значення (stale)
// 4) Примусово оновлюємо об'єкт із БД і перевіряємо ще раз
entityManager.refresh(p);
System.out.println(p.getStatus()); // вже нове значення з БД
Так, println у 2026 році виглядає як «дідівський метод налагодження». Але він чудовий для навчання: він показує, що Java-об’єкт і база даних — це не одне й те саме, і bulk-операція не намагається їх автоматично примирити.
7. Типові помилки під час bulk-flow
Помилка №1: виконати bulk, а потім «ніби нічого не сталося» продовжити працювати зі старими managed-об’єктами.
Це найчастіша пастка. У голові звучить: «Я ж оновив базу, значить об’єкт теж оновився». Але bulk-операція обходить persistence context, і об’єкт у пам’яті не змінюється. Якщо після bulk ви читаєте поля у раніше завантажених сутностей, ви з високою ймовірністю читаєте stale. Лікується дисципліною: або clear() і повторне читання, або точковий refresh().
Помилка №2: зробити clear() до flush() і «втратити» накопичені зміни.
Якщо у вас у поточній unit of work були зміни managed-сутностей, clear() перетворить їх на detached, і Hibernate перестане за ними стежити. У підсумку ви можете втратити зміни без помилок: просто тому, що dirty checking більше не спрацює. Правильний порядок зазвичай такий: спочатку flush() (якщо є що скидати), потім bulk, потім clear().
Помилка №3: використовувати refresh() як універсальну «таблетку від stale», не розуміючи, що він стирає локальні зміни.
refresh() може бути зручним, але він безжальний: він перезаписує стан entity тим, що лежить у базі. Якщо в сутності були зміни, які ви ще не встигли зберегти, вони зникнуть. Якщо вам потрібно оновити стан після bulk, а заодно ви не впевнені, що в об’єкта не було локальних змін, безпечніше очистити контекст (clear()) і перечитати об’єкт заново.
Помилка №4: сподіватися, що «AUTO flush завжди сам усе зробить», і не писати flush() явно.
Так, Hibernate часто робить flush перед запитами, щоб зберегти коректність. Але коли ви читаєте bulk-код через пів року, вам не хочеться гадати: «а flush тут буде? а якщо режим COMMIT? а якщо хтось змінить налаштування?» Явний flush() перед bulk — це не лише runtime-поведінка, а й документація наміру прямо в коді.
Помилка №5: використовувати @Modifying(clearAutomatically = true) посеред великого сервісного методу й дивуватися, що далі все стало «як detached».
clearAutomatically очищає persistence context цілком. Це означає, що всі сутності, які ви вже завантажили в цій транзакції, стануть detached. Якщо далі ви продовжуєте змінювати їх поля, Hibernate ці зміни вже не впіймає. Тому bulk-операції краще виносити в короткі методи, де bulk — центральна дія, а не маленька вставка в довгий сценарій.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ