🍃 Розділ 5 · Питання #17

Що робить анотація @Transactional

Транзакція гарантує дотримання вимог ACID (Атомарність, Узгодженість, Ізольованість, Надійність). Якщо в процесі виконання бізнес-методу стається помилка, усі зміни даних, здійс...


🟢 Junior Level

@Transactional — це декларативна анотація Spring, яка покладає керування транзакціями бази даних на IoC-контейнер, позбавляючи розробника необхідності писати шаблонний низькорівневий код відкриття, фіксації (commit) та відкату (rollback) транзакцій.

Навіщо потрібна анотація

Транзакція гарантує дотримання вимог ACID (Атомарність, Узгодженість, Ізольованість, Надійність). Якщо в процесі виконання бізнес-методу стається помилка, усі зміни даних, здійснені цим методом у базі даних, гарантовано відкочуються до вихідного стану.

package com.example.service;

import com.example.model.Order;
import com.example.repository.OrderRepository;
import com.example.repository.PaymentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

    public OrderService(OrderRepository orderRepository, PaymentRepository paymentRepository) {
        this.orderRepository = orderRepository;
        this.paymentRepository = paymentRepository;
    }

    @Transactional
    public void placeOrder(Order order, Long amount) {
        // Крок 1: Зберегти замовлення в БД (INSERT)
        orderRepository.save(order);

        // Крок 2: Списати кошти (UPDATE)
        paymentRepository.charge(order.getUserId(), amount);

        // Якщо тут станеться RuntimeException, обидва кроки відкотяться (Rollback)
    }
}

Як це працює під капотом (AOP-проксі)

  1. Spring створює динамічний AOP-проксі навколо біна.
  2. При виклику методу інтерцептор TransactionInterceptor запитує з’єднання у PlatformTransactionManager та виконує connection.setAutoCommit(false).
  3. Викликається цільовий бізнес-метод.
  4. Якщо метод завершився успішно $\to$ викликається connection.commit().
  5. Якщо метод викинув RuntimeException або Error $\to$ викликається connection.rollback().
  6. З’єднання повертається в пул (HikariCP).

🟡 Middle Level

Параметри анотації @Transactional

Анотація дозволяє тонко налаштувати поведінку транзакції:

@Transactional(
    propagation = Propagation.REQUIRED,
    isolation = Isolation.READ_COMMITTED,
    timeout = 5,
    readOnly = false,
    rollbackFor = {Exception.class},
    noRollbackFor = {CustomBusinessWarningException.class}
)

Стратегії розповсюдження транзакцій (Propagation)

Spring визначає, як метод має поводитися, якщо транзакція вже відкрита вище за стеком викликів:

Стратегія Поведінка за наявності активної транзакції Поведінка за відсутності транзакції
REQUIRED (за замовчуванням) Приєднується до наявної транзакції Створює нову фізичну транзакцію
REQUIRES_NEW Призупиняє поточну, створює незалежну нову Створює нову транзакцію
NESTED Створює Savepoint у межах поточної транзакції Створює нову транзакцію
MANDATORY Приєднується до наявної Викидає NoTransactionException
SUPPORTS Виконується в межах наявної транзакції Виконується без транзакції (non-transactional)
NOT_SUPPORTED Призупиняє поточну, виконується без неї Виконується без транзакції
NEVER Викидає IllegalTransactionStateException Виконується без транзакції

Рівні ізоляції (Isolation)

Захищають від аномалій паралельного читання даних:

  1. DEFAULT: рівень ізоляції СУБД за замовчуванням (у PostgreSQL — READ_COMMITTED, у MySQL InnoDB — REPEATABLE_READ).
  2. READ_COMMITTED: захищає від «брудного» читання (Dirty Reads).
  3. REPEATABLE_READ: захищає від «брудного» та неповторюваного читання (Non-repeatable Reads).
  4. SERIALIZABLE: повна ізоляція ціною блокувань і відкату конфліктуючих транзакцій (захищає від Phantom Reads).

