💳 Розділ 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 та деградації бази даних).

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