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

Что делает Propagation.NESTED

Если активная транзакция уже существует:


🟢 Junior Level

Propagation.NESTED — это тип распространения транзакций в Spring Framework, который создает вложенную логическую транзакцию через механизм точек сохранения (Savepoints) внутри уже существующей физической транзакции.

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

Если активная транзакция уже существует:

  • Spring не открывает второе соединение с базой данных (в отличие от REQUIRES_NEW), а создает JDBC Savepoint в текущем соединении.
  • Если во вложенном методе происходит ошибка, транзакция откатывается только до этой точки сохранения (ROLLBACK TO SAVEPOINT).
  • Внешняя транзакция может перехватить исключение в блоке try-catch и продолжить работу, успешно закоммитив остальные операции!
  • Однако если в итоге упадет внешняя (родительская) транзакция, то откатятся абсолютно все изменения, включая те, которые выполнил NESTED-метод.

Если активной транзакции не было — NESTED ведет себя точно так же, как REQUIRED (открывает новую физическую транзакцию).

Внешняя транзакция (REQUIRED)
|-- START TRANSACTION (Connection #1)
|   INSERT INTO orders ... (Успех)
|
|   Вложенный вызов (NESTED):
|   |-- SAVEPOINT nested_sp_1
|   |   INSERT INTO order_items ... (ОШИБКА!)
|   |-- ROLLBACK TO SAVEPOINT nested_sp_1  <-- Откат только позиций!
|
|   Внешний метод ловит ошибку в catch:
|   INSERT INTO audit_log ... (Успех)
|-- COMMIT (Сохраняются orders и audit_log, но не order_items!)

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

package com.example.service;

import com.example.dto.OrderDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class BatchOrderProcessor {

    private final SingleOrderService singleOrderService;

    @Transactional // Внешняя транзакция: единый физический коннект
    public void processBatch(List<OrderDto> orders) {
        for (OrderDto order : orders) {
            try {
                // Вызов вложенного метода с точкой сохранения (Savepoint)
                singleOrderService.processSingleOrder(order);
            } catch (Exception ex) {
                // Откатился только конкретный заказ! Вся пачка не ломается!
                log.error("Заказ #{} не обработан: {}", order.getId(), ex.getMessage());
            }
        }
        // Финальный COMMIT: фиксирует все успешно обработанные заказы
    }
}
package com.example.service;

import com.example.dto.OrderDto;
import com.example.repository.OrderJdbcRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class SingleOrderService {

    private final OrderJdbcRepository orderJdbcRepository;

    @Transactional(propagation = Propagation.NESTED)
    public void processSingleOrder(OrderDto order) {
        orderJdbcRepository.save(order);
        if (order.getAmount() <= 0) {
            // Выброс исключения откатит транзакцию только до SAVEPOINT этого заказа
            throw new IllegalArgumentException("Сумма заказа должна быть положительной");
        }
    }
}

🟡 Middle Level

Архитектура: как Spring реализует NESTED через JDBC Savepoints

В основе работы Propagation.NESTED лежит интерфейс SavepointManager (реализованный в JdbcTransactionObjectSupport):

Вызов: batchService.process() -> singleService.processSingleOrder()
                     |
                     v
             [ Spring AOP Proxy ]
                     |
                     v
          [ TransactionInterceptor ]
                     |
1. Проверяет TransactionSynchronizationManager: транзакция активна?
2. Вызывает transactionStatus.createAndHoldSavepoint():
   -> Connection connection = getDataSource().getConnection();
   -> Savepoint sp = connection.setSavepoint("NESTED_SAVEPOINT_1");
                     |
                     v
          [ Целевой метод сервиса ]
                     |
   -----------------------------------------
   | При УСПЕХЕ:                           | При ИСКЛЮЧЕНИИ:
   | connection.releaseSavepoint(sp);      | connection.rollback(sp);
   | (Savepoint освобождается,             | (Откат только изменений до точки,
   |  но COMMIT пока НЕ вызывается!)       |  флаг rollback-only НЕ выставляется!)
   -----------------------------------------
                     |
                     v
Внешний метод завершается -> connection.commit()
(Физический коммит ВСЕХ успешных операций в БД)

Сравнение трех ключевых типов: REQUIRED vs REQUIRES_NEW vs NESTED

Критерий REQUIRED REQUIRES_NEW NESTED
Количество коннектов к БД 1 соединение 2 независимых соединения 1 соединение
Физический коммит вложенного метода Вместе с родителем Немедленно при выходе из метода Вместе с родителем
Реакция на ошибку во внутреннем методе Вся транзакция метится rollback-only Откатывается только внутренний метод Откатывается до Savepoint
Если внешний метод упал ПОСЛЕ внутреннего Откатывается всё Внутренний коммит СОХРАНЯЕТСЯ Откатывается ВСЁ
Поддержка в Hibernate JPA Из коробки (100%) Из коробки (100%) ⚠️ Ограничена (требует спец. настроек)
Поддержка в JTA (XA distributed tx) Да Да ❌ Нет (JTA не поддерживает Savepoint)
Опасность Pool Deadlock Нет ⚠️ Высокая при малом пуле HikariCP Нет (используется тот же коннект)

🔴 Senior Level

Проблема JPA / Hibernate L1 Cache при использовании Savepoint

Главная архитектурная причина, почему Propagation.NESTED редко используется в проектах со Spring Data JPA / Hibernate:

JDBC Savepoint откатывает состояние страниц и строк в реляционной базе данных, но EntityManager (кэш 1-го уровня / PersistenceContext) ничего не знает об этом откате!

Пошаговая катастрофа несинхронизированного кэша Hibernate:
1. singleService.processSingleOrder() [NESTED]
2. entityManager.persist(orderItem); 
   -> Hibernate регистрирует сущность orderItem в PersistenceContext со статусом MANAGED.
3. Внутренний метод выполняет entityManager.flush() -> в БД летит SQL INSERT.
4. Происходит валидационная ошибка -> Spring выполняет connection.rollback(savepoint).
   -> В базе данных строка INSERT физически удалена!
5. Внешний метод ловит ошибку в catch и продолжает работу:
   -> Но в памяти JVM в Hibernate PersistenceContext сущность orderItem ВСЁ ЕЩЕ ЧИСЛИТСЯ MANAGED!
6. Внешний метод завершается, срабатывает финальный flush() / commit():
   -> Hibernate видит сущность со статусом MANAGED и пытается отправить SQL UPDATE или повторный INSERT,
      что приводит к EntityNotFoundException, OptimisticLockException или нарушению целостности ключей!

Как безопасно готовить NESTED в Spring Data JPA

Если в проекте с JPA действительно необходим частичный откат, Senior Engineer предпринимает два шага:

Шаг 1: Явное включение Savepoint в JpaTransactionManager

По умолчанию JpaTransactionManager бросает NestedTransactionNotSupportedException. В конфигурации приложения необходимо разрешить вложенные транзакции:

package com.example.config;

import jakarta.persistence.EntityManagerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
public class TransactionConfig {

    @Bean
    public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
        JpaTransactionManager txManager = new JpaTransactionManager(emf);
        // Принудительно разрешаем использование Savepoint в JPA:
        txManager.setNestedTransactionAllowed(true);
        return txManager;
    }
}