Правила відкату (Rollback Rules): пастка Checked Exceptions

Критичне правило Spring за замовчуванням: Автоматичний відкат (rollback) виконується лише для Unchecked Exceptions (нащадки RuntimeException) та Error.

Якщо метод викине перевіряний виняток (Checked Exception, наприклад IOException або SQLException), транзакція НЕ відкотиться, а успішно зафіксується (commit)!

// ПОМИЛКА: транзакція зафіксується, незважаючи на викинутий виняток!
@Transactional
public void processFile() throws IOException {
    orderRepo.save(new Order());
    if (error) {
        throw new IOException("Диск недоступний"); // Checked exception: транзакція НЕ відкотиться!
    }
}

// ПРАВИЛЬНО: явне зазначення rollbackFor
@Transactional(rollbackFor = Exception.class)
public void processFileCorrect() throws IOException {
    orderRepo.save(new Order());
    if (error) {
        throw new IOException("Диск недоступний"); // Відкотиться коректно
    }
}

🔴 Senior Level

Архітектура: TransactionSynchronizationManager та прив’язка до потоку

Керування транзакціями у традиційному Spring MVC / JDBC побудоване на прив’язці ресурсів до потоку через ThreadLocal:

  1. TransactionSynchronizationManager утримує в ThreadLocal мапу ресурсів (поточне JDBC Connection або Hibernate Session).
  2. Усі репозиторії та JdbcTemplate витягують одне й те саме з’єднання з контексту потоку виконання.
  3. Коллбеки синхронізації (TransactionSynchronization) дозволяють реєструвати слухачі подій комміту та відкату.

Прапорець readOnly = true: внутрішня оптимізація

Зазначення @Transactional(readOnly = true) активує глибокі оптимізації на кількох рівнях:

  1. Рівень Hibernate / JPA:
    • Hibernate переводить Session у режим FlushMode.MANUAL.
    • Вимикається Dirty Checking: Hibernate не зберігає знімки (снапшоти) сутностей у Persistence Context і не сканує поля на предмет змін під час закриття сесії. Це колосально економить оперативну пам’ять та CPU при вибірці тисяч записів!
  2. Рівень JDBC / СУБД:
    • Spring викликає connection.setReadOnly(true).
    • Деякі СУБД (наприклад, PostgreSQL, Oracle) та пули з’єднань можуть перенаправляти такі запити на Read-Replica репліки кластера.

      Важливо: readOnly = true не гарантує абсолютного захисту від операторів INSERT на рівні драйвера, якщо СУБД суворо не валідує статус транзакції.

Смертельна пастка REQUIRES_NEW: Deadlock пулу з’єднань

Сценарій, який регулярно викликає аварії у високонавантажених production-системах:

@Service
public class OrderService {

    @Autowired
    private AuditService auditService;

    @Transactional // Транзакція 1 (Бере Connection A з пулу)
    public void placeOrder() {
        // ... бізнес-логіка ...
        auditService.logAction(); // Внутрішній виклик REQUIRES_NEW
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW) // Транзакція 2 (Бере Connection B)
    public void logAction() {
        auditRepository.save(new AuditLog());
    }
}

Механіка взаємного блокування (Deadlock):

  1. Пул HikariCP налаштований на 10 з’єднань (maximum-pool-size = 10).
  2. Приходить сплеск навантаження: рівно 10 паралельних запитів викликають метод placeOrder().
  3. Кожен із 10 потоків забирає по одному з’єднанню (усі 10 з’єднань пулу вичерпані).
  4. Тепер кожен потік доходить до рядка auditService.logAction().
  5. Для виконання REQUIRES_NEW кожному потоку потрібне друге незалежне з’єднання.
  6. Усі 10 потоків блокуються в очікуванні вільного з’єднання з пулу.
  7. Жоден потік не може завершити зовнішню транзакцію і повернути утримуване з’єднання.
  8. Результат: 100% Deadlock застосунку до закінчення таймауту connection-timeout!

