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. Частичный успех и «полузаказы»

Если транзакции — это «всё или ничего», то частичный успех — это состояние «что-то успело, а что-то нет». На уровне бизнеса это почти всегда хуже, чем честная ошибка, потому что такой «полурезультат» начинает жить в базе как будто он настоящий, а потом отравляет всё вокруг. И самое неприятное: он не всегда ломает приложение сразу. Иногда он тихо лежит в данных и всплывает через неделю в виде странного отчёта или звонка от коллеги: «Почему у нас минус три товара на складе, но заказа нет?»

В нашем mini-shop частичный успех обычно выглядит так: остаток уже уменьшили, а заказ не сохранился; заказ сохранили, но позиции не сохранились; заказ и позиции сохранились, но итоговая сумма не соответствует строкам; заказ сохранили, но остаток не уменьшили, и мы «продали воздух». Это четыре разные формы «полупобеды», и каждая из них — фактически баг в данных.

Чтобы увидеть это не как страшилку, а как инженерную модель, удобно выписать, какие данные должны измениться вместе. Ниже — компактная таблица. Она не про то, как писать код, а про то, что означает «корректный результат операции».

Сущность/таблица Что меняем в 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("order id = " + saved.getId()); // order id = 100

    // После исключения транзакция будет откатана, и записи в таблице может не быть.
    throw new IllegalStateException("всё отменяем");
}

Вывод в консоли может быть, id может быть, а строки в таблице — нет, потому что «памятник в граните» ставится на commit, а не на println.

И вот почему транзакционный подход такой важный для placeOrder(): он гарантирует, что после сбоя у вас не останется «половины заказа», «минуснутого склада без заказа» и других весёлых артефактов, которые потом чинятся руками, слезами и SQL-скриптами под названием fix_prod_final_final_v3.sql.

4. cancelOrder() vs rollback: разные операции

На этом месте часто происходит логическая подмена, особенно у тех, кто впервые всерьёз сталкивается с транзакциями. Кажется, что «отмена заказа» и rollback — это одно и то же, просто разными словами. На самом деле это две совершенно разные идеи. Rollback — это технический откат текущей незавершённой транзакции из-за ошибки. Cancel — это осознанное действие бизнеса, которое применяется к уже существующему заказу, который раньше был успешно оформлен.

Если вы оформляете заказ, и внутри placeOrder() что-то пошло не так — мы хотим rollback, чтобы сделать вид, что операции не было. Это похоже на ситуацию: «я начал оформлять покупку, но на экране оплаты всё упало — считаем, что покупка не совершена».

А вот cancelOrder() — это ситуация «покупка была совершена, чек напечатан, а потом клиент передумал». Тут уже нельзя сделать вид, что ничего не было. У вас есть запись о заказе, возможно, он уже попал в отчёты, возможно, вы показали его пользователю. Поэтому отмена — это не исчезновение факта, а изменение состояния: заказ получает статус CANCELLED, а склад возвращает количество обратно.

Очень удобно увидеть это как две разные ветки времени:

sequenceDiagram
    participant U as Пользователь
    participant S as OrderService
    participant DB as База данных

    U->>S: "placeOrder()"
    S->>DB: "insert customer_order + order_item + update stock"
    alt всё ок
        S->>DB: COMMIT
        S-->>U: orderId
    else ошибка
        S->>DB: ROLLBACK
        S-->>U: "ошибка (заказ не создан)"
    end

    U->>S: "cancelOrder(orderId)"
    S->>DB: update order status + restore stock
    S->>DB: COMMIT

На диаграмме видно главное: cancelOrder() вообще не участвует в истории «ошибка внутри placeOrder». Он начинается позже, когда заказ уже существует, и у него своя транзакция, свои проверки, свои правила.

Важная практическая мысль для нашего mini-shop: 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. Тогда отмена становится предсказуемой и проверяемой.

1
Задача
Spring Data JPA, 18 уровень, 2 лекция
Недоступна
Откат после принудительного сбоя
Откат после принудительного сбоя
1
Задача
Spring Data JPA, 18 уровень, 2 лекция
Недоступна
Отмена заказа как новая бизнес-операция
Отмена заказа как новая бизнес-операция
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