💳 Розділ 11 · Питання #16

Що таке анотація @Transactional

Позначивши метод анотацією @Transactional, ви доручаєте Spring Framework: 4. Якщо метод викинув RuntimeException або Error — автоматично виконати ROLLBACK.


🟢 Junior Level

@Transactional — це ключова анотація Spring Framework, призначена для декларативного керування транзакціями. Вона позбавляє розробника необхідності вручну відкривати з’єднання з базою даних, виконувати команди BEGIN, COMMIT або ROLLBACK та закривати ресурси в блоках try-finally.

Суть у 30 секундах

Позначивши метод анотацією @Transactional, ви доручаєте Spring Framework:

  1. Автоматично відкрити транзакцію в базі даних перед виконанням методу.
  2. Виконати бізнес-логіку методу.
  3. Якщо метод завершився успішно — виконати COMMIT.
  4. Якщо метод викинув RuntimeException або Error — автоматично виконати ROLLBACK.
[ Клієнтський виклик ]
        |
        v
[ Spring AOP Proxy ] -------------> BEGIN TRANSACTION (бере конект із пулу)
        |
        v
[ Виклик методу Service ] ---------> Виконуються SQL-запити в БД
        |
        +-- Без помилок? -----------> COMMIT (зміни зафіксовані)
        |
        +-- RuntimeException? -----> ROLLBACK (усі зміни скасовані)

Найпростіший приклад у Spring Boot

package com.example.service;

import com.example.entity.Account;
import com.example.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
@RequiredArgsConstructor
public class BankTransferService {

    private final AccountRepository accountRepository;

    @Transactional // Атомарний переказ коштів
    public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId)
                .orElseThrow(() -> new IllegalArgumentException("Відправника не знайдено"));
        Account to = accountRepository.findById(toId)
                .orElseThrow(() -> new IllegalArgumentException("Одержувача не знайдено"));

        from.debit(amount);
        to.credit(amount);

        accountRepository.save(from);
        // Якщо тут станеться збій або викидання винятку,
        // списання з рахунку 'from' гарантовано відкотиться!
        accountRepository.save(to);
    }
}

Головні атрибути анотації @Transactional

Атрибут Призначення Значення за замовчуванням
propagation Поведінка під час виклику з іншої транзакції Propagation.REQUIRED
isolation Рівень ізоляції транзакції в СУБД Isolation.DEFAULT (дефолт СУБД)
readOnly Оптимізація для запитів лише на читання false
timeout Максимальний час виконання в секундах -1 (без таймауту)
rollbackFor Класи винятків, що викликають ROLLBACK RuntimeException.class, Error.class
noRollbackFor Винятки, за яких транзакція комітиться {} (порожній масив)

🟡 Middle Level

Архітектурний механізм: Spring AOP Proxy

Spring керує транзакціями за допомогою патерну AOP Proxy (динамічний проксі-об’єкт):

Application Context при старті:
Bean (BankTransferService) ---> Обгортається в CGLIB/JDK Proxy
                                      |
При виклику методу:                   v
proxy.transferMoney() ---> [ TransactionInterceptor ]
                                      |
                      1. Читає @Transactional метадані
                      2. Звертається до PlatformTransactionManager
                      3. Перевіряє ThreadLocal ресурси
                      4. Виконує цільовий метод реального біна
                      5. Обробляє результат / Rollback / Commit

Розміщення анотації: Метод vs Клас vs Інтерфейс

  1. Над методом: Найточніший і рекомендований варіант. Перевизначає налаштування класу для конкретного методу.
  2. Над класом: Застосовується до всіх public методів цього класу.
  3. Над інтерфейсом: ⚠️ Небезпечний антипатерн! Працює тільки при використанні застарілих JDK Dynamic Proxies на базі інтерфейсів. Якщо в проєкті використовується CGLIB (стандарт за замовчуванням у Spring Boot), анотація на інтерфейсі буде повністю проігнорована!

Поширені помилки розробників

Помилка Наслідок Архітектурно правильне рішення
Виклик через this.method() Виклик іде повз AOP Proxy, транзакція не відкривається Винести метод в окремий сервіс або використати self-injection
Анотація на private методі Проксі не може перехопити private-метод, анотація ігнорується Робити метод public
Анотація на final методі CGLIB не може перевизначити final-метод, проксіювання не працює Прибрати ключове слово final
Checked Exception без rollbackFor При викиданні IOException або SQLException відбувається COMMIT Завжди вказувати rollbackFor = Exception.class
Перехоплення винятку без прокидання try-catch ковтає помилку, Spring виконує COMMIT Прокидати виняток або викликати TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
@Transactional на контролері Утримує конект до БД під час парсингу JSON та мережевого I/O Ставити анотацію суворо на шар @Service

