JavaRush /Курсы /Spring Data JPA /Propagation: REQUIRED

Propagation: REQUIRED vs REQUIRES_NEW

Spring Data JPA
19 уровень , 1 лекция
Открыта

1. Propagation в композиции сервисов

Propagation почти всегда приходит не потому, что вы «любите аннотации», а потому, что вы начали писать нормальную архитектуру: один сервис собирает use case, а другие сервисы помогают ему сделать шаги внутри этого use case. Как только появляется цепочка OrderService -> InventoryService -> ..., вопрос «это одна транзакция или несколько?» перестаёт быть абстракцией и превращается в судьбу ваших данных.

В Spring слово propagation отвечает на очень конкретный вопрос: что делать, если метод с @Transactional вызывается внутри другого метода с @Transactional. Это не «размножение транзакций», не «сложная тема для DBA» и не «особая магия Hibernate». Это обычная инженерная ситуация: у нас есть внешний outer сервисный метод и внутренний inner метод, и оба помечены как транзакционные (или хотя бы хотят быть такими).

С изоляцией мы смотрели на независимые транзакции и спрашивали, что они видят друг у друга. Здесь вопрос другой: бизнес-операция сама редко живёт в одном методе. placeOrder() зовёт reserve(), затем сохраняет заказ, и теперь важно понять уже не параллельную видимость данных, а судьбу одной операции, растянутой на несколько @Transactional-вызовов.

Давайте посмотрим на самую типичную картину для нашего mini-shop:

sequenceDiagram
    %% Важно: здесь показан типичный use case, где один сервис вызывает другой внутри одной операции.
    participant OS as "OrderService.placeOrder()"
    participant IS as "InventoryService.reserve()"
    participant DB as PostgreSQL

    OS->>DB: читаем товары/остатки
    OS->>IS: reserve(productId, quantity)
    IS->>DB: UPDATE stock_item ...
    IS-->>OS: ok / exception
    OS->>DB: INSERT customer_order / order_item ...

Выглядит просто. Но в какой момент начинается транзакция? В placeOrder()? В reserve()? В обоих? И если в обоих — это «две транзакции» или «одна и та же транзакция, просто два метода»?

Вот тут и появляется propagation.

Важно сразу проговорить две вещи, чтобы дальше не было «мистики».

Во‑первых, транзакция в Spring — это не объект, который вы передаёте параметром. Она обычно «привязана» к текущему потоку выполнения (thread-bound) и управляется PlatformTransactionManager.

Во‑вторых, разница между propagation режимами проявляется только если внешний метод уже открыл транзакцию. Если транзакции нет, то и REQUIRED, и REQUIRES_NEW в итоге создадут новую (в этом месте они выглядят одинаково).

2. Propagation.REQUIRED: одна транзакция

Propagation.REQUIRED звучит как “требуется транзакция”, и по сути так и есть: если транзакция уже есть — мы к ней присоединяемся, если её нет — создаём новую. Это настолько распространённый и «нормальный» сценарий, что в Spring он является значением по умолчанию для @Transactional. Большую часть времени, когда вы пишете обычный backend, вам нужен именно он.

REQUIRED на пальцах: «участвуем, если уже идёт»

В нашем проекте shop-data-jpa use case placeOrder() — это идеальный кандидат на одну общую транзакцию: либо заказ оформился корректно, списались остатки и появились позиции заказа, либо ничего не изменилось.

Внешний сервис:

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

@Service
public class OrderService {

    @Transactional // propagation = REQUIRED по умолчанию
    public void placeOrder(Long productId, int quantity) {
        // Входим в транзакцию use case: это "внешняя" граница операции оформления заказа.
        inventoryService.reserve(productId, quantity); // В REQUIRED-режиме внутренний вызов присоединится к той же TX.
        orderRepository.save(buildOrder(productId, quantity)); // Сохраняем заказ в рамках той же транзакции.
    }
}

Внутренний сервис:

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

@Service
public class InventoryService {

