В чому різниця між REQUIRED та REQUIRES_NEW
Коли метод з REQUIRED викликається всередині наявної транзакції: 4. При виході з внутрішнього методу commit() не викликається — рішення про коміт делегується найбільш зовнішньом...
🟢 Junior Level
REQUIRED та REQUIRES_NEW — це два ключові типи поширення транзакцій у Spring Framework. Їхня головна відмінність полягає в тому, чи створюється нова фізична транзакція, чи перевикористовується поточна.
Суть у 30 секундах
REQUIRED(за замовчуванням): «Якщо транзакція вже відкрита — я приєднаюся до неї. Якщо ні — відкрию нову». Усі операції виконуються в межах одного з’єднання до БД і підтверджуються одним спільним комітом. Якщо впаде хоча б одна операція — відкотиться абсолютно все.REQUIRES_NEW: «Мені не важлива чужа транзакція. Я завжди відкриваю свою власну, незалежну транзакцію». Поточна транзакція тимчасово призупиняється, відкривається друге фізичне з’єднання до БД, виконується робота й одразу комітиться. Якщо зовнішня транзакція пізніше впаде, дані зREQUIRES_NEWвже назавжди збережені в базі.
Сценарій створення замовлення з аудитом:
[ REQUIRED (за замовчуванням) ]
createOrder() ------------[ START TRANSACTION ] (Connection #1)
|-- saveOrder() | (вставка замовлення)
|-- auditLog() | (вставка аудиту в тому ж конекті)
|-- throw Exception! |
--------------------------[ ROLLBACK ] (Відкотяться І замовлення, І запис аудиту!)
[ REQUIRES_NEW для аудиту ]
createOrder() ------------[ START TX #1 ] (Connection #1)
|-- saveOrder() |
|-- auditLog() -------- | ---[ START TX #2 ] (Connection #2, TX #1 спить)
| |-- saveAudit()
| ---[ COMMIT TX #2 ] (Запис аудиту збережено назавжди!)
|-- throw Exception! |
--------------------------[ ROLLBACK TX #1 ] (Замовлення відкотилося, але аудит залишився!)
Порівняльна таблиця
| Характеристика | REQUIRED |
REQUIRES_NEW |
|---|---|---|
| Створення нової транзакції | Тільки якщо вище за стеком транзакції немає | Завжди (100% випадків) |
| Кількість фізичних з’єднань | 1 з’єднання з пулу | 2 одночасних з’єднання |
| Призупинення поточної транзакції | Ні | Так (suspend зовнішньої, resume після) |
| Незалежний COMMIT | Ні (єдиний коміт у кінці) | Так (фіксується негайно) |
| Відкат внутрішньої транзакції | Помічає ВСЮ чергу rollback-only |
Відкочує тільки себе |
| Відкат зовнішньої транзакції | Відкочує все | Не впливає на вже зафіксований REQUIRES_NEW |
| Типовий Use-Case | Бізнес-логіка, перекази, замовлення | Незалежний аудит, логування, billing-чеки |
Приклад у Spring Boot
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.Transactional;
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final SecurityAuditService auditService;
@Transactional // REQUIRED за замовчуванням
public void registerOrder(Order order) {
orderRepository.save(order);
// Запис в аудит повинен зберегтися навіть при збої бізнес-логіки:
auditService.logAttempt("Створення замовлення #" + order.getId());
if (order.getItems().isEmpty()) {
// Виняток відкотить збереження замовлення,
// але аудит-лог із logAttempt ВЖЕ закомічений у БД!
throw new IllegalArgumentException("Замовлення не може бути порожнім");
}
}
}
package com.example.service;
import com.example.entity.AuditRecord;
import com.example.repository.AuditRecordRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class SecurityAuditService {
private final AuditRecordRepository auditRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 3)
public void logAttempt(String message) {
auditRepository.save(new AuditRecord(message));
// Фізичний COMMIT відбувається в момент виходу з цього методу!
}
}
🟡 Middle Level
Механіка роботи під капотом Spring Framework
1. Як працює REQUIRED (Join)
Коли метод з REQUIRED викликається всередині наявної транзакції:
TransactionInterceptorзвертається доTransactionSynchronizationManagerі виявляє активний транзакційний ресурс (ConnectionHolder).- Нова фізична транзакція не створюється. Повертається об’єкт
TransactionStatusіз прапорцемisNewTransaction() == false. - Усі SQL-запити виконуються в тому самому JDBC-з’єднанні.
- При виході з внутрішнього методу
commit()не викликається — рішення про коміт делегується найбільш зовнішньому методу (isNewTransaction() == true). - Важливо: Якщо внутрішній метод викидає необроблений
RuntimeException, перехоплювач викликаєstatus.setRollbackOnly().
2. Як працює REQUIRES_NEW (Suspend & New Connection)
- Spring відв’язує поточне з’єднання від потоку:
TransactionSynchronizationManager.unbindResource(dataSource). - Призупиняє сесію Hibernate / JPA та всі зареєстровані слухачі транзакції (
TransactionSynchronization.suspend()). - Запаковує контекст у
SuspendedResourcesHolder. - Запитує нове з’єднання з пулу HikariCP і виконує
connection2.setAutoCommit(false). - Прив’язує нове з’єднання до поточного потоку
ThreadLocal. - Виконує цільовий метод.
- При успішному завершенні негайно викликає
connection2.commit()і закриває (повертає в пул) друге з’єднання. - У блоці
finallyвідновлює початкове з’єднання: викликаєresume()і прив’язує перше з’єднання назад до потоку.
🔴 Senior Level
Ризик дедлоку блокувань рядків між REQUIRED та REQUIRES_NEW
Класична й найнебезпечніша помилка при використанні REQUIRES_NEW — спроба мутації одного й того самого рядка у зовнішній та внутрішній транзакціях:
Хронологія дедлоку:
Потік T1 (Tomcat):
1. outerMethod() [REQUIRED]:
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-> СУБД накладає ексклюзивне блокування (X-lock) на рядок id=1 в Connection #1.
2. outerMethod() викликає innerMethod() [REQUIRES_NEW]:
-> Spring призупиняє Connection #1.
-> Spring відкриває Connection #2 у ТОМУ Ж потоці T1.
3. innerMethod() [REQUIRES_NEW] намагається виконати:
UPDATE accounts SET status = 'VIP' WHERE id = 1;
-> Connection #2 звертається до рядка id=1 в СУБД.
-> СУБД бачить, що рядок id=1 вже заблокований Connection #1, і ПРИЗУПИНЯЄ Connection #2!
БЕЗВИХІДЬ (Livelock / Deadlock одного потоку):
- Connection #2 чекає, поки Connection #1 закомітиться і зніме блокування.
- Але Connection #1 ніколи не закомітиться, тому що його потік T1 заблокований в очікуванні завершення Connection #2!
- ПІДСУМОК: Потік висить до закінчення innodb_lock_wait_timeout в MySQL або lock_timeout в PostgreSQL!
Connection Pool Deadlock (Виснаження пулу з’єднань)
Оскільки REQUIRES_NEW утримує батьківське з’єднання і вимагає друге:
HikariCP maximum-pool-size = 10
Паралельно надходить 10 HTTP-запитів (10 потоків Tomcat):
Кожен потік 1..10 виконує outerMethod() [REQUIRED]:
-> Потоки 1..10 успішно захоплюють усі 10 з'єднань із пулу.
-> Вільних з'єднань у HikariCP: 0!
Потім кожен потік 1..10 викликає innerMethod() [REQUIRES_NEW]:
-> Усі 10 потоків одночасно просять у HikariCP ще по одному з'єднанню.
-> Пул порожній. Усі 10 потоків засинають у черзі очікування пулу.
-> Жоден потік не може завершити зовнішню транзакцію і повернути конект,
тому що всі чекають конект для внутрішньої транзакції.
-> ПІДСУМОК: Повний параліч застосунку (Connection Pool Deadlock) до вильоту
SQLTransientConnectionException: Connection is not available, request timed out after 30000ms.
Захисні заходи на рівні архітектури:
- Розрахунок місткості пулу: $\text{PoolSize} \ge \text{ConcurrentThreads} \times 2 + \text{Margin}$.
- Роздільні пули БД (Multi-Datasource): Виділити окремий вторинний пул із лімітом з’єднань виключно для бінів аудиту з
REQUIRES_NEW. - Обов’язковий Timeout: Завжди задавати
timeout:@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 3) - Перехід на транзакційні події (Transaction Events): Замінити синхронний
REQUIRES_NEWна@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)— тоді запис аудиту стартує суворо після закриття з’єднання зовнішньої транзакції.
Проблема видимості L1-кешу Hibernate (PersistenceContext)
Оскільки REQUIRES_NEW працює в окремій фізичній транзакції:
- Якщо зовнішня транзакція створила або змінила сутність у пам’яті Hibernate, але зміни ще не відправлені в БД (
flush()), внутрішній метод зREQUIRES_NEWїх не побачить! - Щоб внутрішній метод прочитав свіжі зміни, перед його викликом необхідно явно виконати
entityManager.flush(). - І навпаки: зміни, зафіксовані внутрішнім методом з
REQUIRES_NEW, не потраплять автоматично в L1-кеш батьківської сесії Hibernate (можлива аномалія читання застарілих даних у батькові).
4 Tricky Questions
1. Що станеться, якщо метод з REQUIRES_NEW спробує виконати UPDATE того самого рядка, який оновив батьківський метод з REQUIRED?
Відповідь: Станеться зависання потоку з подальшим падінням за таймаутом блокування БД:
- У PostgreSQL:
ERROR: canceling statement due to lock timeout. - У MySQL InnoDB:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction. Причина: Батьківське з’єднання захопилоX-Lockна рядок таблиці і було призупинено (suspended). Дочірня транзакціяREQUIRES_NEWвиконується в окремому фізичному з’єднанні в тому ж потоці JVM і намагається захопити той самийX-Lock. Оскільки з’єднання №2 чекає зняття лока від з’єднання №1, а з’єднання №1 чекає повернення керування зі з’єднання №2, потік блокує сам себе.
2. Чому при перехопленні винятку з REQUIRES_NEW зовнішня транзакція комітиться успішно, а при перехопленні винятку з REQUIRED падає з UnexpectedRollbackException?
Відповідь:
- При
REQUIRED: обидва методи ділять один і той самийTransactionStatus. Коли внутрішній метод викидає необробленийRuntimeException, інтерцептор викликаєstatus.setRollbackOnly(). Коли зовнішній метод ловить помилку вcatchі намагається закомітитися, Spring бачить прапорецьrollback-onlyі примусово відкочує транзакцію, викидаючи викликаючому кодуUnexpectedRollbackException. - При
REQUIRES_NEW: внутрішній метод має власний ізольованийTransactionStatus (isNewTransaction() == true). При помилці інтерцептор відкочує лише внутрішню фізичну транзакцію і завершує її. БатьківськийTransactionStatusне позначається прапорцемrollback-only. Зовнішній метод може безпечно перехопити помилку в блоціcatchі успішно зафіксувати свою частину змін.
3. Чи бачить метод з Propagation.REQUIRES_NEW сутність, створену через repository.save() у батьківській транзакції REQUIRED прямо перед його викликом?
Відповідь: Ні, не бачить (за замовчуванням)! Причини дві:
- Hibernate L1 Cache: Метод
save()у Spring Data JPA не відправляє SQL негайно, а зберігає сутність уPersistenceContextу пам’яті JVM. Поки не викликаноflush(), у базі даних цього рядка фізично не існує. - Ізоляція транзакцій: Навіть якщо перед викликом зробити
entityManager.flush(), рядок буде записано в базу в межах фізичного З’єднання №1 (без коміту). Оскільки З’єднання №2 (REQUIRES_NEW) працює на рівні ізоляціїRead Committedабо вище, воно не може читати незафіксовані дані паралельного з’єднання (захист від Dirty Read). МетодfindById()уREQUIRES_NEWповернеOptional.empty().
4. Як переписати виклик REQUIRES_NEW аудиту на події Spring, щоб повністю усунути ризик Connection Pool Deadlock?
Відповідь:
Використовувати транзакційні події Spring (@TransactionalEventListener) з фазою AFTER_COMPLETION:
// У сервісі бізнес-логіки:
@Transactional
public void processOrder(Order order) {
orderRepository.save(order);
// Публікуємо подію аудиту замість синхронного виклику іншого сервісу
eventPublisher.publishEvent(new OrderProcessedEvent(order.getId()));
}
// У компоненті аудиту:
@Component
public class AuditEventListener {
private final AuditRepository auditRepository;
// Спрацює СУВОРО ПІСЛЯ коміту або відкату зовнішньої транзакції,
// коли батьківське з'єднання ВЖЕ повернулося в пул HikariCP!
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderProcessed(OrderProcessedEvent event) {
auditRepository.save(new AuditLog("Замовлення " + event.orderId() + " оброблено"));
}
}
При такому підході потік ніколи не утримує два з’єднання з пулу одночасно.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
«
REQUIRED— це режим за замовчуванням: метод приєднується до поточної відкритої транзакції (одне з’єднання, єдиний коміт/відкат).REQUIRES_NEWзавжди відкриває нову фізичну транзакцію в окремому з’єднанні з БД, призупиняючи поточну. Головна перевагаREQUIRES_NEW— незалежний коміт (ідеально для аудиту та логування помилок). Головні архітектурні ризикиREQUIRES_NEW: дедлок пулу з’єднань HikariCP (коли кожен потік тримає 2 конекти) та дедлок блокувань рядків, якщо батько і нащадок оновлюють один і той самий запис».
Чек-лист ключових понять
- REQUIRED: 1 з’єднання, єдиний коміт, внутрішній виняток позначає всю транзакцію
rollback-only. - REQUIRES_NEW: 2 з’єднання одночасно (suspend/resume), незалежний коміт.
- UnexpectedRollbackException: Виникає тільки при перехопленні винятку з
REQUIRED-методу, але не зREQUIRES_NEW. - Self-Deadlock: Оновлення одного рядка в
REQUIREDі вкладеномуREQUIRES_NEWвішає потік наглухо. - HikariCP Deadlock: При нестачі пулу потоки взаємно блокуються в очікуванні других з’єднань.
Червоні прапорці (чого ні в якому разі не можна говорити)
- ❌ «REQUIRES_NEW виконується в тому самому з’єднанні, що й батько» (Обов’язково вимагає окреме фізичне з’єднання з пулу).
- ❌ «Якщо батьківська транзакція відкотиться, то зміни з REQUIRES_NEW теж відкотяться» (Внутрішня транзакція вже зафіксована фізично і не відкочується).
- ❌ «Внутрішня транзакція REQUIRES_NEW завжди бачить те, що записав батько» (Не бачить через ізоляцію транзакцій та затримку
flush()в Hibernate).