JavaRush /Курсы /Hibernate deep-dive /Оформление заказа и транзакции

Оформление заказа и транзакции

Hibernate deep-dive
17 уровень , 4 лекция
Открыта

1. Транзакция: от результата

Теперь можно собрать всё в один полный placeOrder сценарий. Сокращённый order/inventory сценарий уже показал, где должна жить transaction boundary: подтвердить заказ и зарезервировать остаток должны жить одной судьбой. Теперь расширяем эту же линию до полноценного оформления заказа, где к резерву добавляются создание PurchaseOrder и OrderItem.

В Commerce Persistence Lab оформление заказа — это бизнес-операция, в которой обычно участвуют разные куски домена: клиент (Customer), остатки (InventoryItem), сам заказ (PurchaseOrder) и строки заказа (OrderItem). А значит, мы должны проектировать код так, чтобы единый результат операции был защищён одной общей транзакционной судьбой.

Чтобы зафиксировать ожидание, давайте сформулируем «честный» итог операции человеческим языком: заказ создан, у заказа есть позиции, у заказа корректный статус (хотя бы “создан”), а остатки уменьшились/зарезервировались согласованно. Если что-то пошло не так (например, товара не хватает), мы хотим получить ситуацию “как будто операции не было” — по крайней мере в базе.

Ниже — простая таблица, которая помогает держать фокус:

Сценарий Что меняем в БД Что считаем «успехом» Что считаем «провалом»
Оформление заказа
purchase_order
,
order_item
,
inventory_item
все изменения зафиксированы вместе ничего не зафиксировано (или зафиксировано строго то, что мы сознательно разрешили)

И именно эта табличка должна быть у вас в голове раньше, чем рука потянется поставить @Transactional.

2. Состав операции placeOrder

Раз уж теперь берём полный placeOrder, полезно на минуту остановиться и явно перечислить, что обычно делает «оформление заказа» в нашей учебной модели. Это нужно не ради бюрократии, а чтобы понять: если операция состоит из нескольких шагов, то и транзакционная граница должна их “обнять”. Иначе вы получите не операцию, а набор независимых попыток, которые случайно оказались рядом в коде.

В упрощённом виде place-order сценарий в Commerce Persistence Lab выглядит так: мы читаем клиента, читаем товар (или товары), читаем и проверяем остаток, обновляем InventoryItem, создаём PurchaseOrder, создаём OrderItem, записываем всё это в БД. Где-то рядом обычно живут вычисления суммы, снапшоты имени/sku/цены, инициализация адреса доставки (как Address-snapshot), но сегодня нам важнее не полнота бизнес-логики, а целостность.

Схематично можно представить одну операцию так:

flowchart TD
    %% Один use case = одна смысловая цепочка шагов
    A["Вход в use case: placeOrder()"] --> B[Загрузить Customer]
    B --> C[Загрузить Product]
    C --> D[Загрузить InventoryItem]
    D --> E[Проверить доступность qty]
    E --> F[Обновить InventoryItem: available/reserved]
    F --> G[Создать PurchaseOrder]
    G --> H["Создать OrderItem(ы)"]
    H --> I[Сохранить order]
    I --> J[Commit транзакции]

Ключевая мысль: это одна смысловая линия. Если вы разрежете её на куски с разными транзакциями, вы получите систему, которая “в среднем работает”, но ровно до первого неприятного сбоя. А сбой обязательно будет — хотя бы потому, что IllegalStateException умеет случаться даже у самых добрых людей.

3. Антипаттерн: две транзакции

Очень типичная ошибка — сделать красивый внешний метод placeOrderBad(), который выглядит как одна операция, но внутри вызывает два (или три) сервиса, и каждый живёт своей жизнью. Издалека это похоже на оркестр, а вблизи — на хор людей, которые поют разные песни, но все уверены, что они в одной тональности.

Посмотрим на пример — он специально “не до компила”, потому что нам сейчас важнее форма мысли, чем идеальная проводка зависимостей:

import org.springframework.stereotype.Service;

@Service
public class CheckoutFacade {

