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. Тогда отмена становится предсказуемой и проверяемой.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