💳 Розділ 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;

import java.util.List;

@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).

Пов’язані теми