    @Transactional // тоже REQUIRED по умолчанию
    public void reserve(Long productId, int quantity) {
        // Этот метод не открывает "вторую" транзакцию: он участвует во внешней (если она уже есть).
        StockItem stock = stockRepository.findByProductId(productId).orElseThrow(); // Читаем текущие остатки.
        stock.reserve(quantity); // Доменная логика: проверка/уменьшение остатка.
        stockRepository.save(stock); // Фиксация изменения будет вместе с COMMIT внешнего use case.
    }
}

Хотя на обоих методах стоит @Transactional, в режиме REQUIRED мы получаем одну физическую транзакцию (один BEGIN, один COMMIT или ROLLBACK). Внутренний сервис не «открывает новую жизнь», а продолжает ту же самую операцию.

Эта модель очень хорошо ложится на базовое правило data-layer: «одна бизнес-операция — одна транзакция». Никаких лишних commit-ов, никаких сюрпризов, предсказуемое all-or-nothing.

REQUIRED и композиция сервисов

Как только вы начинаете строить проект «по-взрослому», у вас неизбежно появляются сервисы, которые вызывают друг друга. И это нормально: OrderService отвечает за оформление заказа, но он не обязан знать все детали резервирования остатков, поэтому вызывает InventoryService.

Propagation REQUIRED делает этот стиль архитектуры безопасным, потому что он сохраняет единую судьбу операции. Если резервирование не удалось, то и заказ не должен появиться (или наоборот, если заказ не появился — не должны остаться изменения по остатку).

Именно поэтому в большинстве «обычных» сервисных сценариев вы начинаете с REQUIRED и только потом, при очень чётком требовании, думаете про что-то другое.

Важный нюанс: «поймал исключение — не значит спас транзакцию»

Вот тут многие новички получают по лбу (и это нормально: Spring умеет обучать через боль). В REQUIRED внутренний метод участвует во внешней транзакции. Если внутренний метод выбросил runtime-исключение, Spring на границе этого внутреннего метода обычно помечает текущую транзакцию как rollback-only.

И дальше вы можете сделать «логическую» (но неправильную) вещь: поймать исключение и продолжить.

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

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long productId, int quantity) {
        try {
            inventoryService.reserve(productId, quantity); // Внутренний шаг участвует в той же транзакции.
        } catch (OutOfStockException e) {
            // Даже если мы поймали исключение, транзакция может быть уже помечена как rollback-only.
            System.out.println("Нет остатков, но продолжаем..."); // Нет остатков, но продолжаем...
        }
        // В конце метода Spring попытается сделать COMMIT, но может получить rollback и выбросить UnexpectedRollbackException.
        orderRepository.save(buildOrder(productId, quantity));
    }
}

На уровне Java вы действительно «продолжили». Но на уровне транзакции вы уже живёте в мире «эта транзакция должна откатиться». И в финале вы можете получить неприятный сюрприз при завершении метода: вместо коммита Spring сделает rollback и выбросит UnexpectedRollbackException.

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

Я не предлагаю сейчас превращать исключения в религию. Просто запомните интуитивное правило: в REQUIRED сценарии внутренний сервис — часть одной операции, и если он упал, то внешний сервис должен очень осторожно решать, можно ли вообще продолжать. Чаще всего ответ: нельзя.

3. Propagation.REQUIRES_NEW: отдельный commit

REQUIRES_NEW — это уже не «чуть другой синтаксис», а смена модели мышления. Если REQUIRED делает из цепочки вызовов одну общую транзакцию, то REQUIRES_NEW говорит: «какой бы ни была внешняя транзакция, я хочу начать свою и завершить её независимо». Это значит, что внешний use case на время будет приостановлен, а внутри выполнится отдельная операция со своим commit/rollback.

Как это работает: «внешнюю транзакцию ставим на паузу»

Поскольку сегодня мы не уходим в глубокие внутренности Spring, опишем поведение человеческим языком. Когда внешний метод уже находится в транзакции, и вы вызываете внутренний метод с REQUIRES_NEW, Spring делает примерно такую историю:

# outer — это "основная" транзакция use case (оформление заказа).
outer: BEGIN
outer: ... делаем шаги ...
# inner — отдельная транзакция, которая не зависит от судьбы outer.
inner(REQUIRES_NEW): SUSPEND outer
inner: BEGIN
inner: ... делаем свои SQL ...
inner: COMMIT (или ROLLBACK)
inner: RESUME outer
outer: ... продолжаем ...
outer: COMMIT (или ROLLBACK)

