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

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

@Transactional — це анотація Spring, яка дозволяє управляти транзакціями декларативно. Замість ручного BEGIN/COMMIT/ROLLBACK ви просто ставите анотацію — Spring робить все автом...

Мовні версії: English Russian Ukrainian

🟢 Junior Level

@Transactional — це анотація Spring, яка дозволяє управляти транзакціями декларативно. Замість ручного BEGIN/COMMIT/ROLLBACK ви просто ставите анотацію — Spring робить все автоматично.

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

@Service
public class UserService {

    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
        // Spring автоматично зробить COMMIT при успіху
        // або ROLLBACK при RuntimeException
    }
}

Аналогія

Уявіть банківський переказ: ви або переказуєте гроші цілком, або не переказуєте взагалі. @Transactional гарантує, що половина переказу не “зависне” в базі.

Ключові параметри

Параметр Що робить Значення за замовчуванням
propagation Як транзакція пов’язана з іншими REQUIRED
isolation Рівень ізоляції DEFAULT (рівень БД)
readOnly Оптимізація для читання false
rollbackFor Які винятки викликають відкат RuntimeException, Error
timeout Таймаут в секундах -1 (немає таймауту)

Коли використовувати

Будь-який метод, який виконує кілька операцій з БД, що мають бути атомарними: перекази грошей, створення замовлення з позиціями, оновлення пов’язаних сутностей.

Важливі правила

  • Працює тільки на public методах
  • Працює тільки при виклику ззовні класу (не через this)
  • За замовчуванням відкочує тільки при RuntimeException та Error

🟡 Middle Level

Як працює: AOP Proxy механізм

Зовнішній виклик → Proxy → TransactionInterceptor → PlatformTransactionManager
                                                       ↓
                                              Відкрити транзакцію → Виконати метод → COMMIT/ROLLBACK
  1. Spring створює Proxy-об’єкт навколо біна (JDK Dynamic Proxy або CGLIB)
  2. При виклику методу управління потрапляє в TransactionInterceptor
  3. Перехоплювач звертається до PlatformTransactionManager, який відкриває з’єднання і починає транзакцію
  4. Виконується ваш метод
  5. При успіху — commit, при винятку — rollback

Де можна розміщувати анотацію

Рівень Працює? Рекомендація
Метод ✅ Так Найкращий варіант — точний контроль
Клас ✅ Так Всі public методи транзакційні
Інтерфейс ⚠️ Залежить Тільки з JDK Dynamic Proxy
Private метод ❌ Ні Proxy не перехоплює

Приклад з параметрами

@Transactional(
    propagation = Propagation.REQUIRED,
    isolation = Isolation.READ_COMMITTED,
    timeout = 30,
    readOnly = false,
    rollbackFor = Exception.class,
    noRollbackFor = NotFoundException.class
)
public void processOrder(Order order) { ... }

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

Помилка Що відбувається Як виправити
@Transactional на private методі Анотація ігнорується Зробити метод public
Виклик this.method() всередині класу Proxy bypassed, немає транзакції Винести в окремий бін або self-injection
Checked Exception без rollbackFor COMMIT замість ROLLBACK Додати rollbackFor = Exception.class
@Transactional на Controller З’єднання з БД тримається при серіалізації JSON Перенести на Service шар
Catch і swallow винятку Spring не бачить виняток → COMMIT Перекинути виняток або викликати setRollbackOnly()

Коли НЕ використовувати

Ситуація Чому не потрібно Альтернатива
Метод тільки читає один запис Зайва складність readOnly = true або без транзакції
HTTP-виклик всередині метода Тримає з’єднання з БД unnecessarily Винести HTTP-виклик за транзакцію
Логування/аудит Не вимагає атомарності з бізнес-логікою REQUIRES_NEW в окремому біні
Фонова обробка повідомлень Може вимагати іншої стратегії Programmatic transactions

Порівняння: декларативний vs програмний підхід

Підхід Плюси Мінуси Коли використовувати
@Transactional Чистий код, декларативність Обмежений proxy Стандартні сценарії
TransactionTemplate Повний контроль, працює всередині this Більше boilerplate Умовна логіка транзакцій

🔴 Senior Level

Spring Transaction AOP — Internal Architecture

Proxy Creation Flow

