1. Успіх placeOrder(): commit важливіший за save(...)
Майже в кожного новачка в якийсь момент зʼявляється відчуття: «Я ж зробив save(...) — отже, усе збереглося». Це дуже людська думка: наш мозок любить ставити позначки. Але в транзакційному коді вона небезпечна. У placeOrder() успіх операції визначається не першим вдалим рядком, не останнім save(...) і навіть не тим, що в обʼєкта вже зʼявився id, а тим, що транзакція дійшла до кінця і успішно закомітилася.
У Spring із @Transactional на публічному методі сервісного шару базова модель така: на початку методу Spring відкриває транзакцію, наприкінці — якщо метод завершився без помилки — робить commit, а якщо виник виняток (у найтиповішому випадку — runtime) — робить rollback. Репозиторії всередині методу можуть викликатися хоч десять разів, але це все одно одна операція, один «чек у касі», а не десять різних покупок.
Уявіть собі просту життєву аналогію: ви збираєте валізу до відпустки. Поклали шкарпетки — це ще не «відпустка відбулася». Поклали зарядку — теж ні. Відпустка «відбулася» лише в момент, коли ви вийшли з квартири й зачинили двері. Транзакція працює так само: проміжні дії важливі, але підсумок «операція успішна» настає в момент завершення транзакції.
Міні-ілюстрація (зверніть увагу на коментар — він спеціально «ламає» інтуїцію):
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
@Transactional
public Long placeOrder(/* ... */) {
// ВАЖЛИВО: транзакція стартує під час входу до методу,
// а commit/rollback відбудеться лише під час виходу з методу.
// Усе, що всередині, — це «чернетка» операції.
// ... створюємо замовлення та позиції
// Навіть якщо orderRepository.save(...) було викликано,
// це ще не означає, що результат "назавжди" у базі.
// «Назавжди» настане лише після commit наприкінці методу.
return 42L;
}
}
Повернене значення, наприклад orderId, — це зручний результат для коду, що викликає метод, але це ще не «памʼятник у граніті». Памʼятник ставиться на commit.
2. Частковий успіх і «напівзамовлення»
Якщо транзакції — це «все або нічого», то частковий успіх — це стан «щось устигло, а щось ні». На рівні бізнесу це майже завжди гірше, ніж чесна помилка, бо такий «напіврезультат» починає жити в базі так, ніби він справжній, а потім отруює все довкола. І найнеприємніше: він не завжди ламає застосунок відразу. Інколи він тихо лежить у даних і спливає через тиждень у вигляді дивного звіту або дзвінка від колеги: «Чому в нас мінус три товари на складі, але замовлення немає?»
У нашому мінішопі частковий успіх зазвичай виглядає так: залишки вже зменшили, а замовлення не зберегли; замовлення зберегли, але позиції не збереглися; замовлення і позиції збереглися, але підсумкова сума не відповідає рядкам; замовлення зберегли, але залишки не зменшили, і ми «продали повітря». Це чотири різні форми «напівперемоги», і кожна з них — фактично баг у даних.
Щоб побачити це не як страшилку, а як інженерну модель, зручно виписати, які дані мають змінюватися разом. Нижче — компактна таблиця. Вона не про те, як писати код, а про те, що означає «коректний результат операції».
| Сутність/таблиця | Що змінюємо в placeOrder() | Чому має бути атомарно |
|---|---|---|
| customer_order | створюємо замовлення, ставимо статус, totalAmount | замовлення — корінь операції |
| order_item | створюємо позиції замовлення | замовлення без позицій — порожня оболонка |
| stock_item | зменшуємо availableQuantity | інакше продаємо більше, ніж є |
| (логіка) | перевіряємо залишки до списання | інакше отримуємо відʼємні залишки або помилки |
Транзакція існує саме для того, щоб ці зміни або сталися разом, або не сталася жодна з них.
Важливо відчути один тонкий момент: окремий save(...) — це лише «крок сценарію», а не «гарантія результату». Усередині placeOrder() ви можете зробити два, три, пʼять збережень — і все одно лишатися в стані, де операція ще не завершилася. Саме тому в транзакційному мисленні слово «успіх» стосується операції цілком, а не її частин.
3. Rollback на практиці: ламаємо placeOrder()
Найкращий спосіб перестати вірити в магію — один раз її розібрати й подивитися, що під капотом. Тому зараз зробимо навмисно «поганий» сценарій: виконаємо кілька дій, а потім кинемо виняток. І подивимося, що буде з базою: чи має вона залишитися в проміжному стані, чи повинна відкочуватися до початкового.
Зімітуємо ситуацію, коли ми встигли зберегти замовлення і змінити залишок, але потім раптом зʼясувалося щось критичне. Причина не важлива: це може бути і «помилка в даних», і «неочікувана умова», і «зламаний інваріант». Важливо лише одне: помилка сталася посередині.
import java.math.BigDecimal;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final CustomerOrderRepository orderRepository;
private final StockItemRepository stockItemRepository;
public OrderService(CustomerOrderRepository orderRepository,
StockItemRepository stockItemRepository) {
this.orderRepository = orderRepository;
this.stockItemRepository = stockItemRepository;
}
@Transactional
public Long placeOrderAndFail(CustomerOrder order, StockItem stockItem) {
// Підготували дані: ці зміни поки що живуть усередині транзакції
// і ще не є остаточною правдою бази.
order.setTotalAmount(BigDecimal.TEN);
// Виклик save(...) не дорівнює commit:
// ми лише реєструємо зміни, які буде зафіксовано пізніше.
orderRepository.save(order);
// Ще одна зміна в тій самій транзакції.
stockItemRepository.save(stockItem);
// Тут спеціально ламаємо сценарій.
// Runtime-виняток призведе до rollback усієї транзакції.
throw new IllegalStateException("Збій посередині placeOrder()");
}
}
Тут спеціально зроблено те, що зазвичай збиває з пантелику: ми встигли викликати save(...), але потім усе одно падаємо. Якщо транзакції немає, то з великою ймовірністю ви отримаєте саме той самий «напіврезультат»: частина даних встигла зберегтися, частина — ні. А от якщо транзакція є і налаштована правильно, то відбудеться rollback, і база повернеться в стан «як було до входу в метод».
Щоб у голові не залишалося відчуття «ну це в теорії», корисно уявити, що в SQL-логах під час спроби операції ви могли б побачити логічні кроки на кшталт:
-- приблизно так виглядає потік дій (спрощено)
-- ВАЖЛИВО: те, що SQL-команди «пробігли» в логах, ще не означає,
-- що вони стали остаточним станом даних: підсумок настає лише після COMMIT.
insert into customer_order (...) values (...);
update stock_item set available_quantity = ... where id = ...;
-- ... і тут сталася помилка -> rollback
Важливо: SQL-логи можуть показувати, що якісь команди вже «пройшли». Це не суперечить rollback. Rollback означає, що ці зміни не стають остаточною версією даних. У перекладі на людську мову: ви могли встигнути написати текст у документі, але якщо ви закрили його, не зберігши, то фінальної версії документа не буде.
Ще одна пастка для новачка: інколи в сутності встигає зʼявитися id, і це сприймається як «ну все, в базі точно є рядок». Насправді id — це лише ідентифікатор, який система змогла видати в процесі операції. Якщо транзакцію відкочено, рядок із таким id може так і не існувати.
Невелика демонстрація саме цієї плутанини:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public Long createOrderThenFail(CustomerOrder order) {
// Зберігаємо сутність: JPA може присвоїти id ще до commit
// (наприклад, під час flush або за стратегії генерації ідентифікаторів).
CustomerOrder saved = orderRepository.save(order);
// Цей вивід НЕ доводить, що рядок «назавжди» у базі.
System.out.println("id замовлення = " + saved.getId()); // id замовлення = 100
// Після винятку транзакцію буде відкочено, і запису в таблиці може не бути.
throw new IllegalStateException("усе скасовуємо");
}
Вивід у консолі може бути, id може бути, а запису в таблиці — ні, тому що «памʼятник у граніті» ставиться на commit, а не на println.
І ось чому транзакційний підхід такий важливий для placeOrder(): він гарантує, що після збою у вас не залишиться «половини замовлення», «мінуснутого складу без замовлення» та інших веселих артефактів, які потім лагодять руками, сльозами й SQL-скриптами під назвою fix_prod_final_final_v3.sql.
4. cancelOrder() vs rollback: різні операції
На цьому місці часто відбувається логічна підміна, особливо для тих, хто вперше серйозно стикається з транзакціями. Здається, що «скасування замовлення» і rollback — це одне й те саме, тільки різними словами. Насправді це дві абсолютно різні ідеї. Rollback — це технічний відкат поточної незавершеної транзакції через помилку. cancelOrder — це свідома дія бізнесу, яка застосовується до вже існуючого замовлення, що раніше було успішно оформлене.
Якщо ви оформлюєте замовлення, і всередині placeOrder() щось пішло не так — ми хочемо rollback, щоб зробити вигляд, що операції не було. Це схоже на ситуацію: «я почав оформлювати покупку, але на екрані оплати все впало — вважаємо, що покупку не здійснено».
А ось cancelOrder() — це ситуація «покупка була здійснена, чек надруковано, а потім клієнт передумав». Тут уже не можна зробити вигляд, що нічого не було. У вас є запис про замовлення, можливо, воно вже потрапило у звіти, можливо, ви показали його користувачу. Тому скасування — це не зникнення факту, а зміна стану: замовлення отримує статус CANCELLED, а склад повертає на склад списану кількість.
Дуже зручно побачити це як дві різні гілки часу:
sequenceDiagram
participant U as Користувач
participant S as OrderService
participant DB as База даних
U->>S: "placeOrder()"
S->>DB: "створити customer_order + order_item + оновити залишки"
alt усе гаразд
S->>DB: COMMIT
S-->>U: orderId
else помилка
S->>DB: ROLLBACK
S-->>U: "помилка (замовлення не створено)"
end
U->>S: "cancelOrder(orderId)"
S->>DB: "оновити статус замовлення + повернути залишки"
S->>DB: COMMIT
На діаграмі видно головне: cancelOrder() взагалі не бере участі в історії «помилка всередині placeOrder()». Він починається пізніше, коли замовлення вже існує, і в нього своя транзакція, свої перевірки, свої правила.
Важлива практична думка для нашого мінішопу: cancelOrder() має спиратися не на зовнішні «вхідні дані», а на те, що реально було збережено в OrderItem. Бо скасування — це зворотна операція до оформлення, і вона має бути «симетричною» щодо даних, які ми колись зафіксували.
5. Нарис cancelOrder(): що змінюємо
Щоб не плутати ці дві операції, достатньо короткої картини: cancelOrder() — це вже не спроба «відкотити placeOrder() заднім числом», а нова бізнес-операція над замовленням, яке раніше було успішно оформлене. Вона працює не з вхідним списком товарів, а з тим, що реально збережено в CustomerOrder і OrderItem.
На практиці це означає ось що:
— завантажити вже існуюче замовлення;
— переконатися, що його взагалі можна скасовувати;
— повернути на склад кількість товарів із збережених OrderItem;
— перевести замовлення в CANCELLED.
Для розрізнення з rollback нам важлива не краса цього коду, а сам склад змін. Це окрема сервісна транзакція зі своїм commit або rollback, а не спосіб скасувати попередню транзакцію оформлення.
| Параметр | rollback | cancelOrder() |
|---|---|---|
| Коли відбувається | під час помилки всередині поточної операції | після успішного оформлення, за рішенням бізнесу |
| Що «бачить» база | результат операції зникає повністю | результат лишається, але змінюється статус/залишки |
| Це нова транзакція? | ні, це відкат поточної | так, це окрема операція |
| Що змінюємо | нічого не має залишитися | статус замовлення + залишки (і інколи інші поля) |
| «Зміст» | «не вийшло — забули» | «вийшло, але скасували» |
6. Типові помилки під час роботи з транзакціями
Помилка № 1: думати, що успіх операції настає на першому save(...).
Такий код зазвичай виглядає логічно: ми зберегли замовлення — отже, уже можна повідомляти назовні: «усе готово». Але якщо всередині транзакції після цього станеться збій, буде rollback, і замовлення в базі не зʼявиться. Тому будь-які дії, які зовні фіксують факт, мають спиратися не на ранній save(...), а на те, що метод справді завершився успішно.
Помилка № 2: не помічати, що частковий успіх — це пошкодження даних, а не «ну, майже вийшло».
Частковий успіх небезпечний тим, що він створює стани, з якими система взагалі не вміє жити. «Залишки зменшено, замовлення немає» — це не «майже оформлення», це вже інцидент. Якщо ви ловили себе на думці «та нічого, потім виправимо», знайте: так народжуються легенди про нічні релізи та тихі прокльони на адресу бази даних.
Помилка № 3: плутати rollback із cancelOrder() і намагатися «скасувати оформлене замовлення» відкатом транзакції.
Rollback уміє відкотити тільки те, що відбулося всередині поточної транзакції й ще не стало фінальним. Якщо замовлення вже оформлене, відкотити заднім числом минулу транзакцію неможливо. Скасування — це нова операція: вона має завантажити замовлення, змінити статус і повернути залишки. Це інша логіка й інша відповідальність сервісу.
Помилка № 4: робити cancelOrder() лише зміною статусу.
Зміна статусу без повернення залишків виглядає як «ми скасували замовлення на папері, але товар зі складу все одно зник». У нашому домені це пряме порушення інваріанта: скасування має бути дзеркальним до оформлення. Якщо в placeOrder() ми зменшили залишки, то в cancelOrder() ми зобовʼязані повернути їх назад.
Помилка № 5: повертати залишки «за вхідними даними», а не за збереженими OrderItem.
Інколи хочеться зробити cancelOrder(email, address, items) і передати туди ті самі дані, що були під час оформлення. Це виглядає зручно, але відкриває нові можливості для розбіжностей: «а це точно ті самі позиції?», «а чи не підмінив хтось вхідні дані?». Скасування має спиратися на те, що реально зберігається в замовленні: на OrderItem та їх quantity. Тоді скасування стає передбачуваним і таким, що можна перевірити.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