Вопрос 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 метода из другого метода того же класса]]