    public Long placeOrderBad(Long customerId, Long productId, int qty) {
        // Шаг 1: создаём заказ (часто внутри будет своя транзакция)
        Long orderId = orderDraftService.createDraft(customerId, productId, qty);

        // Шаг 2: отдельная операция с остатками (часто тоже внутри своя транзакция)
        inventoryReservationService.reserve(productId, qty);

        // Возвращаем id, но реальная целостность тут уже не гарантирована
        return orderId;
    }
}

Снаружи это выглядит логично: “создал черновик заказа, зарезервировал остатки, вернул id”. Но проблема начинается, когда вы задаёте вопрос: а что будет, если второй шаг упадёт? Например, reserve() кинет исключение, потому что остатков не хватило. Если createDraft() уже успел зафиксировать транзакцию, вы получите заказ в базе, но без реального резерва. Это не просто “чуть некрасиво”, это модель, которая начинает жить в противоречиях.

Иногда случается зеркальная ситуация: резерв успешно записался, а потом сохранение заказа упало. Тогда в базе есть резерв, но нет заказа. И если вам кажется, что “ну и ладно, потом поправим”, то поздравляю: вы только что изобрели систему, которой срочно нужны компенсирующие операции, аудит, фоновые джобы и человек, который будет плакать по ночам, читая тикеты от поддержки. И это точно не та цель, ради которой мы учим Hibernate.

Визуально плохой дизайн можно показать так:

sequenceDiagram
    %% Плохой сценарий: COMMIT одной части и FAIL другой -> «полу-состояние» в БД
    participant U as "Внешний слой (контроллер/фасад)"
    participant O as OrderDraftService
    participant I as InventoryReservationService
    participant DB as PostgreSQL

    U->>O: createDraft()
    O->>DB: "COMMIT (заказ сохранён)"
    U->>I: reserve()
    I->>DB: "FAIL (исключение)"
    Note over DB: В БД остался заказ без резерва

Да, можно сказать “сделаем @Transactional на фасаде”. И это уже будет шаг в правильном направлении — но только если вы действительно соберёте всю операцию под одной границей, а не оставите куски «на всякий случай» с REQUIRES_NEW или хитрой self-invocation-магией.

4. Паттерн: одна транзакция

Теперь соберём «нормальный» вариант. Он начинается с очень простой идеи: у операции есть единая судьба — значит, у неё есть единая транзакционная граница. Обычно это один публичный метод сервиса, который является точкой входа use case.

Скелет выглядит так:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CheckoutService {

    @Transactional // Одна транзакция на весь use case
    public Long placeOrder(Long customerId, Long productId, int qty) {
        // 1) загрузили данные
        // 2) проверили инварианты (например, хватает ли остатков)
        // 3) изменили managed-сущности (dirty checking сам сделает UPDATE)
        // 4) создали новые сущности (их нужно сделать managed через save/persist)
        // 5) вышли из метода -> commit/rollback
        return 42L;
    }
}

Да, это пока “псевдокод”, но он уже правильно мыслит. Мы не прячем транзакцию в репозиторий, не размазываем по мелким методам, не надеемся, что “у Spring Data там по умолчанию всё обернётся”. Мы делаем границу там, где живёт смысл.

Давайте накидаем реалистичный (но короткий) вариант логики, который хорошо сочетается с тем, что вы уже знаете про managed-state и dirty checking:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CheckoutService {

    @Transactional // Вся операция оформляется атомарно
    public Long placeOrder(Long customerId, Long productId, int qty) {
        // Загружаем данные -> сущности попадают в persistence context (становятся managed)
        Customer customer = customerRepository.findById(customerId).orElseThrow();
        Product product = productRepository.findById(productId).orElseThrow();

        // Загружаем остатки и меняем их через доменную операцию
        InventoryItem item = inventoryRepository.findByProductId(productId).orElseThrow();
        item.reserve(qty); // Меняем managed-сущность -> Hibernate увидит изменения на flush

        // Создаём новый заказ и позиции (пока transient-объекты)
        PurchaseOrder order = PurchaseOrder.forCustomer(customer);
        order.addItem(OrderItem.from(product, qty));

        // Новый агрегат нужно сделать managed, чтобы Hibernate выполнил INSERT-ы
        return orderRepository.save(order).getId();
    }
}