// InfrastructureAdvisorAutoProxyCreator (BeanPostProcessor)
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
    if (isInfrastructureClass(bean.getClass())) return bean;

    if (AnnotationUtils.findAnnotation(bean.getClass(), Transactional.class) != null
        || hasTransactionalMethods(bean.getClass())) {

        ProxyFactory proxyFactory = new ProxyFactory(bean);
        proxyFactory.addAdvice(new TransactionInterceptor(tm, transactionAttributeSource));
        return proxyFactory.getProxy();
    }
    return bean;
}

TransactionInterceptor Execution

public Object invoke(MethodInvocation invocation) throws Throwable {
    Class<?> targetClass = AopProxyUtils.ultimateTargetClass(invocation.getThis());

    TransactionAttribute txAttr = tas.getTransactionAttribute(
        invocation.getMethod(), targetClass);

    PlatformTransactionManager tm = determineTransactionManager(txAttr);
    TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, invocation.getMethod().toString());

    Object retVal;
    try {
        retVal = invocation.proceed();
    } catch (Throwable ex) {
        completeTransactionAfterThrowing(txInfo, ex);
        throw ex;
    } finally {
        cleanupTransactionInfo(txInfo);
    }

    commitTransactionAfterReturning(txInfo);
    return retVal;
}

JDK Dynamic Proxy vs CGLIB

Характеристика JDK Dynamic Proxy CGLIB
Механізм Реалізує інтерфейси Наслідує клас
Швидкість створення Швидше (~5-10μs) Повільніше (~20-50μs)
Overhead виклику Низький Трохи вищий
@Transactional на інтерфейсі ✅ Працює ❌ Ігнорується
Final методи ✅ Не проблема ❌ Не можна перевизначити
Пам’ять Менше Більше (генерує клас)
// JDK Dynamic Proxy
UserService proxy = (UserService) Proxy.newProxyInstance(
    classLoader,
    new Class[]{UserService.class},
    handler
);

// CGLIB
UserService proxy = (UserService) Enhancer.create(UserService.class, handler);

Spring Boot за замовчуванням використовує CGLIB якщо немає інтерфейсів. Можна форсувати: @EnableTransactionManagement(proxyTargetClass = true).

Thread-Local Transaction State

public abstract class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources");
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal<>("Transaction synchronizations");
    private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal<>("Current transaction name");
    private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal<>("Current transaction read-only status");
    private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal<>("Current transaction isolation level");
    private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal<>("Actual transaction active");
}

Саме тому @Transactional не працює між потоками при стандартній конфігурації — ThreadLocal не передається. @Async методи отримують окремий transaction context. При використанні кастомних TaskExecutor з ThreadLocal propagation або Virtual Threads (Java 21+) можливе пробрасывание контексту, але це вимагає явної настройки.

Edge Cases (мінімум 3)

  1. UnexpectedRollbackException: В ланцюжку REQUIRED → REQUIRED внутрішній метод кинув RuntimeException. Зовнішній перехопив, але TransactionInterceptor вже виставив rollback-only флаг. При спробі commit — викидається UnexpectedRollbackException.

  2. @Transactional на final методі: CGLIB не може перевизначити final метод → анотація silently ignored. JDK Proxy теж не перехоплює final методи інтерфейсу.

  3. Наслідування та @Transactional: Якщо підклас перевизначає метод без повторення анотації — поведінка залежить від типу proxy. З CGLIB анотація суперкласу може не виявитися.

  4. Кілька TransactionManager: Якщо в застосунку два PlatformTransactionManager, @Transactional без qualifier використовує @Primary. Виклик двох різних методів з різними менеджерами = дві незалежні транзакції, не розподілена транзакція. Потрібен JTA/Atomikos для 2PC. JTA (Java Transaction API) та Atomikos (реалізація JTA). 2PC (Two-Phase Commit) — протокол для атомарного комміту транзакцій на кількох ресурсах.

Performance Numbers

Операція Час
Proxy invocation overhead ~1-2 μs
Transaction setup (Spring side) ~5-10 μs
Connection acquisition (HikariCP) ~50-200 μs
Typical DB query 1-10 ms

Overhead Spring — нехтувано малий. Домінує робота з БД.

Memory Implications

Кожна активна транзакція утримує:

  • JDBC Connection з пула (~2-4 KB)
  • Hibernate L1 cache (Persistence Context) — росте з кількістю завантажених entities
  • ThreadLocal entries в TransactionSynchronizationManager
  • Undo log в БД (чим довша транзакція, тим більше)

