💳 Раздел 11 · Вопрос #17

На каком уровне можно использовать @Transactional

Аннотацию @Transactional можно размещать на трех основных уровнях кода:


🟢 Junior Level

Аннотацию @Transactional можно размещать на трех основных уровнях кода:

  1. Над отдельным методом (рекомендуемый промышленный стандарт).
  2. Над классом (применяется ко всем public-методам класса).
  3. Над интерфейсом (устаревший подход, не рекомендуемый в современном Spring Boot).

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

  • Уровень метода: Обеспечивает максимальную гранулярность и контроль. Каждый метод получает свои индивидуальные настройки таймаута, уровня изоляции, режима readOnly и правил отката.
  • Уровень класса: Удобен для однородных сервисов (например, сервиса справочников, где все методы — только чтение). Настройки класса действуют как шаблон по умолчанию для всех открытых методов.
  • Уровень интерфейса: ⚠️ Опасная ловушка! В современном Spring Boot по умолчанию используется CGLIB-проксирование классов, при котором аннотации с интерфейсов не считываются и молча игнорируются.
Где размещать @Transactional в слоеной архитектуре:

[ Controller / RestController ]  ---> ❌ НИКОГДА (Антипаттерн! Удерживает коннект при I/O)
             |
             v
[ Service Layer ]                --->  СТРОГО ЗДЕСЬ (Уровень бизнес-логики и транзакционных границ)
             |
             v
[ Repository Layer ]             ---> ⚠️ Только для кастомных @Modifying UPDATE/DELETE

Пример приоритета: метод переопределяет класс

package com.example.service;

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

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 1. Базовый шаблон для всего класса: режим чтения
public class UserService {

    private final UserRepository userRepository;

    // Унаследует readOnly = true из аннотации класса:
    public User findById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("Пользователь не найден"));
    }

    // 2. Метод ПЕРЕОПРЕДЕЛЯЕТ настройки класса: открывается транзакция на запись!
    @Transactional(readOnly = false)
    public void updateUserEmail(Long id, String newEmail) {
        User user = findById(id);
        user.setEmail(newEmail);
        userRepository.save(user);
    }
}

🟡 Middle Level

Иерархия разрешения аннотаций (Resolution Order)

Когда Spring AOP перехватывает вызов метода, класс AbstractFallbackTransactionAttributeSource ищет метаданные @Transactional по строгой цепочке приоритетов от частного к общему:

Порядок поиска TransactionAttribute:
1. Метод реализации целевого класса (Target class method)        [Высший приоритет]
   └── Если найдено -> ИСПОЛЬЗОВАТЬ
2. Целевой класс реализации (Target class)
   └── Если найдено -> ИСПОЛЬЗОВАТЬ
3. Метод интерфейса (Interface method)
   └── Работает только с JDK Dynamic Proxy!
4. Сам интерфейс (Interface)                                    [Низший приоритет]
   └── Работает только с JDK Dynamic Proxy!

Важнейшее правило: Аннотация на уровне метода всегда полностью переопределяет аннотацию на уровне класса.

Сравнение уровней размещения

Уровень размещения Плюсы Минусы / Риски Рекомендация
Метод сервиса Максимальная точность; разделение read/write; безопасный таймаут Чуть больше аннотаций в коде Best Practice (100% рекомендация)
Класс сервиса Меньше дублирования в чисто CRUD сервисах Легко случайно открыть транзакцию записи на «тяжелом» запросе Допустимо с readOnly = true
Интерфейс Чистота классов реализации Игнорируется CGLIB-прокси в Spring Boot! ❌ Категорически избегать
Контроллер Никаких Блокировка пула коннектов при сетевом I/O; N+1 при сериализации JSON ❌ Грубейший антипаттерн
Репозиторий Spring Data автоматически ставит readOnly = true Ограничен одиночным запросом; не связывает бизнес-транзакцию Только для @Modifying DML

Почему @Transactional на контроллере убивает Highload

Размещение аннотации над методом @RestController или @Controller:

  1. Удержание физического соединения: Поток Tomcat захватывает сокет БД еще до валидации входящего тела запроса и удерживает его на протяжении всей сериализации DTO в JSON по HTTP.
  2. Атака медленного клиента (Slowloris): Если мобильный клиент скачивает 5-мегабайтный JSON-ответ через плохую сеть 3G в течение 15 секунд, сокет базы данных в пуле HikariCP остается заблокированным все эти 15 секунд!
  3. Исчерпание пула: Всего 30–50 параллельных медленных запросов полностью утилизируют весь пул соединений микросервиса, приводя к массовому ConnectionTimeoutException.

