В чём разница между 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.
Защитные меры на уровне архитектуры:
- Расчет емкости пула: $PoolSize \ge ConcurrentThreads \times 2 + 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).