Довгі транзакції = exhaustion пула з’єднань + bloat в БД.

Thread Safety

@Transactional не thread-safe сам по собі. Кожен потік отримує:

  • Окрему транзакцію
  • Окреме з’єднання з БД
  • Окремий Hibernate Session

Сервісний бін — singleton. Але стан транзакції зберігається в ThreadLocal, тому конкурентні виклики одного @Transactional метода ізольовані.

Production War Story

В production сервіс з @Transactional ловив checked BusinessException. Розробник не вказав rollbackFor. Транзакція робила COMMIT, але частина даних вже була записана. Клієнт отримував помилку, але половина замовлення залишалася в базі. Рішення: rollbackFor = Exception.class на всіх write-методах + інтеграційні тести на rollback поведінку.

Чому Spring закоммітив: за замовчуванням rollback тільки для RuntimeException + Error. Checked exception (наслідується від Exception, але не від RuntimeException) сприймається TransactionInterceptor як «нормальне завершення» → commit. Навіть якщо метод кинув виняток, Spring не бачить його як сигнал до відкату — бо виняток перехоплено в catch-блоці, а rollbackFor не вказано.

Monitoring

Spring Boot Debug Logging:

logging:
  level:
    org.springframework.transaction: DEBUG
    org.springframework.orm.jpa: DEBUG

Actuator Metrics:

@Bean
public TransactionManagerCustomizers transactionManagerCustomizers(MeterRegistry registry) {
    return new TransactionManagerCustomizers(tm -> {
        // Custom metrics
    });
}

Micrometer:

spring.datasource.connections.active
spring.datasource.connections.idle

Highload Best Practices

  1. Мінімальний scope транзакції: Тільки DB-операції. HTTP-виклики, файловий I/O — за межами.
  2. readOnly для читання: Вимикає dirty checking (Hibernate порівнює entities з snapshot-ом при flush; при readOnly = true цей механізм вимкнено). Може роутити на read-репліку.
  3. Connection pool tuning: HikariCP maximumPoolSize = CPU cores * 2 + disk spindles (формула-орієнтир від творця HikariCP. Для SSD «disk spindles» = 1. Starting point — 10-20, потім tune під навантаження).
  4. Avoid long transactions: Розбивайте batch-операції на чанки.
  5. Use REQUIRES_NEW для side-effects: Логування, нотифікації — не повинні блокувати основну транзакцію.
  6. Consider programmatic transactions: Для складної умовної логіки TransactionTemplate гнучкіший.

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

Обов’язково знати:

  • @Transactional — декларативне управління транзакціями через AOP Proxy (JDK Dynamic Proxy або CGLIB)
  • Працює тільки на public методах і тільки при виклику ззовні класу (не через this)
  • За замовчуванням rollback тільки для RuntimeException та Error, checked exceptions → commit
  • Параметри: propagation (REQUIRED), isolation (DEFAULT), readOnly (false), timeout (-1), rollbackFor
  • Стан зберігається в ThreadLocal — не працює across threads (@Async = окремий context)
  • @Transactional на інтерфейсі працює тільки з JDK Dynamic Proxy, ігнорується з CGLIB

Часті уточнюючі запитання:

  • Чому @Transactional не працює при виклику через this? — Proxy bypassed, TransactionInterceptor не бере участі
  • Чим JDK Proxy відрізняється від CGLIB? — JDK: interface-based, CGLIB: subclass-based (final методи не перехоплюються)
  • Що буде якщо @Transactional на Controller? — З’єднання з БД тримається при серіалізації JSON — anti-pattern
  • Як працює UnexpectedRollbackException? — Inner REQUIRED виставив rollback-only, outer намагається commit

Червоні прапорці (НЕ говорити):

  • “@Transactional на private методі працює” — proxy не перехоплює private методи
  • “Checked exception викликає rollback” — за замовчуванням Spring робить commit
  • “Можна ставити на Controller” — connection held during JSON serialization, pool exhaustion

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

  • [[13. Що таке Propagation в Spring]]
  • [[17. На якому рівні можна використовувати @Transactional]]
  • [[19. Які винятки за замовчуванням викликають rollback]]
  • [[22. Що станеться при виклику @Transactional методу з іншого методу того ж класу]]