Шаг 2: Ручная зачистка L1 Cache (EntityManager.clear()) при ошибке

Чтобы удалить «фантомные» сущности из памяти после отката Savepoint, вызывающий сервис обязан очистить контекст персистентности:

package com.example.service;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class SafeNestedProcessor {

    @PersistenceContext
    private final EntityManager entityManager;
    private final NestedItemService nestedItemService;

    @Transactional
    public void processSafeBatch(List<ItemDto> items) {
        for (ItemDto item : items) {
            try {
                nestedItemService.processItem(item); // NESTED
            } catch (Exception ex) {
                // КРИТИЧЕСКИ ВАЖНО: очистить L1 Cache, чтобы откатанные
                // сущности не попытались синхронизироваться при общем коммите!
                entityManager.clear();
            }
        }
    }
}

4 Tricky Questions

1. Что произойдет, если родительская транзакция откатится ПОСЛЕ того, как вложенный метод с Propagation.NESTED успешно завершил свою работу?

Ответ: Все изменения, выполненные успешным вложенным методом, будут полностью откатаны вместе с родительской транзакцией. В отличие от Propagation.REQUIRES_NEW, где внутренний метод коммитится физически в отдельном соединении и его фиксация необратима, Propagation.NESTED удерживает изменения в рамках того же самого открытого физического соединения. При откате внешней транзакции вызывается глобальный connection.rollback(), который стирает все изменения в транзакции независимо от того, насколько успешно были освобождены промежуточные точки сохранения (Savepoints).

