💳 Розділ 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. Розрахунок місткості пулу: $\text{PoolSize} \ge \text{ConcurrentThreads} \times 2 + \text{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).

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