🔴 Senior Level

Проблема отсутствия слияния атрибутов (No Merging of Attributes)

Одна из самых коварных ловушек для Middle/Senior разработчиков:

В Spring Framework атрибуты аннотации @Transactional НЕ объединяются и не наследуются по частям! Аннотация метода ПОЛНОСТЬЮ заменяет метаданные аннотации класса.

@Service
@Transactional(
    readOnly = true, 
    rollbackFor = Exception.class, 
    timeout = 10
)
public class PaymentProcessingService {

    // ЛОВУШКА!
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processPayment() {
        // Разработчик ожидает:
        // - propagation = REQUIRES_NEW
        // - readOnly = false (по умолчанию)
        // - rollbackFor = Exception.class (унаследовано от класса?? НЕТ!)
        // - timeout = 10 (унаследовано от класса?? НЕТ!)
        
        // ФАКТИЧЕСКИЙ РЕЗУЛЬТАТ:
        // rollbackFor сбросился в RuntimeException.class!
        // timeout сбросился в -1 (без таймаута)!
    }
}

Если метод сервиса выбросит проверяемое исключение PaymentCheckedException, метод processPayment будет закоммичен, потому что настройка rollbackFor = Exception.class с уровня класса была затерта дефолтными значениями аннотации метода!

CGLIB vs JDK Dynamic Proxy: почему интерфейсы не работают

В Spring Boot по умолчанию включен параметр:

spring.aop.proxy-target-class=true

Это означает, что Spring Boot использует CGLIB (через библиотеку ByteBuddy), генерируя подкласс реального бина: OrderServiceImpl$$SpringCGLIB$$0 extends OrderServiceImpl.

  1. Спецификация языка Java (JLS §9.6.4.3) гласит: аннотации интерфейсов никогда не наследуются классами, реализующими эти интерфейсы, даже при наличии мета-аннотации @Inherited!
  2. CGLIB генерирует класс-наследник от OrderServiceImpl, но в байткоде OrderServiceImpl аннотации @Transactional физически нет (она осталась на интерфейсе OrderService).
  3. При поиске метаданных через рефлексию CGLIB-прокси видит «чистый» класс без транзакционных атрибутов.
  4. Итог: Никакой ошибки при сборке или старте приложения не возникает, но транзакция в рантайме попросту не открывается, изменения выполняются в режиме autocommit, а откат при ошибках не происходит!

Взаимодействие с Open Session in View (OSIV)

По умолчанию в Spring Boot включен механизм spring.jpa.open-in-view=true:

  • Фильтр OpenSessionInViewFilter перехватывает HTTP-запрос и открывает сессию Hibernate на уровне сервлета.
  • Если разработчик размещает @Transactional на уровне сервиса, физическая транзакция открывается и коммитится строго внутри сервисного метода.
  • Однако при выходе из сервиса в контроллер сессия Hibernate остается открытой, что позволяет сериализатору Jackson обращаться к lazy-полям сущностей (провоцируя шквал скрытых запросов N+1 прямо во время формирования JSON-ответа).
  • Senior Best Practice: В высоконагруженных системах open-in-view всегда принудительно отключают (spring.jpa.open-in-view=false), а все необходимые графы связей загружают строго в сервисных методах с @Transactional(readOnly = true) через JOIN FETCH или Entity Graph.

4 Tricky Questions

1. Что произойдет, если в сервисе с @Transactional(readOnly = true) на уровне класса вызвать метод репозитория с аннотацией @Modifying?

Ответ: Вызов завершится критической ошибкой во время выполнения:

  • В PostgreSQL: org.postgresql.util.PSQLException: ERROR: cannot execute UPDATE in a read-only transaction.
  • Либо на уровне Spring: InvalidDataAccessApiUsageException: Executing an update/delete query... TransactionRequiredException. Причина: По правилу Propagation.REQUIRED метод репозитория входит в уже существующую транзакцию, созданную сервисом. Поскольку сервис выставил режим readOnly = true, Spring и драйвер БД перевели сессию в режим только для чтения (SET TRANSACTION READ ONLY). Для разрешения записи метод сервиса обязан явно переопределить параметр: @Transactional(readOnly = false).