2. Почему попытка вызвать метод с Propagation.NESTED в стандартном приложении со Spring Boot + Hibernate приводит к NestedTransactionNotSupportedException?

Ответ: Потому что в JpaTransactionManager флаг nestedTransactionAllowed по умолчанию жестко выставлен в false. Разработчики Spring Framework намеренно заблокировали NESTED по умолчанию для JPA, чтобы защитить разработчиков от скрытой рассинхронизации памяти EntityManager (кэш первого уровня, dirty checking, карты snapshot) с состоянием строк в СУБД после отката к точке сохранения. Для безопасного применения NESTED рекомендуется использовать чистый JDBC (DataSourceTransactionManager), JdbcTemplate, JOOQ или MyBatis.

3. Освобождаются ли строчные блокировки (Row Locks) в базе данных при откате к точке сохранения (ROLLBACK TO SAVEPOINT)?

Ответ: Да, блокировки, захваченные внутри блока точки сохранения, освобождаются. Когда СУБД (PostgreSQL, MySQL InnoDB, Oracle) выполняет ROLLBACK TO SAVEPOINT sp:

  • Все изменения данных, выполненные после создания точки sp, откатываются по журналу отката (Undo Log / WAL).
  • Все эксклюзивные блокировки строк (X-locks), наложенные запросами UPDATE, DELETE или SELECT FOR UPDATE после момента создания точки sp, немедленно снимаются.
  • При этом блокировки строк, захваченные родительской транзакцией до создания sp, остаются на месте и удерживаются до финального коммита/отката транзакции.

4. В чем фундаментальная разница в обработке ошибок между REQUIRED и NESTED, если вызывающий метод оборачивает вызов в try-catch?

Ответ:

  • При REQUIRED: внутренний метод выполняется в той же транзакции. При возникновении исключения внутренний TransactionInterceptor перехватывает его и безусловно вызывает transactionStatus.setRollbackOnly(). Когда родительский метод перехватывает ошибку в catch и завершается штатно, Spring при коммите видит флаг rollback-only и выбрасывает UnexpectedRollbackException, откатывая всю транзакцию целиком.
  • При NESTED: внутренний TransactionInterceptor перехватывает исключение и вызывает connection.rollback(savepoint). Он НЕ выставляет флаг rollback-only на глобальной транзакции. В результате родительский метод спокойно ловит исключение в catch, а родительская транзакция успешно коммитится.

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

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

«Propagation.NESTED создает вложенную транзакцию с использованием точки сохранения (JDBC Savepoint) в рамках одного и того же физического соединения. В отличие от REQUIRES_NEW, здесь не тратится дополнительный коннект из пула и нет риска Connection Pool Deadlock. При ошибке во вложенном методе откат происходит только до точки сохранения, позволяя внешней транзакции перехватить ошибку и закоммитить остальную работу. Однако если в конце упадет внешняя транзакция, откатятся абсолютно все данные, включая вложенные. В Hibernate JPA NESTED по умолчанию отключен из-за рассинхронизации L1-кэша».

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

  1. Механизм: JDBC Savepoint (connection.setSavepoint(), connection.rollback(savepoint)).
  2. Коннекты к БД: Ровно 1 соединение (нет оверхеда пула).
  3. Откат родителя: Откатывает все вложенные Savepoint.
  4. Откат вложенного: Не ставит флаг rollback-only на родителе (позволяет обработать в try-catch).
  5. JPA конфликт: JpaTransactionManager бросает NestedTransactionNotSupportedException, так как L1 Cache не умеет откатываться вместе с БД.

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

  • ❌ «NESTED открывает вторую физическую транзакцию и берет второй коннект» (Это делает REQUIRES_NEW. NESTED использует Savepoint в текущем коннекте).
  • ❌ «Если родительская транзакция откатится, вложенный метод NESTED останется в базе» (При глобальном rollback откатится всё).
  • ❌ «NESTED работает в Spring Data JPA из коробки без всяких настроек» (Упадет с NestedTransactionNotSupportedException).

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