💳 Раздел 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 Пробрасывать исключение или вызывать TestTransaction.flagForRollback()
@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).

Ссылки на связанные темы