🔴 Senior Level

Нутрощі ядра: TransactionInterceptor та TransactionAttributeSource

У ядрі Spring процес перехоплення транзакційного методу інкапсульований у класі TransactionAspectSupport (метод invokeWithinTransaction):

// Спрощена архітектурна логіка Spring Framework 6.x / 7.x
protected Object invokeWithinTransaction(Method method, Class<?> targetClass,
                                          InvocationCallback invocation) throws Throwable {

    TransactionAttributeSource tas = getTransactionAttributeSource();
    TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
    PlatformTransactionManager tm = determineTransactionManager(txAttr);

    // 1. Створення транзакції (якщо необхідно згідно з Propagation)
    TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);

    Object retVal;
    try {
        // 2. Виклик реального методу цільового біна
        retVal = invocation.proceedWithInvocation();
    } catch (Throwable ex) {
        // 3. Обробка винятку: перевірка правил rollbackFor / noRollbackFor
        completeTransactionAfterThrowing(txInfo, ex);
        throw ex;
    } finally {
        // 4. Очищення контексту в ThreadLocal
        cleanupTransactionInfo(txInfo);
    }

    // 5. Фіксація транзакції при успішному поверненні
    commitTransactionAfterReturning(txInfo);
    return retVal;
}

CGLIB vs JDK Dynamic Proxy: генерація байткоду

Spring Boot починаючи з 2.x за замовчуванням використовує CGLIB-проксіювання (spring.aop.proxy-target-class=true):

  • JDK Dynamic Proxy: Створює проксі-клас через java.lang.reflect.Proxy. Вимагає обов’язкової наявності інтерфейсу. Не може проксіювати виклики до класів без інтерфейсів.
  • CGLIB (через ByteBuddy): Генерує байткод підкласу-нащадка на льоту (TargetService$$SpringCGLIB$$0 extends TargetService).
    • Не вимагає інтерфейсів.
    • Критичне обмеження: не може перевизначити методи з модифікатором final (оскільки Java забороняє перевизначення final-методів у нащадках). Якщо викликати final-метод, керування піде безпосередньо в оригінальний клас без виклику інтерцептора транзакцій.

Життєвий цикл з’єднання: Lazy Connection Acquisition

Багато розробників помилково вважають, що анотація @Transactional негайно фізично захоплює сокетне з’єднання з пулу HikariCP при вході в метод:

  1. За замовчуванням у зв’язці з JPA/Hibernate захоплення з’єднання відбувається ліниво (Lazy Connection Acquisition): фізичний конект із пулу запитується лише тоді, коли Hibernate надсилає перший реальний SQL-запит (Statement.execute()) або звертається до sequence генератора.
  2. Виняток: Якщо в анотації вказано нестандартний рівень ізоляції (наприклад, @Transactional(isolation = Isolation.REPEATABLE_READ) або SERIALIZABLE), Spring зобов’язаний захопити з’єднання негайно в точці входу в проксі, щоб виконати виклик connection.setTransactionIsolation(...).

Програмне керування: TransactionTemplate як альтернатива

У високонавантажених розподілених системах для максимального звуження меж транзакції Senior Engineers часто замінюють декларативну анотацію на TransactionTemplate:

package com.example.service;

import com.example.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;

@Service
@RequiredArgsConstructor
public class HighloadOrderService {

    private final TransactionTemplate transactionTemplate;
    private final OrderRepository orderRepository;
    private final ExternalPaymentClient paymentClient;

    public void processOrderWithExternalCall(Long orderId) {
        // 1. Важкий зовнішній мережевий виклик (БЕЗ утримання конекту до БД!)
        PaymentResponse response = paymentClient.executePayment(orderId);

        // 2. Коротка транзакція рівно на час оновлення запису в БД:
        transactionTemplate.execute(status -> {
            orderRepository.updateStatus(orderId, response.getStatus());
            return true;
        });
    }
}

4 Tricky Questions

1. Що станеться, якщо позначити метод @Transactional модифікатором final при стандартній конфігурації Spring Boot (CGLIB)?

Відповідь: Код успішно скомпільовано і застосунок успішно запуститься, проте транзакція відкрита не буде! При використанні CGLIB (ByteBuddy) Spring створює динамічний клас-нащадок TargetClass$$SpringCGLIB. Відповідно до специфікації віртуальної машини Java (JVM), метод із модифікатором final не може бути перевизначений у підкласі. У результаті CGLIB-проксі не може впровадити свій перехоплюючий байткод (MethodInterceptor), і виклик final-методу передається безпосередньо в оригінальний екземпляр класу. TransactionInterceptor ніколи не викликається, з’єднання з БД не відкривається, а зміни виконуються або в режимі autocommit, або призводять до TransactionRequiredException.