Здесь молча предполагается нормальный aggregate mapping от PurchaseOrder к OrderItem — например, cascade PERSIST или эквивалентный каскад на создание позиций. Иначе этот же пример мгновенно превратится в кейс про mapping, а сейчас нам важна одна транзакционная судьба всего графа.

Обратите внимание на две вещи. Во-первых, item.reserve(qty) — это доменная операция на managed-сущности: мы не зовём inventoryRepository.save(item) “для уверенности”, потому что Hibernate и так увидит изменения через dirty checking. Во-вторых, orderRepository.save(order) здесь уместен, потому что PurchaseOrderновая сущность, её надо сделать managed (через persist, который в Spring Data обычно скрывается внутри save()).

Сами helper-методы на сущностях — это не украшение, а способ сделать сервис читаемым и уменьшить шанс “сломать модель руками”. Например, InventoryItem.reserve(qty):

public void reserve(int qty) {
    // Защищаем инварианты доменной модели
    if (qty <= 0) throw new IllegalArgumentException("qty must be positive");
    if (availableQty < qty) throw new IllegalStateException("not enough stock");

    // Собственно изменение состояния (dirty checking сделает UPDATE на flush)
    availableQty -= qty;
    reservedQty += qty;
}

И аналогично PurchaseOrder.addItem(...) помогает держать двунаправленную связь консистентной: helper-метод сразу синхронизирует обе стороны и не оставляет “полусвязь”:

public void addItem(OrderItem item) {
    // Коллекция заказа — одна сторона связи
    items.add(item);

    // Вторая сторона связи: без этой строчки легко получить «полусвязь»
    item.setOrder(this); // важная строчка, которая спасает от полусвязей
}

Смысл всего этого: сервис управляет сценарием, сущности управляют своей внутренней консистентностью, а Hibernate управляет синхронизацией с БД.

5. Hibernate внутри транзакции

После того как вы написали “красивый” @Transactional метод, возникает следующий типичный вопрос: “А что там реально происходит в базе? Когда именно пойдут INSERT и UPDATE?” И это как раз тот момент, где курс Hibernate deep-dive начинает оправдывать своё название: мы не верим на слово, мы понимаем механику.

Внутри placeOrder() происходит примерно следующее. Когда вы делаете findById(), сущности (Customer, Product, InventoryItem) становятся managed и попадают в persistence context. Когда вы вызываете item.reserve(qty), меняются поля managed-сущности, и Hibernate помечает её как “грязную” (точнее, он обнаружит это на dirty checking во время flush). Когда вы создаёте PurchaseOrder и OrderItem, это пока просто новые Java-объекты (transient), но после orderRepository.save(order) они станут managed (через persist/merge в зависимости от newness).

Когда метод заканчивается, Spring пытается завершить транзакцию. В этот момент Hibernate делает flush: формирует SQL, отправляет его в БД, и только потом происходит commit JDBC-транзакции. Если где-то по пути выкинули исключение, то транзакция откатится, и изменения в БД не будут зафиксированы.

Типичный SQL-профиль (упрощённо) будет выглядеть примерно так:

-- чтения
select * from customers where id = ?;
select * from products where id = ?;
select * from inventory_items where product_id = ?;

-- запись остатков (dirty checking -> UPDATE)
update inventory_items
set available_qty = ?, reserved_qty = ?
where id = ?;

-- создание заказа и позиций
insert into purchase_orders (...) values (...);
insert into order_items (...) values (...);

И тут важная деталь из предыдущих дней: если вы внутри транзакции делаете дополнительные запросы, которые пересекаются с изменёнными сущностями, Hibernate может сделать flush раньше конца метода (flush-before-query), чтобы запросы “видели” согласованную картину. В нашем примере этого может и не быть, но ваша голова должна помнить, что flush — отдельная фаза, и SQL иногда появляется “раньше, чем вы думали”.

Отдельно проговорю нюанс, который ломает новичков морально: rollback не откатывает Java-объекты в памяти. Если вы внутри placeOrder() изменили item и потом бросили исключение, item в памяти остаётся с изменёнными полями — просто эти изменения не попадут в БД. Это нормально. Это ещё одна причина, почему после проваленной операции не надо пытаться “дожить на тех же объектах” и продолжать ими пользоваться как будто ничего не было: лучше дать операции завершиться и начать новый unit of work.