То есть появляется две независимых точки завершения. И вот здесь начинается взрослая часть: вы обязаны понимать, какую судьбу вы хотите для данных.

Чтобы увидеть разницу визуально, удобно сравнить REQUIRED и REQUIRES_NEW в одной схеме:

flowchart TD
    %% Идея диаграммы: REQUIRED = одна TX, REQUIRES_NEW = две независимые TX с SUSPEND/RESUME.
    A["OrderService (outer) BEGIN"] --> B["InventoryService.reserve()"]
    B -->|REQUIRED| C["работаем в той же TX"]
    B -->|REQUIRES_NEW| D["пауза outer TX, BEGIN inner TX"]
    D --> E["COMMIT inner"]
    E --> F["возврат в outer TX"]
    C --> G["COMMIT outer (если всё ок)"]
    F --> G

Пример: логируем неуспешный заказ

Для нашего mini-shop логичный и полезный сценарий, где REQUIRES_NEW действительно оправдан: мы хотим зафиксировать факт ошибки в отдельной таблице, даже если основная операция откатится.

Смысл бизнесовый и технический одновременно. Заказ не оформился — значит, мы откатываем изменения. Но информацию «почему он не оформился» иногда полезно сохранить, чтобы потом разбирать проблемы (например, почему часто заканчивается товар).

Пусть у нас есть небольшой сервис логирования ошибок заказа:

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

@Service
public class OrderFailureLogService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logFailure(Long productId, int quantity, String reason) {
        // Лог пишем в отдельной транзакции: он должен сохраниться даже при откате основного use case.
        failureLogRepository.save(new OrderFailureLog(productId, quantity, reason));
    }
}

Здесь важно не то, как устроена сущность OrderFailureLog (это мы уже умеем делать по прошлым модулям), а то, что лог сохраняется в своей транзакции.

Теперь внешний placeOrder() может при ошибке записать лог и всё равно откатить основную операцию:

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

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Long productId, int quantity) {
        try {
            inventoryService.reserve(productId, quantity); // Основной шаг use case: участвует во внешней транзакции.
            orderRepository.save(buildOrder(productId, quantity)); // Тоже часть внешней транзакции.
        } catch (OutOfStockException e) {
            // Важно: логируем причину в REQUIRES_NEW, чтобы запись пережила откат внешней транзакции.
            failureLogService.logFailure(productId, quantity, e.getMessage());
            throw e; // Пробрасываем дальше, чтобы внешний use case честно откатился.
        }
    }
}

Обратите внимание на две идеи, которые здесь важно держать вместе.

Первая идея: failureLogService.logFailure(...) завершится своим коммитом даже если внешний placeOrder() потом откатится (и он откатится, потому что мы пробросили исключение дальше).

Вторая идея: мы не пытаемся «продолжать оформление заказа» после ошибки. Мы фиксируем важный факт (лог) и честно падаем, чтобы основной use case не оставил полуправды в базе.

С точки зрения инвариантов домена это аккуратно: заказ не появился, остатки не изменились — но запись о неуспешной попытке осталась.

REQUIRES_NEW — не «более надёжно», а «иначе»

Очень популярная ошибка мышления: «REQUIRES_NEW надёжнее, значит буду ставить его везде, чтобы точно коммитилось». В этот момент в вашем проекте появляется множество независимых commit-ов, и всё превращается в набор полу-независимых действий.

Чтобы почувствовать, почему это опасно, давайте сделаем антипример. Представим, что кто-то решил «улучшить» reserve() и сделал его REQUIRES_NEW:

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

@Service
public class InventoryService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void reserve(Long productId, int quantity) {
        // ВНИМАНИЕ: так резервирование получит свой независимый COMMIT и может "пережить" откат заказа.
        StockItem stock = stockRepository.findByProductId(productId).orElseThrow();
        stock.reserve(quantity);
        stockRepository.save(stock);
    }
}