2. Чому в Spring анотація @Transactional за замовчуванням відкочує транзакцію тільки при RuntimeException та Error, але фіксує її при Checked Exception?

Відповідь: Це фундаментальне архітектурне рішення творця Spring Рода Джонсона, що спирається на філософію мови Java:

  • RuntimeException (unchecked): Сигналізує про непередбачену технічну або програмну аварію (NullPointerException, DataAccessException, баг в алгоритмі). У цьому випадку продовження роботи неможливе, транзакція вважається скомпрометованою і повинна бути відкочена.
  • Checked Exception (checked): Розглядається як штатний, очікуваний альтернативний результат бізнес-логіки (наприклад, InsufficientFundsException або UserNotFoundException). Передбачається, що викликаючий код зобов’язаний знати про такий результат, перехопити його і продовжити коректне виконання програми, зберігши попередні зміни. Якщо бізнес-вимоги вимагають відкату при будь-яких помилках, розробник зобов’язаний явно вказати: @Transactional(rollbackFor = Exception.class) або @Transactional(rollbackFor = Throwable.class).

3. У який фізичний момент часу запитується з’єднання з пулу HikariCP при вході в метод з @Transactional?

Відповідь: Це залежить від налаштувань ізоляції та DataSource:

  1. За замовчуванням (Isolation.DEFAULT): З’єднання запитується ліниво (Lazy Connection Acquisition) у момент першого реального звернення до бази даних (виконання першого SQL-запиту через JDBC або генерація sequence). До цього моменту метод може виконувати важкі обчислення в JVM без утримання сокета БД.
  2. При явній вказівці рівня ізоляції (isolation = Isolation.REPEATABLE_READ тощо): Spring зобов’язаний запросити фізичне з’єднання з пулу негайно в точці входу в AOP Proxy, оскільки для зміни рівня ізоляції необхідно виконати метод java.sql.Connection.setTransactionIsolation(...). Це знижує масштабованість при високому RPS, якщо метод довго готується перед відправкою запиту в БД.

4. Чому розміщення @Transactional на методі @RestController є критичною помилкою в Highload?

Відповідь: Це грубий архітектурний антипатерн, що призводить до швидкого виснаження пулу з’єднань (Connection Pool Exhaustion):

  1. Метод контролера утримує відкриту транзакцію і конект до БД протягом усієї фази формування HTTP-відповіді.
  2. Якщо клієнт завантажує відповідь через повільне мобільне з’єднання (slow client), або контролер серіалізує великий JSON-масив із 50 000 об’єктів, JDBC-з’єднання залишається заблокованим потоком контролера.
  3. 50–100 повільних клієнтів повністю паралізують пул HikariCP, змушуючи всі інші сервіси падати за ConnectionTimeout. Транзакції повинні відкриватися тільки на рівні сервісного шару (@Service) і жити мінімально можливий час.

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

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

«@Transactional — це інструмент декларативного керування транзакціями у Spring, реалізований через патерн AOP Proxy та перехоплювач TransactionInterceptor. При виклику методу проксі відкриває транзакцію через PlatformTransactionManager, виконує метод, фіксує його при успіху й відкочує при викиданні RuntimeException або Error. За замовчуванням анотація працює тільки на public-методах при зовнішньому виклику. Виклики через this оминають проксі. Checked-винятки за замовчуванням не відкочують транзакцію, якщо не задано атрибут rollbackFor = Exception.class».

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

  1. Механізм реалізації: Spring AOP Proxy (CGLIB за замовчуванням у Spring Boot).
  2. Обмеження проксі: Не працює на private та final методах, не працює при виклику всередині класу через this.
  3. Правило відкату за замовчуванням: Тільки RuntimeException та Error. Для checked винятків потрібен rollbackFor = Exception.class.
  4. Шар використання: Суворо @Service, ні в якому разі не @RestController.
  5. Thread-Safety: Метадані транзакції прив’язані до ThreadLocal, тому @Transactional не передається в дочірні потоки (@Async / CompletableFuture).

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

  • ❌ «@Transactional працює на private методах» (AOP Proxy перехоплює тільки public методи).
  • ❌ «Будь-який виняток у Java автоматично відкочує транзакцію» (Checked exceptions за замовчуванням фіксуються / комітяться).
  • ❌ «Якщо викликати метод цього ж сервісу через this, відкриється нова транзакція» (Виклик через this іде повз проксі).
  • ❌ «Можна ставити @Transactional на контролери» (Це антипатерн, що викликає pool exhaustion).

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