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

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