💳 Раздел 11 · Вопрос #13

Что такое Propagation в Spring

Когда метод с аннотацией @Transactional вызывает другой метод с аннотацией @Transactional, Spring должен решить:


🟢 Junior Level

Propagation (распространение транзакций) — это атрибут аннотации @Transactional, определяющий поведение транзакционного менеджера при вызове одного транзакционного метода из другого.

Суть в 30 секундах

Когда метод с аннотацией @Transactional вызывает другой метод с аннотацией @Transactional, Spring должен решить:

  • Присоединиться к уже существующей открытой транзакции?
  • Приостановить текущую транзакцию и открыть абсолютно новую физическую транзакцию в отдельном соединении с БД?
  • Выполнить операцию без транзакции?
  • Выбросить исключение, если транзакции нет (или наоборот — если она есть)?

Именно за это решение отвечает параметр propagation:

@Transactional(propagation = Propagation.REQUIRED) // Значение по умолчанию
public void someBusinessMethod() { ... }

7 типов распространения транзакций в Spring

Тип Поведение, если транзакция УЖЕ ЕСТЬ Поведение, если транзакции НЕТ Типичный сценарий
REQUIRED (дефолт) Присоединяется к текущей транзакции Открывает новую транзакцию 90% стандартных операций бизнес-логики
REQUIRES_NEW Приостанавливает текущую, открывает новую Открывает новую транзакцию Независимый аудит, логирование, billing-чеки
NESTED Создает вложенную транзакцию (JDBC Savepoint) Открывает новую транзакцию Пакетная обработка (откат части элементов)
MANDATORY Присоединяется к текущей транзакции Бросает исключение IllegalTransactionStateException Вспомогательные сервисы, требующие внешнюю транзакцию
SUPPORTS Присоединяется к текущей транзакции Выполняется не транзакционно Оптимизированные запросы на чтение
NOT_SUPPORTED Приостанавливает текущую транзакцию Выполняется не транзакционно Тяжелые внешние вызовы (REST/gRPC/Email)
NEVER Бросает исключение IllegalTransactionStateException Выполняется не транзакционно Операции, строго запрещающие блокировки БД

Наглядный пример: заказ и независимый аудит

package com.example.service;

import com.example.entity.Order;
import com.example.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final AuditService auditService;

    @Transactional // REQUIRED по умолчанию: создает Transaction #1
    public void placeOrder(Order order) {
        orderRepository.save(order);

        // Вызов метода другого бина с REQUIRES_NEW
        auditService.logAction("Попытка создания заказа #" + order.getId());

        if (order.getTotalPrice() == null) {
            // При выбросе RuntimeException Transaction #1 откатится,
            // но запись аудита из logAction останется в БД!
            throw new IllegalArgumentException("Цена заказа не указана!");
        }
    }
}
package com.example.service;

import com.example.entity.AuditLog;
import com.example.repository.AuditLogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    // Открывает Transaction #2 в отдельном физическом коннекте к БД,
    // приостанавливая Transaction #1
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAction(String message) {
        auditLogRepository.save(new AuditLog(message));
    }
}

🟡 Middle Level

Архитектурный пайплайн: Spring AOP, Proxy и TransactionInterceptor

Spring реализует управление транзакциями через паттерн AOP Proxy и перехватчик TransactionInterceptor.

Вызов клиента:
service.placeOrder()
        |
        v
[ CGLIB / Dynamic Proxy ]
        |
        v
[ TransactionInterceptor ]
  1. Читает @Transactional(propagation = ...)
  2. Обращается к PlatformTransactionManager
  3. Проверяет ThreadLocal ресурсы в TransactionSynchronizationManager
  4. Принимает решение: JOIN / SUSPEND / CREATE NEW / SAVEPOINT
        |
        v
[ Целевой метод сервиса (Target Bean) ]
        |
        v
[ TransactionInterceptor ]
  5. При успехе: commit (или ничего, если транзакция внешняя)
  6. При исключении: rollback / rollback to savepoint / setRollbackOnly

Разбор механики каждого типа Propagation

1. Propagation.REQUIRED (дефолт)

Единый логический и физический контекст. Все вызовы объединяются в одну физическую транзакцию БД.

  • Опасная ловушка: Если внутренний метод выбросил RuntimeException, он помечает всю транзакцию статусом rollback-only. Даже если внешний метод поймает исключение через try-catch, транзакцию невозможно будет закоммитить!

