Що таке 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 виконує таку мікрохірургію:
- Відв’язує
ConnectionHolderвід поточногоThreadLocalчерезTransactionSynchronizationManager.unbindResource(dataSource). - Призупиняє всі зареєстровані синхронізації (
TransactionSynchronization.suspend()), включаючи сесію Hibernate L1 Cache. - Запаковує весь цей стан в об’єкт
SuspendedResourcesHolder. - Створює новий
TransactionStatus, бере нове з’єднання з пулу HikariCP, прив’язує його доThreadLocalі перемикаєautoCommit = false. - У блоці
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-рішення проблеми:
- Винесення в окремий бін (Best Practice): Розділення відповідальності та винесення логіки з іншим життєвим циклом в
AuditService. - Інжекція самого себе (
Self-injection):@Service public class OrderService { @Autowired private OrderService self; // Інжектується AOP-проксі! @Transactional public void outer() { self.innerAudit(); // Проходить через проксі! } } - Використання
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.
Механіка під капотом:
- Обидва методи виконуються в одній і тій самій фізичній транзакції та ділять один
TransactionStatus. - Коли у внутрішньому методі стається
RuntimeException, його перехоплюєTransactionInterceptorвнутрішнього проксі до того, як керування повернеться в блокcatchзовнішнього методу. - Оскільки транзакція розділювана, внутрішній інтерцептор зобов’язаний гарантувати цілісність даних: він викликає
transactionStatus.setRollbackOnly(). - Зовнішній метод ловить виняток, вважає, що успішно обробив помилку, і виходить без винятків.
- Зовнішній
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:
- Дочірній потік стартує з порожнім
TransactionSynchronizationManager. - Якщо в дочірньому потоці викликати
accountRepository.save(account), він виконається в абсолютно новому незалежному з’єднанні в режимі autocommit (або відкриє нову власну транзакцію). - Дочірній потік не бачить незафіксованих змін батьківського потоку (на рівнях вище Read Uncommitted).
- Завершення або відкат батьківської транзакції ніяк не впливає на операції дочірнього потоку, що часто призводить до порушення атомарності.
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».
Чек-лист ключових понять
- Дефолт:
Propagation.REQUIRED. - 7 типів:
REQUIRED,REQUIRES_NEW,NESTED,MANDATORY,SUPPORTS,NOT_SUPPORTED,NEVER. - UnexpectedRollbackException: Виникає, коли зовнішній метод перехоплює виняток із внутрішнього
REQUIRED-методу, що помітив транзакціюrollback-only. - Self-invocation: Виклик
this.method()іде повз AOP Proxy, анотація не перехоплюється. - Connection Pool Deadlock: Вкладені
REQUIRES_NEWблокують батьківські з’єднання в пулі. - 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).