JavaRush /Курси /Spring Data JPA /Успіх і збій: commit та rollback

Успіх і збій: commit та rollback

Spring Data JPA
Рівень 18 , Лекція 2
Відкрита

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. Тоді скасування стає передбачуваним і таким, що можна перевірити.

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