2. Propagation.REQUIRES_NEW

Внутренний метод всегда выполняется в новой физической транзакции в отдельном соединении из пула.

  • Текущая внешняя транзакция приостанавливается (suspend).
  • Поток удерживает физический коннект №1 и запрашивает из пула коннект №2.
  • Внутренняя транзакция коммитится или откатывается совершенно независимо от судьбы внешней.
  • После завершения внутренний коннект возвращается в пул, внешняя транзакция восстанавливается (resume).

3. Propagation.NESTED

Использует механизм Savepoint (точки сохранения) в рамках одного и того же физического соединения.

  • Не открывает второй коннект к БД.
  • Перед вызовом внутреннего метода выполняется connection.setSavepoint("...").
  • Если внутренний метод упал, откат происходит только до точки сохранения (connection.rollback(savepoint)), а внешний метод может перехватить ошибку и успешно завершить внешнюю транзакцию.

4. Propagation.MANDATORY

Метод отказывается выполняться «в одиночку». Если выше по стеку нет открытой транзакции, Spring немедленно выбрасывает: org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'.

5. Propagation.SUPPORTS

«Транзакционный хамелеон». Если транзакция уже открыта — выполняется внутри неё. Если транзакции нет — выполняется без неё (автокоммит JDBC).

6. Propagation.NOT_SUPPORTED

Принудительно приостанавливает существующую транзакцию на время работы метода и выполняет его вне транзакционного контекста. Полезно для тяжелых блокирующих вызовов сторонних REST API или очередей сообщений, чтобы не удерживать транзакционные блокировки строк в БД.

7. Propagation.NEVER

Антипод MANDATORY. Выбрасывает IllegalTransactionStateException, если обнаружена активная транзакция.


🔴 Senior Level

Внутренности ядра: TransactionSynchronizationManager и PlatformTransactionManager

Вся синхронизация транзакционных ресурсов держится на статическом классе TransactionSynchronizationManager, хранящем состояние в ThreadLocal:

public abstract class TransactionSynchronizationManager {
    // Карта ресурсов: DataSource -> ConnectionHolder (или SessionFactory -> SessionHolder)
    private static final ThreadLocal<Map<Object, Object>> resources =
            new NamedThreadLocal<>("Transactional resources");

    // Зарегистрированные колбэки (TransactionSynchronization)
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
            new NamedThreadLocal<>("Transaction synchronizations");

    private static final ThreadLocal<String> currentTransactionName =
            new NamedThreadLocal<>("Current transaction name");

    private static final ThreadLocal<Boolean> currentTransactionReadOnly =
            new NamedThreadLocal<>("Current transaction read-only status");

    private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
            new NamedThreadLocal<>("Current transaction isolation level");

    private static final ThreadLocal<Boolean> actualTransactionActive =
            new NamedThreadLocal<>("Actual transaction active");
}

Механизм Suspend/Resume в AbstractPlatformTransactionManager:

Когда вызывается метод с REQUIRES_NEW, метод suspend() ядра Spring выполняет следующую микрохирургию:

  1. Отвязывает ConnectionHolder от текущего ThreadLocal через TransactionSynchronizationManager.unbindResource(dataSource).
  2. Приостанавливает все зарегистрированные синхронизации (TransactionSynchronization.suspend()), включая сессию Hibernate L1 Cache.
  3. Упаковывает всё это состояние в объект SuspendedResourcesHolder.
  4. Создает новый TransactionStatus, берет новое соединение из пула HikariCP, привязывает его к ThreadLocal и переключает autoCommit = false.
  5. В блоке finally после завершения внутреннего метода вызывается resume(), возвращающий SuspendedResourcesHolder обратно в ThreadLocal.

Риск Connection Pool Deadlock при REQUIRES_NEW в Highload

Использование REQUIRES_NEW в высоконагруженных системах несет в себе скрытую угрозу взаимной блокировки пула соединений (Pool Exhaustion Deadlock).

Размер пула HikariCP = 10 соединений.
Одновременно поступает 10 HTTP-запросов (10 рабочих потоков Tomcat/Undertow):

