🍃 Раздел 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. Если метод завершился успешно → connection.commit().
  5. Если метод выбросил RuntimeException или Error → 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). Откат вложенной части происходит до точки сохранения без отката всей внешней транзакции.

  3. Как использование REQUIRES_NEW может положить сервер при росте нагрузки? Ответ: Каждый вызов REQUIRES_NEW удерживает сразу два физических соединения из пула (одно заморожено во внешней транзакции, второе активно во внутренней). Если пул соединений ограничен, а число параллельных потоков равно размеру пула, наступает взаимная блокировка (Deadlock пула соединений): все потоки ждут второе соединение, удерживая первое.

  4. Что дает флаг readOnly = true в Hibernate? Ответ: Hibernate отключает механизм отслеживания грязных сущностей (Dirty Checking) и переводит сессию в режим FlushMode.MANUAL. Hibernate не сохраняет копии снимков сущностей в памяти, что значительно снижает потребление RAM и нагрузку на CPU при выборке больших объемов данных.

Красные флаги (чего категорически нельзя говорить)

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

Связанные темы