2. Почему аннотация @Transactional на интерфейсе сервиса может прекрасно работать в старых проектах на Spring Framework 3/4, но перестает работать в Spring Boot 2/3?

Ответ: В старых версиях Spring Framework по умолчанию использовались стандартные JDK Dynamic Proxies (если бин реализовывал хотя бы один интерфейс). JDK-прокси строится вокруг интерфейса и исследует его методы, поэтому видит аннотацию на интерфейсе. Начиная со Spring Boot 2.0 (и далее в 3.x), стратегия по умолчанию была изменена на spring.aop.proxy-target-class=true (принудительный CGLIB). CGLIB строит прокси на основе наследования класса реализации (extends ServiceImpl). Так как Java не наследует аннотации с интерфейсов на реализующие их классы, CGLIB-прокси не видит @Transactional на интерфейсе и не открывает транзакцию.

3. Смержатся ли атрибуты timeout и rollbackFor между классом и методом, если на классе указан timeout = 5, а на методе — rollbackFor = Exception.class?

Ответ: Нет, мерджинга не произойдет! Spring использует принцип полного переопределения (full override). Если аннотация обнаружена на уровне метода, Spring полностью игнорирует аннотацию на уровне класса. Для этого метода применятся:

  • rollbackFor = Exception.class (из метода);
  • timeout = -1 (значение по умолчанию в аннотации метода! Настройка timeout = 5 с уровня класса будет потеряна).

4. В чем разница между вызовом @Transactional(readOnly = true) на уровне сервиса и вызовом метода из стандартного Spring Data репозитория, где уже стоит readOnly = true?

Ответ:

  • На уровне репозитория: Каждый метод Spring Data JPA (SimpleJpaRepository) изолированно открывает короткую транзакцию чтения ровно на время выполнения одиночного SQL-запроса SELECT.
  • На уровне сервиса: Транзакция охватывает весь бизнес-метод целиком. Это дает три фундаментальных преимущества:
    1. Несколько связанных SELECT гарантированно видят согласованное состояние базы в рамках единого снимка (на уровнях Repeatable Read или в связке L1-кэша);
    2. Доступ к lazy-инициализированным коллекциям сущностей (LazyInitializationException) возможен на протяжении всего метода сервиса;
    3. Hibernate отключает механизм отслеживания грязных сущностей (Dirty Checking), экономя память и CPU на вычислении diff-массивов при выходе из метода.

🎯 Шпаргалка для интервью

30-секундный ответ

«Аннотацию @Transactional можно размещать на уровне метода, класса или интерфейса. Золотой стандарт индустрии — размещение на уровне методов сервисного слоя (@Service). Аннотация на методе всегда полностью переопределяет аннотацию класса (при этом параметры не объединяются!). Размещение на интерфейсах — опасный антипаттерн, так как Spring Boot по умолчанию использует CGLIB-прокси, которые игнорируют аннотации интерфейсов. Размещение на контроллерах строго запрещено из-за риска блокировки пула соединений БД при сетевом вводе-выводе».

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

  1. Рекомендуемый уровень: Публичные методы сервисного слоя.
  2. Приоритет поиска: Метод реализации $\rightarrow$ Класс реализации $\rightarrow$ Метод интерфейса $\rightarrow$ Интерфейс.
  3. Нет объединения атрибутов: Если аннотация стоит на методе, все неуказанные параметры сбрасываются в значения по умолчанию, а не берутся из аннотации класса.
  4. CGLIB vs Interfaces: Аннотации на интерфейсах не видны CGLIB-прокси.
  5. Антипаттерн Controller: Удерживает коннект к БД во время передачи HTTP-трафика и сериализации JSON.

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

  • ❌ «Лучше всего ставить @Transactional на интерфейс сервиса для слабой связанности» (В Spring Boot с CGLIB транзакции просто не откроются).
  • ❌ «Если указать timeout на классе, он автоматически применится ко всем методам с @Transactional» (Если над методом стоит @Transactional, классовый таймаут затирается дефолтным).
  • ❌ «@Transactional на контроллере — это удобно, чтобы не писать DTO и отдавать lazy-сущности прямо в JSON» (Это путь к Connection Pool Exhaustion и деградации базы данных).

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