6. Транзакция должна быть короткой

Есть соблазн сделать транзакцию “на всё”: и данные загрузить, и скидки посчитать, и письмо отправить, и с внешним сервисом пообщаться, и заодно чайник включить. Но транзакция — штука ревнивая: чем дольше вы её держите, тем больше вы удерживаете ресурсы базы и тем менее предсказуемым становится поведение (включая банальные таймауты и нагрузочные эффекты).

Хорошее правило звучит скучно, но работает всегда: внутри транзакции оставляйте только то, что нужно для получения согласованного результата в базе. Любые “долгие” действия (вызовы внешних API, ожидание пользователя, тяжёлые вычисления, генерация отчётов, всё что угодно, что может занять секунды) — плохие кандидаты для жизни внутри @Transactional границы. Даже если оно “технически работает”, вы платите непредсказуемостью.

В терминах нашего place-order сценария это означает: транзакция должна включать чтение нужных строк, проверку инвариантов и запись результата, но не должна включать “дальнейшую жизнь” заказа. Например, если вы захотите отправить уведомление, это лучше сделать так, чтобы оно не удлиняло критическую секцию операции (и нет, сегодня мы не обсуждаем, как именно — нам сейчас важнее научиться не превращать транзакцию в болото).

Смешной, но правдивый тест: если внутри транзакционного метода вам захотелось написать Thread.sleep(...), значит, где-то в архитектуре плачет один DBA. Даже если DBA у вас ещё нет — он плачет авансом.

Read-use cases отдельно

Очень частая архитектурная каша начинается с невинной мысли: “раз мы оформляем заказ, давайте тут же и прочитаем его статус/детали и вернём наружу entity”. В результате write-use case превращается в гибрид “создал, сохранил, вернул managed-объект, а где-то там его ещё сериализовали”. И вот вы уже снова рядом с LazyInitializationException, OSIV и другими прекрасными вещами, которые мы сознательно отключили в проекте.

Правильный стиль проще: метод оформления заказа возвращает минимально достаточный результат, обычно orderId. А чтение статуса/деталей — это отдельный read-use case, который либо работает в @Transactional(readOnly=true), либо вообще возвращает projection/DTO, когда полноценная entity для ответа вообще не нужна. Даже если сейчас вы возвращаете всего лишь String status, разделение по смыслу всё равно делает код живучим.

Вот пример честного чтения статуса:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    @Transactional(readOnly = true) // Явно фиксируем: это только чтение
    public String loadOrderStatus(Long orderId) {
        // Загружаем заказ внутри транзакции, чтобы не упереться в lazy вне контекста
        PurchaseOrder order = orderRepository.findById(orderId).orElseThrow();

        // Возвращаем наружу простое значение, а не entity
        return order.getStatus().name();
    }
}

А вот почему это важно даже “для маленького метода”. Во-первых, readOnly здесь является сигналом: “мы не меняем систему”. Во-вторых, вы не смешиваете сценарий создания заказа с сценариями показа данных. В-третьих, вы не создаёте иллюзию, что оформлением заказа можно “заодно починить что-то в модели”.

Психологически это выглядит как “лишний класс”. Но инженерно это выглядит как “возможность потом поддерживать проект без ощущения, что всё связано со всем”.

Helper-методы и self-invocation

И ещё важно помнить: Spring применяет @Transactional через proxy, и self-invocation может обойти этот proxy. В контексте place-order сценария это обычно проявляется так: разработчик разбивает метод на несколько private методов и вешает на них @Transactional, ожидая, что “каждый кусочек будет транзакционным”. А потом кусочки внезапно оказываются не транзакционными, и начинается шаманство.

Для place-order use case хороший стиль такой: разбивайте код на приватные методы, но воспринимайте их как обычные helper-методы, а не как независимые транзакционные точки.

Например, так можно (и нужно) делать — потому что транзакция всё равно одна, на внешнем методе:

@Transactional // Транзакция объявлена на внешней точке входа
public Long placeOrder(Long customerId, Long productId, int qty) {
    // Приватные методы — это просто декомпозиция, а не «новые транзакции»
    InventoryItem item = loadInventory(productId);
    item.reserve(qty);

    PurchaseOrder order = createOrder(customerId, productId, qty);

    // Сохраняем новый агрегат, чтобы он стал managed и попал в flush
    return orderRepository.save(order).getId();
}

А loadInventory(...) и createOrder(...) пусть будут обычными методами без аннотаций. Вы выиграете читабельность, но не будете строить ложных ожиданий, что у каждого helper — своя транзакционная судьба.

Если же вам действительно нужна другая транзакционная семантика (что само по себе уже “опасная территория”), то это почти всегда означает: нужна другая точка входа в другом bean, чтобы вызов прошёл через proxy. Но сегодня, в рамках “одна операция — одна транзакция”, это просто не требуется. Нам достаточно одной нормальной границы.

7. Типичные ошибки: граница транзакции

Почти все ошибки в транзакциях начинаются не с Hibernate и даже не со Spring, а с туманного понимания “что я вообще пытаюсь гарантировать?”. Поэтому ниже — набор граблей, на которые наступают чаще всего, когда реализуют place-order сценарий. Я опишу их как жизненные истории: так запоминается лучше, чем “пункт 1, пункт 2”.

Ошибка №1: разнести одну операцию на несколько независимых транзакций “ради удобства”.
Обычно это выглядит как “создадим заказ в одном сервисе, а остатки обновим в другом”, и пусть каждый будет @Transactional. Проблема в том, что вы теряете атомарность: один шаг мог уже зафиксироваться, а второй упасть. В базе появляются “полузаказы” или “полурезервы”, а дальше начинается зоопарк исключений и ручных чисток данных.

Ошибка №2: делать readOnly=true на методе, который меняет данные, потому что “так быстрее”.
Это одна из самых коварных ловушек: метод называется вроде placeOrder(), внутри вы меняете InventoryItem, создаёте PurchaseOrder, а аннотация говорит readOnly=true. Такой код становится методически двусмысленным: вы читаете его как “не меняем”, а он на самом деле меняет. Даже если оно “случайно работает”, сопровождение превращается в детектив.

Ошибка №3: механически вызывать save() для managed-сущности после каждого изменения.
В place-order сценарии InventoryItem обычно загружается через репозиторий, становится managed, и достаточно вызвать item.reserve(qty). Если вы после этого делаете inventoryRepository.save(item), вы часто просто закрепляете непонимание dirty checking. Плюс вы усложняете чтение кода: кажется, что без save() ничего бы не сохранилось, хотя это не так.

Ошибка №4: возвращать наружу PurchaseOrder как entity и продолжать работать с ним “после транзакции”.
Это выглядит удобно (“я верну заказ и дальше в контроллере что-нибудь сделаю”), но в реальности вы возвращаете объект, который после выхода из метода станет detached. Любая попытка лениво дочитать коллекции или связи вне транзакции может упасть, а ещё хуже — “случайно” сработать в другом окружении и породить непредсказуемый SQL. Для write-use case гораздо честнее вернуть orderId и всё, а чтение вынести отдельно.

Ошибка №5: растянуть транзакцию на лишнюю работу и “ждать” внутри неё.
Если place-order метод внутри транзакции делает что-то долгое (вызовы внешних сервисов, тяжёлые вычисления, ожидание пользователя), вы превращаете базу в заложника вашего метода. Это ухудшает предсказуемость и масштабируемость даже в учебном проекте. Транзакция должна быть короткой и целенаправленной: загрузили, проверили, записали, вышли.

1
Задача
Hibernate deep-dive, 17 уровень, 4 лекция
Недоступна
Оформление заказа как одна транзакционная операция
Оформление заказа как одна транзакционная операция
1
Задача
Hibernate deep-dive, 17 уровень, 4 лекция
Недоступна
Разделение write- и read-сценариев при записи на курс
Разделение write- и read-сценариев при записи на курс
1
Опрос
Транзакции Spring, 17 уровень, 4 лекция
Недоступен
Транзакции Spring
Hibernate и границы сессии
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