💳 Раздел 11 · Вопрос #15

В чём разница между 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 вызывается внутри существующей транзакции:

  1. TransactionInterceptor обращается к TransactionSynchronizationManager и обнаруживает активный транзакционный ресурс (ConnectionHolder).
  2. Новая физическая транзакция не создается. Возвращается объект TransactionStatus с флагом isNewTransaction() == false.
  3. Все SQL-запросы выполняются в том же JDBC-соединении.
  4. При выходе из внутреннего метода commit() не вызывается — решение о коммите делегируется самому внешнему методу (isNewTransaction() == true).
  5. Важно: Если внутренний метод выбрасывает необработанный RuntimeException, перехватчик вызывает status.setRollbackOnly().

2. Как работает REQUIRES_NEW (Suspend & New Connection)

  1. Spring отвязывает текущее соединение от потока: TransactionSynchronizationManager.unbindResource(dataSource).
  2. Приостанавливает сессию Hibernate / JPA и все зарегистрированные листенеры транзакции (TransactionSynchronization.suspend()).
  3. Упаковывает контекст в SuspendedResourcesHolder.
  4. Запрашивает новое соединение из пула HikariCP и выполняет connection2.setAutoCommit(false).
  5. Привязывает новое соединение к текущему потоку ThreadLocal.
  6. Выполняет целевой метод.
  7. При успешном завершении немедленно вызывает connection2.commit() и закрывает (возвращает в пул) второе соединение.
  8. В блоке 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.

Защитные меры на уровне архитектуры:

  1. Расчет емкости пула: $PoolSize \ge ConcurrentThreads \times 2 + Margin$.
  2. Раздельные пулы БД (Multi-Datasource): Выделить отдельный вторичный пул с лимитом соединений исключительно для бинов аудита с REQUIRES_NEW.
  3. Обязательный Timeout: Всегда задавать timeout:
    @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 3)
    
  4. Переход на транзакционные события (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 прямо перед его вызовом?

Ответ: Нет, не видит (по умолчанию)! Причины две:

  1. Hibernate L1 Cache: Метод save() в Spring Data JPA не отправляет SQL немедленно, а сохраняет сущность в PersistenceContext в памяти JVM. Пока не вызван flush(), в базе данных этой строки физически не существует.
  2. Изоляция транзакций: Даже если перед вызовом сделать 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 коннекта) и дедлок блокировок строк, если родитель и потомок обновляют одну и ту же запись».

Чек-лист ключевых понятий

  1. REQUIRED: 1 соединение, единый коммит, внутреннее исключение помечает всю транзакцию rollback-only.
  2. REQUIRES_NEW: 2 соединения одновременно (suspend/resume), независимый коммит.
  3. UnexpectedRollbackException: Возникает только при перехвате исключения из REQUIRED-метода, но не из REQUIRES_NEW.
  4. Self-Deadlock: Обновление одной строки в REQUIRED и вложенном REQUIRES_NEW вешает поток насмерть.
  5. HikariCP Deadlock: При нехватке пула потоки взаимно блокируются в ожидании вторых соединений.

Красные флаги (чего ни в коем случае нельзя говорить)

  • ❌ «REQUIRES_NEW выполняется в том же самом соединении, что и родитель» (Обязательно требует отдельное физическое соединение из пула).
  • ❌ «Если родительская транзакция откатится, то изменения из REQUIRES_NEW тоже откатятся» (Внутренняя транзакция уже закоммичена физически и не откатывается).
  • ❌ «Внутренняя транзакция REQUIRES_NEW всегда видит то, что записал родитель» (Не видит из-за изоляции транзакций и задержки flush() в Hibernate).

Ссылки на связанные темы