Надійна публікація подій: TransactionalEventListener

Якщо всередині транзакції необхідно відправити повідомлення в Apache Kafka або RabbitMQ:

  • Відправляти напряму з @Transactional не можна: якщо брокер прийме повідомлення, а база даних впаде на етапі комміту, виникне розсинхронізація (Phantom Event).
  • Рішення: використовувати @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) або патерн Transactional Outbox.
package com.example.event;

import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class OrderNotificationListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        // Виконується СУВОРО ПІСЛЯ успішного комміту транзакції в БД!
        kafkaProducer.send("orders-topic", event);
    }
}

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

@Transactional — анотація декларативного керування транзакціями, що працює через Spring AOP Proxy та PlatformTransactionManager.

  • Обгортає виклик методу у відкриття транзакції, commit при успіху та rollback при помилці.
  • За замовчуванням: відкат відбувається лише для RuntimeException та Error. Перевіряні винятки (Checked Exception) транзакцію не відкочують (потрібно вказувати rollbackFor = Exception.class).
  • Основні параметри: propagation (за замовчуванням REQUIRED), isolation (рівень ізоляції СУБД), readOnly (вимикає Dirty Checking у Hibernate), timeout.
  • Не працює при виклику всередині того самого класу (self-invocation) та на private/final методах.

4 каверзних питання з відповідями

1. Чи відкотиться транзакція, якщо метод викине IOException?

Відповідь: Ні, за замовчуванням транзакція зафіксується (commit). Spring відкочує транзакції лише при виникненні неперевіряних винятків (RuntimeException) або системних помилок (Error). Щоб транзакція відкочувалася при будь-яких помилках, необхідно явно вказувати @Transactional(rollbackFor = Exception.class).

2. У чому різниця між Propagation.REQUIRES_NEW та Propagation.NESTED?

Відповідь: REQUIRES_NEW призупиняє зовнішню транзакцію і відкриває повністю незалежну нову транзакцію, що вимагає другого фізичного з’єднання з БД. Помилка у зовнішній транзакції не відкотить нову, і навпаки. NESTED використовує те саме фізичне з’єднання і створює точку збереження (Savepoint). Відкат вкладеної частини відбувається до Savepoint без відкату всієї зовнішньої транзакції.

3. Як використання REQUIRES_NEW може покласти сервер при зростанні навантаження?

Відповідь: Кожен виклик REQUIRES_NEW утримує одночасно два фізичні з’єднання з пулу (одне заморожене у зовнішній транзакції, друге активне у внутрішній). Якщо розмір пулу обмежений, а кількість паралельних потоків дорівнює розміру пулу, настає взаємне блокування (Deadlock пулу з’єднань): усі потоки чекають друге з’єднання, утримуючи перше.

4. Що дає прапорець readOnly = true в Hibernate?

Відповідь: Hibernate вимикає механізм відстеження брудних сутностей (Dirty Checking) і переводить сесію в режим FlushMode.MANUAL. Hibernate не зберігає знімки стану сутностей у пам’яті, що суттєво знижує навантаження на Garbage Collector та процесор при читанні великих масивів даних.


Червоні прапорці (чого категорично не можна говорити)

  • ❌ «Spring відкочує транзакцію при абсолютно будь-якій помилці» — груба помилка. Checked Exceptions за замовчуванням ігноруються механізмом відкату.
  • ❌ «REQUIRES_NEW відкриває транзакцію в тому самому з’єднанні» — для REQUIRES_NEW обов’язково виділяється окреме фізичне з’єднання з пулу.
  • ❌ «readOnly = true гарантовано блокує операції INSERT на рівні драйвера» — це оптимізація сесії Hibernate та підказка драйверу, а не суворий тригер прав СУБД.
  • ❌ «Можна робити довгі HTTP-виклики до зовнішніх сервісів прямо всередині @Transactional» — антипатерн, що призводить до вичерпання пулу з’єднань БД і падіння мікросервісу.

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