Теперь сценарий становится неприятным: резервирование остатков может успешно закоммититься, а потом оформление заказа упадёт на другом шаге (например, ошибка при сохранении заказа, ошибка проверки инварианта, что-то ещё). Внешняя транзакция откатится, а резерв уже останется.

И вы получите состояние «остатки зарезервировали, но заказа нет». Это как положить товар в коробку, а потом забыть записать адрес доставки. На складе хаос, в голове — тоска, в базе — inconsistent state.

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

4. Выбор между REQUIRED и REQUIRES_NEW

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

Давайте сведём различия в компактную таблицу. Таблица — не для заучивания, а чтобы мозгу было легче не путаться.

Ситуация REQUIRED REQUIRES_NEW
Внешняя транзакция уже есть Внутренний метод участвует в ней Внешняя «ставится на паузу», стартует новая
Когда происходит COMMIT Один общий коммит в конце внешнего use case Коммит внутреннего шага отдельно + потом коммит/rollback внешнего
Что будет, если внешний use case откатится Откатится всё вместе Внутренний коммит останется как отдельный факт
Хорошо подходит для Шагов одной бизнес-операции (placeOrder) Технических «побочных эффектов», которые должны сохраниться (логирование ошибки)
Опасно использовать для Основных доменных изменений, которые должны быть атомарными

Если описать то же самое одним абзацем, то мысль такая: REQUIRED поддерживает идею unit of work и почти всегда является правильной стартовой точкой. REQUIRES_NEW нужен редко, но метко, когда вы сознательно хотите отдельную транзакционную судьбу — и готовы объяснить это человеческим языком коллеге (или себе через неделю).

5. Типичные ошибки при работе с REQUIRED и REQUIRES_NEW

Ошибка №1: воспринимать REQUIRED как «всегда создаёт новую транзакцию».
Новичок часто думает, что если он видит @Transactional на методе, то «вот тут точно стартует новая транзакция». В реальности REQUIRED сначала пытается присоединиться к уже существующей. Из‑за этого иногда кажется, что «аннотация не работает», хотя она работает ровно так, как задумано: внутренний сервис участвует во внешней операции.

Ошибка №2: ставить REQUIRES_NEW “для надёжности”, чтобы «точно сохранилось».
Это выглядит логично на уровне эмоций и очень плохо на уровне данных. REQUIRES_NEW — это не «усилитель транзакции», а отдельный коммит. Если так оформить резервирование остатков, сохранение заказа и ещё пару шагов, вы получите систему, где часть данных коммитится даже при провале use case. Потом такие баги чинятся долго и обычно грустно.

Ошибка №3: ловить исключение во внутреннем REQUIRED-методе и думать, что внешняя транзакция “продолжит работать нормально”.
Когда внутренний транзакционный метод участвует во внешней транзакции и падает с runtime-исключением, транзакция может быть помечена как rollback-only. Если внешний метод потом «передумал» и решил всё-таки коммитить, он внезапно получит rollback в конце. Поэтому исключения внутри unit of work нужно трактовать серьёзно: либо вы реально можете продолжать (тогда внутренний шаг должен быть спроектирован иначе), либо вы откатываете всю операцию.

Ошибка №4: использовать REQUIRES_NEW для действий, которые зависят от ещё не завершённых изменений внешней транзакции.
Даже если не лезть в internals, полезно помнить простую вещь: внешняя транзакция ещё не сделала commit, значит её изменения — это «в процессе». Новая транзакция живёт своей жизнью и не обязана видеть промежуточные состояния внешней. Если вы делаете REQUIRES_NEW-шаг, который пытается оперировать данными, которые «ещё не стали реальностью» в базе, вы рискуете получить странные эффекты и очень неприятные расследования.

Ошибка №5: выбирать propagation, не сформулировав use case.
Propagation — это настройка поведения use case, а не настройка «метода репозитория» или «какого-то helper». Если вы не можете сказать фразу “я хочу, чтобы этот внутренний шаг коммитился независимо от основной операции” или наоборот “я хочу, чтобы это было одним атомарным действием” — значит, вы ещё не готовы выбирать REQUIRES_NEW. В такой ситуации почти всегда правильнее остаться на REQUIRED и доуточнить требования.

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