Поток 1: начал outerMethod (REQUIRED)     -> Захватил Connection #1 из пула (удерживает в suspend)
Поток 2: начал outerMethod (REQUIRED)     -> Захватил Connection #2 из пула (удерживает в suspend)
...
Поток 10: начал outerMethod (REQUIRED)    -> Захватил Connection #10 из пула (удерживает в suspend)

В этот момент в пуле HikariCP осталось 0 свободных соединений!

Поток 1 вызывает innerMethod (REQUIRES_NEW)  -> просит Connection #11 -> ЖДЕТ В ПУЛЕ...
Поток 2 вызывает innerMethod (REQUIRES_NEW)  -> просит Connection #12 -> ЖДЕТ В ПУЛЕ...
...
Поток 10 вызывает innerMethod (REQUIRES_NEW) -> просит Connection #20 -> ЖДЕТ В ПУЛЕ...

ИТОГ: Все 10 потоков заблокированы навсегда, ожидая соединения из пустого пула!
По истечении connectionTimeout (по умолчанию 30 секунд) все 10 транзакций упадут с:
SQLTransientConnectionException: Connection is not available, request timed out after 30000ms.

Математическая формула размера пула:

Для безопасного использования $N$ уровней вложенности REQUIRES_NEW: \(\text{PoolSize} \ge \text{MaxThreads} \times (1 + \text{MaxNestedRequiresNew}) + \text{SafetyMargin}\)

Проблема Self-Invocation (самовызова) и методы её решения

Вызов транзакционного метода из того же класса (this.anotherTransactionalMethod()) полностью игнорирует propagation, потому что вызов идет мимо Spring AOP Proxy.

@Service
public class OrderService {

    @Transactional
    public void outer() {
        // ОШИБКА: propagation = REQUIRES_NEW НЕ СРАБОТАЕТ!
        // Вызов идет напрямую через указатель this, минуя TransactionInterceptor!
        this.innerAudit();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void innerAudit() {
        // Выполнится внутри транзакции outer(), как если бы было REQUIRED!
    }
}

Senior-решения проблемы:

  1. Вынесение в отдельный бин (Best Practice): Разделение ответственности и вынос логики с другим жизненным циклом в AuditService.
  2. Инжекция самого себя (Self-injection):
    @Service
    public class OrderService {
        @Autowired
        private OrderService self; // Инжектится AOP-прокси!
    
        @Transactional
        public void outer() {
            self.innerAudit(); // Проходит через прокси!
        }
    }
    
  3. Использование AopContext: Включить @EnableAspectJAutoProxy(exposeProxy = true) и вызывать ((OrderService) AopContext.currentProxy()).innerAudit().

4 Tricky Questions

1. Что произойдет, если родительский метод с Propagation.REQUIRED перехватит в блоке try-catch исключение из дочернего метода, который тоже объявлен как Propagation.REQUIRED?

Ответ: При попытке коммита внешней транзакции Spring выбросит UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only.

Механика под капотом:

  1. Оба метода выполняются в одной и той же физической транзакции и делят один TransactionStatus.
  2. Когда во внутреннем методе случается RuntimeException, его перехватывает TransactionInterceptor внутреннего прокси до того, как управление вернется в блок catch внешнего метода.
  3. Поскольку транзакция разделяемая, внутренний интерцептор обязан гарантировать целостность данных: он вызывает transactionStatus.setRollbackOnly().
  4. Внешний метод ловит исключение, считает, что успешно обработал ошибку, и выходит без исключений.
  5. Внешний TransactionInterceptor пытается выполнить connection.commit(), но обнаруживает флаг isRollbackOnly() == true. Spring выполняет rollback и бросает UnexpectedRollbackException, чтобы вызывающий клиент не подумал ошибочно, что данные успешно сохранились.

2. Поддерживает ли JpaTransactionManager (Hibernate) уровень Propagation.NESTED из коробки?

Ответ: Нет, по умолчанию не поддерживает. При попытке вызвать метод с Propagation.NESTED под управлением JpaTransactionManager будет выброшено исключение: org.springframework.transaction.NestedTransactionNotSupportedException: JpaTransactionManager does not support nested transactions by default.

Причина: Hibernate использует контекст персистентности первого уровня (PersistenceContext / L1 Cache) с накоплением мутаций в памяти. JDBC Savepoint откатывает состояние страниц и блокировок в самой базе данных, но EntityManager не умеет восстанавливать свое внутреннее состояние снимков сущностей до состояния Savepoint. Если бы NESTED разрешили, при последующем flush() Hibernate попытался бы отправить в базу неконсистентные сущности, которые в БД уже были откатаны точкой сохранения. NESTED поддерживается только DataSourceTransactionManager при работе с чистым JDBC / MyBatis / JdbcTemplate.

3. Как ведут себя переменные ThreadLocal транзакции при переходе в асинхронный поток (@Async или CompletableFuture)?

Ответ: Переменные TransactionSynchronizationManager НЕ передаются в дочерние потоки автоматически. По умолчанию Spring использует стандартный ThreadLocal, а не InheritableThreadLocal. Если из метода @Transactional запустить дочерний поток через CompletableFuture.runAsync(...) или @Async:

  1. Дочерний поток стартует с пустым TransactionSynchronizationManager.
  2. Если в дочернем потоке вызвать accountRepository.save(account), он выполнится в абсолютно новом независимом соединении в режиме autocommit (или откроет новую собственную транзакцию).
  3. Дочерний поток не видит незакоммиченных изменений родительского потока (на уровнях выше Read Uncommitted).
  4. Завершение или откат родительской транзакции никак не влияет на операции дочернего потока, что часто приводит к нарушению атомарности.

4. В чем отличие Propagation.NOT_SUPPORTED от выполнения метода без аннотации @Transactional вообще?

Ответ:

  • Если метод вообще не аннотирован @Transactional, и он вызывается внутри существующей транзакции (через бин-прокси), этот метод будет выполняться внутри текущей открытой транзакции, используя ее соединение и контекст синхронизации.
  • Если метод аннотирован @Transactional(propagation = Propagation.NOT_SUPPORTED), Spring AOP Proxy принудительно приостанавливает текущую транзакцию (suspend), отвязывает коннект от потока и выполняет тело метода без транзакции (все SQL-команды работают в чистом autoCommit = true). После завершения метода родительская транзакция возобновляется (resume).

🎯 Шпаргалка для интервью

30-секундный ответ

«Propagation в Spring определяет границы транзакций при вызове одного транзакционного метода из другого. Существует 7 типов. Дефолтный — REQUIRED (присоединяется к текущей или создает новую). Для независимых операций (например, аудит) используется REQUIRES_NEW (приостанавливает внешнюю и открывает новую физическую транзакцию в отдельном соединении). Для частичного отката через точки сохранения JDBC — NESTED. Важно помнить: вызовы через this внутри одного класса обходят Spring Proxy, из-за чего настройки propagation игнорируются, а использование REQUIRES_NEW при маленьком пуле соединений может вызвать Connection Pool Deadlock».

Чек-лист ключевых понятий

  1. Дефолт: Propagation.REQUIRED.
  2. 7 типов: REQUIRED, REQUIRES_NEW, NESTED, MANDATORY, SUPPORTS, NOT_SUPPORTED, NEVER.
  3. UnexpectedRollbackException: Возникает, когда внешний метод перехватывает исключение из внутреннего REQUIRED-метода, пометившего транзакцию rollback-only.
  4. Self-invocation: Вызов this.method() идет мимо AOP Proxy, аннотация не перехватывается.
  5. Connection Pool Deadlock: Вложенные REQUIRES_NEW блокируют родительские соединения в пуле.
  6. NESTED vs JPA: JpaTransactionManager не поддерживает NESTED из коробки из-за невозможности синхронизации Hibernate L1 Cache.

Красные флаги (чего ни в коем случае нельзя говорить)

  • ❌ «REQUIRES_NEW и NESTED — это одно и то же» (REQUIRES_NEW коммитится независимо в новом коннекте; NESTED использует Savepoint в текущем коннекте и откатывается, если упадет внешняя транзакция).
  • ❌ «Если поймать ошибку внутреннего REQUIRED-метода в try-catch, внешняя транзакция успешно закоммитится» (Нет, будет UnexpectedRollbackException, так как транзакция уже помечена rollback-only).
  • ❌ «Propagation работает при вызове любого метода того же класса» (Вызов через this обходит Spring прокси).
  • ❌ «NESTED поддерживается Hibernate JPA из коробки» (Вызовет NestedTransactionNotSupportedException).

Ссылки на связанные темы