1. Проблема: транзакція «про всяк випадок»
Якщо ви колись бачили метод на 80 рядків із @Transactional, який «нібито робить корисне», а потім ще трохи — формує звіт, сортує, форматує, логує, перевіряє якісь умови, то ви вже тримали в руках зародок антипатерна. Він особливо підступний: код працює, тести зелені, прод не горить. Але SQL-поведінка стає непередбачуваною, а ціна одного use case зростає через дрібниці.
Після витоку сутностей це ще одна типова втрата контролю. Навіть якщо сама сутність виглядає невинно, довга транзакція й серія дрібних repository-викликів швидко перетворюють use case на джерело випадкового SQL. Гігантські графи, неочікувані flushʼі та зайві selectʼи дуже люблять саме такий ґрунт.
Давайте домовимося про один неприємний факт: Hibernate — це не «просто бібліотека для збереження». Це runtime-система зі своїми фазами (managed state, dirty checking, flush), і вона чесно підкоряється вашим межам. Тому коли ви розтягуєте транзакцію або викликаєте репозиторій по одній сутності за раз, ви фактично проєктуєте SQL-навантаження — просто робите це випадково, без дизайну.
Щоб було простіше тримати це в голові, намалюємо міні-картинку «як зазвичай ламають життя»:
flowchart TD A["Сервіс @Transactional"] --> B[findById у циклі] B --> C[зміна] C --> D[форматування/звіт/логування] D --> E["ще один select «для перевірки»"] E --> F[коміт]
З погляду бізнес-логіки це виглядає як «один метод виконує роботу». З погляду Hibernate це виглядає як «довгий unit of work + купа дрібних round trips + більше шансів на неочікуваний flush і роздування persistence context».
2. Транзакція та межі unit of work
Транзакцію в Spring/JPA дуже зручно сприймати як «дужки навколо коду». Але в Hibernate-реальності транзакція майже завжди означає ще й межу життя persistence context. Це важливіше, ніж здається: всередині транзакції обʼєкти стають managed, Hibernate накопичує snapshots, відстежує зміни, а наприкінці робить flush/commit. Чим ширша межа, тим більше обʼєктів ви тримаєте в памʼяті й тим більше місць, де може виникнути неочікувана SQL-активність.
Практичне визначення, яке добре працює на code review: транзакція має покривати рівно той шматок роботи, який зобовʼязаний бути атомарним. Тобто те, що справді має або повністю застосуватися, або повністю відкотитися. Усе, що не зобовʼязане бути атомарним (форматування тексту, підготовка звіту, підрахунок метрик для логів, конвертація в DTO «для відповіді»), дуже часто краще винести за межі транзакції, бо воно не виграє від persistence context, а ризики збільшує.
Щоб це не звучало як «релігія», порівняймо дві моделі на одній схемі:
flowchart TD
subgraph Bad["Надто широка транзакція (погано)"]
A1["@Transactional"] --> A2["Завантаження + зміна"] --> A3["Форматування/звіт"] --> A4["Зовнішній виклик / додаткове читання"] --> A5["Коміт"]
end
flowchart TD
subgraph Good["Менший unit of work (здоровіше)"]
B1["Оркестрація без транзакції"] --> B2["@Transactional: завантаження + зміна"] --> B3["Коміт"]
B3 --> B4["Форматування/звіт поза tx"]
end
Ми не говоримо: «У транзакції не можна робити нічого, окрім save». Ми говоримо інше: транзакція — це дорога конструкція. Якщо ви використовуєте її як «контейнер для всього підряд», то платите за це в найнесподіваніших місцях: памʼяттю, блокуваннями, зайвими flush-тригерами та, що особливо неприємно, втратою передбачуваності.
3. Overscoped transaction на практиці
Слово overscoped звучить як діагноз із лікарні архітектури: «у вас транзакція розширена, прийміть два рефакторинги до обіду». На практиці все простіше: транзакція стає overscoped, коли всередину потрапляє робота, яка не належить до атомарної зміни даних. Дуже часто це трапляється з найкращих спонукань: «нехай усе буде в одному місці», «так простіше», «так точно не буде LazyInitializationException». І так, інколи це навіть правда… але ціна зазвичай неприємніша, ніж здається.
Давайте перелічимо, не вдаючись до чеклістів, типових «зайвих мешканців» транзакції. Часто туди потрапляє формування звіту через StringBuilder, бо «це ж швидко». Потім — сортування та групування результатів, бо «дані вже є». Далі раптом з’являється логування entity або обчислення order.getItems().size(), і це перетворюється на пачку додаткових SELECT, особливо якщо поруч блукає витік сутностей. Ще дорожчий варіант — зовнішні виклики: HTTP, надсилання листів, запис файлу, звернення до інших сервісів. Навіть якщо ви зараз не вивчаєте distributed systems, дискомфорт має бути очевидним: транзакція бази даних і зовнішній виклик живуть у різних світах, а rollback не вміє «відкочувати» надісланий лист.
У Hibernate-контексті overscoped транзакція майже завжди означає три технічні ефекти. По-перше, persistence context роздувається, тому що ви завантажили більше сутностей і довше тримаєте їх managed. По-друге, dirty checking стає дорожчим, тому що Hibernate має порівнювати більше snapshots під час flush. По-третє, ви створюєте більше точок, де flush може статися раніше, ніж очікувалося. Наприклад, ви змінили дані, а потім виконали JPQL-запит — Hibernate зобовʼязаний синхронізувати контекст, щоб запит бачив коректну картину.
Ось маленька табличка, яка допомагає пояснювати новачкам, що саме не так, не скочуючись у гасла:
| Що ви робите всередині транзакції | Чому це спокушає | Чим це може відгукнутися |
|---|---|---|
| Форматування звіту/рядка/CSV | «Це ж не SQL, це просто рядки» | Зайвий час життя persistence context, випадкові lazy-завантаження, зайві flush-тригери |
| findById()/save() у циклі | «Так зрозуміліше: беру по одному й обробляю» | Багато round trips, величезна кількість запитів, роздування контексту, flush у неочікуваних місцях |
| Читання і запис упереміш | «Треба ж перевірити умову перед зміною» | AUTO flush перед query, складніше передбачити SQL-порядок і момент відправлення |
| Зовнішні виклики (HTTP/пошта) | «Хочу все зробити одним кроком» | Неконсистентність: БД відкотилася, а зовнішня дія вже відбулася |
Важливо: тут немає заборони «ніколи так не робіть». Це список місць, де потрібно підняти внутрішню тривогу й поставити собі питання: «А чи справді це має бути атомарним разом зі зміною даних?».
4. Кейс: закриваємо замовлення і пишемо звіт
Зараз буде приклад, який виглядає майже невинно, особливо якщо ви лише почали бекенд-шлях. Ми хочемо закрити кілька замовлень і повернути користувачеві текстовий звіт, хоч би для адмінки або лабораторного сценарію. Наївний код здається логічним: відкрили транзакцію, пройшлися по id, змінили статус, зібрали рядки звіту, повернули результат. Насправді ми змішали атомарну частину — зміну статусу — і неатомарну — підготовку тексту.
Наївна версія:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class OrderClosingService {
@Transactional
public String closeOrders(java.util.List<Long> ids) {
// Транзакція вже відкрита, хоча нижче немає ні мутацій БД, ні причин тримати persistence context (у прикладі).
var report = new StringBuilder();
// У реальному коді це зазвичай обростає findById/setStatus/доступом до lazy-звʼязків — і починає генерувати SQL.
for (Long id : ids) report.append(id).append('\n'); // "звіт"
// Повертаємо presentation-результат із транзакції: саме по собі це не помилка, але часто ознака overscope.
return report.toString();
}
}
Проблема зазвичай починається саме так: транзакція вже відкрита, а всередині пішла робота, яка не потребує ні managed-сутностей, ні атомарності. Далі цей метод майже завжди обростає findById(), order.setStatus(...), випадковим читанням order.getItems().size() і перетворюється на повноцінний SQL-генератор.
Трохи більш реалістичний — і вже небезпечний — варіант:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class OrderClosingService {
private final OrderRepository orderRepository;
OrderClosingService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public String closeOrders(java.util.List<Long> ids) {
// Усередині однієї транзакції змішані читання/зміна та збирання звіту (presentation-частина).
var report = new StringBuilder();
for (Long id : ids) {
// Потенційно: один SELECT на кожну ітерацію.
var order = orderRepository.findById(id).orElseThrow();
// Мутація managed-сутності: Hibernate запамʼятає зміни і застосує їх під час flush/commit.
order.setStatus(OrderStatus.CLOSED);
// Будь-яке звернення до полів або звʼязків може випадково викликати додаткові SELECT (особливо при lazy).
report.append(order.getOrderNumber()).append('\n');
}
// Чим «товстіший» звіт, тим довше живе транзакція і persistence context.
return report.toString();
}
}
Проблема не в StringBuilder як у класі. Бідний StringBuilder, він узагалі-то нормальний хлопець. Проблема в тому, що транзакція стала «мішком для всього», а отже:
Транзакція живе довше, ніж потрібно для зміни статусів. Ви тримаєте persistence context і потенційні блокування, поки формуєте рядок. І якщо завтра звіт стане трохи складнішим — із сортуванням, фільтрами, обчисленням сум — ви збільшите час транзакції просто «по дорозі».
Сервіс почав виконувати дві різні відповідальності: атомарну зміну даних і підготовку подання. Це не лише про архітектурну красу. Це про те, що якщо звіт упаде через NullPointerException під час форматування, ви відкочуєте транзакцію, що, можливо, і правильно, але місце падіння буде далеко від суті операції. У налагодженні це перетворюється на: «Чому не закрилося замовлення? — Бо звіт не зібрався». І мозок тихо плаче.
Що з цим робити, не заходячи у «великий рефакторинг усього проєкту»? Перший крок тут банальний: залишити в транзакції лише закриття замовлень, а назовні повернути мінімальні дані для звіту — наприклад, номери замовлень. Тоді String.join(), сортування та інша presentation-логіка живуть уже після commit і не тримають persistence context відкритим.
Для цього місця важливий саме розріз відповідальності, а не кількість сервісів чи краса діаграми. Mutation має відбуватися всередині unit of work. Форматування і збирання відповіді — зовні, на простих даних, які вже не можуть випадково смикнути lazy-звʼязки. Щойно межа стає такою, одразу простіше помітити й другу половину проблеми: навіть коротка транзакція лишається дорогою, якщо сервіс продовжує ходити в БД по одному findById().
5. Chatty repository: сервіс-кол-центр для БД
І ось тут майже завжди спливає наступний біль: транзакція вже може бути коротшою, але сервіс усе ще занадто дрібно розмовляє з БД. Термін chatty repository звучить кумедно, але проблема дуже реальна: один use case збирається з безлічі дрібних звернень до репозиторію, часто в циклі, інколи у вкладених циклах. На рівні code-style це виглядає як «усе читабельно: ось я беру замовлення, потім товар, потім залишок». На рівні SQL це виглядає як «вітаємо, ви побудували міні-DDoS на власну базу, причому в одному потоці».
Важливо розрізняти два схожі сценарії. Якщо у вас один сервіс викликає два-три репозиторії по одному разу кожен, це ще не балакучість — інколи це нормальна координація агрегатів у unit of work. Балакучість починається там, де репозиторій кличуть багато разів за одним і тим самим шаблоном: findById() на кожен id, saveAndFlush() на кожну сутність, existsBy... на кожну перевірку. Hibernate при цьому не «вгадує», що ви хотіли зробити масово — він чесно виконує те, що ви написали.
Найтиповіший приклад — читання списку сутностей за списком id:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class ProductLoadingService {
private final ProductRepository productRepository;
ProductLoadingService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional(readOnly = true)
public java.util.List<Product> loadByIds(java.util.List<Long> ids) {
// Балакучий шаблон: один findById -> один SELECT. У циклі це перетворюється на N запитів.
var result = new java.util.ArrayList<Product>();
for (Long id : ids) {
// Потенційно N round trips замість одного bulk-читання.
result.add(productRepository.findById(id).orElseThrow());
}
return result;
}
}
Формально усе добре: транзакція read-only, код зрозумілий, помилок немає. Але по SQL це майже напевно буде «один select на кожен id». Якщо ids = 100, ви щойно попросили 100 запитів замість одного. Це і є chatty repository: сервіс не виражає intent «дай мені набір», він виражає intent «дай мені по одному, а я сам зберу колекцію».
Перший і найпростіший крок покращення, не змінюючи архітектуру читання радикально, — використати bulk-виклик findAllById():
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class ProductLoadingService {
private final ProductRepository productRepository;
ProductLoadingService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional(readOnly = true)
public java.util.List<Product> loadByIds(java.util.List<Long> ids) {
// Bulk intent: «дай мені набір за списком id» (зазвичай перетворюється на WHERE id IN (...)).
return productRepository.findAllById(ids);
}
}
Чому це краще, навіть якщо ви поки що все ще повертаєте entity? Тому що ви хоча б перестали «балакати» з БД по одному. У більшості реалізацій Spring Data JPA це перетворюється на один запит із where id in (...) (плюс нюанси з порожнім списком тощо). Hibernate починає працювати з нормальним набором даних, а не з тисячею маленьких «дай-дай-дай». Це ще не design read-model, але це вже чесний bulk intent: «дай мені набір», а не «дай по одному, а я потім зроблю вигляд, що це один сценарій».
Є й друга популярна форма chatty repository: коли write-сценарій збирається з save()/saveAndFlush() на кожній ітерації. Тут уже прямий конфлікт із тим, що ми вивчали раніше: managed-сутність і так буде збережена через dirty checking, а flush — це окрема фаза, і робити її на кожній ітерації зазвичай означає «примусово відправляти SQL просто зараз», ламаючи batching-можливості й просто роблячи дорого.
Наприклад, карикатурно, але життєво:
for (Long id : ids) {
// Один SELECT на ітерацію + ще й примусовий запис на кожній ітерації.
PurchaseOrder order = orderRepository.findById(id).orElseThrow();
// Мутація managed-сутності: сама по собі ок.
order.setStatus(OrderStatus.CLOSED);
// Майже завжди підозріло: змушує flush зараз, ламає batching і роздуває вартість сценарію.
orderRepository.saveAndFlush(order);
}
Навіть якщо ви забудете все інше, запамʼятайте одну побутову метафору: saveAndFlush() у циклі — це як після кожного нарізаного огірка мити всю кухню і заново розкладати каструлі. Технічно чисто, але чому ви так себе ненавидите?
6. Комбо: довга транзакція + цикл findById()
Є рідкісні випадки, коли overscoped transaction і chatty repositories існують окремо. Але частіше вони трапляються як друзі-товариші: транзакція розтягнута, бо всередині неї відбувається багато дрібних repository-викликів і зайвої постобробки. І тоді SQL-поведінка починає визначатися не use case, а структурою циклів.
Уявіть сценарій «закрити список замовлень і порахувати статистику». Наївний розробник робить так: в одній транзакції завантажує замовлення по одному, змінює статус, потім для кожного замовлення лізе за items (або за inventory), потім будує текстовий звіт. У підсумку в одному методі змішалися «змінити стан» і «порахувати/показати», і ви самі не помітили, як ваша транзакція перетворилася на великий мішок із купою SQL.
У цей момент особливо корисно памʼятати дві ідеї, які ми вже проходили раніше, але сьогодні вони «склеюються» в одне ціле. Перша ідея: що більше ви робите всередині транзакції, то більше шансів, що Hibernate буде змушений flushʼитися перед черговим запитом, щоб зберегти коректність читання. Друга ідея: кожен findById() у циклі — це потенційний окремий SELECT. Складіть їх, і отримаєте сценарій, де навіть «невелика добавка у звіт» (наприклад, вивести customer.email) несподівано перетворюється на десятки додаткових запитів.
Щоб не перетворювати лекцію на «просто страшилки», ось проста таблиця запитань, яку зручно ставити коду, коли ви бачите підозрілий сервісний метод:
| Спостереження в коді | Про що це може говорити | Перше запитання до автора |
|---|---|---|
| @Transactional + метод повертає String/StringBuilder | Транзакція містить presentation-логіку | «Чи зобовʼязаний звіт бути атомарним разом зі зміною даних?» |
| findById() всередині for | Chatty repository / багато round trips | «Чому не можна отримати все одним запитом?» |
| saveAndFlush() всередині циклу | Примусові flushʼі / дорогі записи | «Навіщо вам flush на кожній ітерації?» |
| Читання-запис-читання в одному методі | AUTO flush перед query, непередбачуваність | «Чи можна розділити mutation і read-частину?» |
| У транзакції є сортування/форматування/логування графа | Overscoped transaction + ризик випадкового lazy-завантаження | «Чи справді це має виконуватися з відкритим persistence context?» |
Зверніть увагу: це не «чекліст заборон». Це спосіб швидко знайти місця, де межі unit of work розмилися, і повернути код у стан, де SQL можна пояснити людині, а не викликати духів Hibernate.
7. Типові помилки під час роботи з транзакціями та репозиторіями
Помилка №1: тримати транзакцію відкритою заради форматування і «красивої відповіді».
Часто це виглядає як невинний StringBuilder, потім як map -> collect, потім як «давайте ще відсортуємо». У підсумку атомарна частина — зміна стану — займає 5 рядків, а все інше — 50 рядків обвʼязки, яка випадково починає чіпати lazy-звʼязки та генерувати SQL. Хороша звичка — спочатку відділяти «що змінюємо» від «як показуємо», а транзакцію залишати там, де змінюємо.
Помилка №2: збирати набір даних через findById() у циклі, тому що “так простіше читати”.
Це один із найшкідливіших прикладів «простоти для розробника проти ціни для системи». Ціна виражається не в абстракції, а в конкретних запитах. Якщо вам потрібні 100 сутностей, то 100 findById() майже завжди програють одному bulk-читанню. Навіть якщо ви поки не готові до повноцінного розщеплення read model, уже одне findAllById() зазвичай різко покращує ситуацію.
Помилка №3: лікувати архітектурні проблеми збільшенням транзакції.
Іноді розробник стикається з lazy-поведінкою, недовантаженням даних або просто «дивним місцем, де падає», і робить висновок: «треба просто розширити транзакцію». У підсумку транзакція починає захоплювати зайві дії, а проблема не розвʼязується, а ховається. Такий “фікс” зазвичай живе до першого зростання навантаження, після чого виявляється, що транзакція тримає ресурси занадто довго й створює затор.
Помилка №4: перетворювати репозиторій на “API по одному обʼєкту”, а сервіс — на ручний ORM.
Сервісний шар інколи починає виглядати як «я сам оркеструю базу»: дістань це, потім це, потім онови те, потім перевір це. На рівні Java це схоже на акуратну покрокову логіку. На рівні SQL це схоже на «багато дрібних запитів, які могли бути одним-двома». Репозиторій і query-шар існують не лише для того, щоб сховати EntityManager, а й для того, щоб виражати намір читання більшими мазками.
Помилка №5: механічно викликати save()/saveAndFlush() «для надійності».
Це особливо часта звичка після поверхневої роботи зі Spring Data. У JPA-моделі, якщо сутність managed, зміни й так будуть збережені dirty checkingʼом. А saveAndFlush() додає вам примусовий flush, який може різко змінити момент відправлення SQL і вартість сценарію. «Надійність» тут найчастіше ілюзорна, а ціна — цілком реальна.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