На якому рівні можна використовувати @Transactional
Анотацію @Transactional можна розміщувати на трьох основних рівнях коду:
🟢 Junior Level
Анотацію @Transactional можна розміщувати на трьох основних рівнях коду:
- Над окремим методом (рекомендований промисловий стандарт).
- Над класом (застосовується до всіх
public-методів класу). - Над інтерфейсом (застарілий підхід, не рекомендований у сучасному 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:
- Утримання фізичного з’єднання: Потік Tomcat захоплює сокет БД ще до валідації вхідного тіла запиту й утримує його протягом усієї серіалізації DTO в JSON по HTTP.
- Атака повільного клієнта (Slowloris): Якщо мобільний клієнт завантажує 5-мегабайтну JSON-відповідь через погану мережу 3G протягом 15 секунд, сокет бази даних у пулі HikariCP залишається заблокованим усі ці 15 секунд!
- Виснаження пулу: Лише 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.
- Специфікація мови Java (JLS §9.6.4.3) стверджує: анотації інтерфейсів ніколи не успадковуються класами, що реалізують ці інтерфейси, навіть за наявності мета-анотації
@Inherited! - CGLIB генерує клас-нащадок від
OrderServiceImpl, але в байткодіOrderServiceImplанотації@Transactionalфізично немає (вона залишилася на інтерфейсіOrderService). - Під час пошуку метаданих через рефлексію CGLIB-проксі бачить «чистий» клас без транзакційних атрибутів.
- Підсумок: Жодної помилки при збірці або старті застосунку не виникає, але транзакція в рантаймі просто не відкривається, зміни виконуються в режимі 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. - На рівні сервісу: Транзакція охоплює весь бізнес-метод цілком. Це дає три фундаментальні переваги:
- Кілька пов’язаних
SELECTгарантовано бачать узгоджений стан бази в межах єдиного знімка (на рівняхRepeatable Readабо у зв’язці L1-кешу); - Доступ до lazy-ініціалізованих колекцій сутностей (
LazyInitializationException) можливий протягом усього методу сервісу; - Hibernate вимикає механізм відстеження брудних сутностей (Dirty Checking), заощаджуючи пам’ять і CPU на обчисленні diff-масивів при виході з методу.
- Кілька пов’язаних
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
«Анотацію
@Transactionalможна розміщувати на рівні методу, класу або інтерфейсу. Золотий стандарт індустрії — розміщення на рівні методів сервісного шару (@Service). Анотація на методі завжди повністю перевизначає анотацію класу (при цьому параметри не об’єднуються!). Розміщення на інтерфейсах — небезпечний антипатерн, оскільки Spring Boot за замовчуванням використовує CGLIB-проксі, які ігнорують анотації інтерфейсів. Розміщення на контролерах суворо заборонено через ризик блокування пулу з’єднань БД при мережевому ввіді-виводі».
Чек-лист ключових понять
- Рекомендований рівень: Публічні методи сервісного шару.
- Пріоритет пошуку: Метод реалізації $\rightarrow$ Клас реалізації $\rightarrow$ Метод інтерфейсу $\rightarrow$ Інтерфейс.
- Немає об’єднання атрибутів: Якщо анотація стоїть на методі, усі невказані параметри скидаються в значення за замовчуванням, а не беруться з анотації класу.
- CGLIB vs Interfaces: Анотації на інтерфейсах не видно CGLIB-проксі.
- Антипатерн Controller: Утримує конект до БД під час передачі HTTP-трафіку та серіалізації JSON.
Червоні прапорці (чого ні в якому разі не можна говорити)
- ❌ «Найкраще ставити @Transactional на інтерфейс сервісу для слабкої зв’язності» (У Spring Boot із CGLIB транзакції просто не відкриються).
- ❌ «Якщо вказати timeout на класі, він автоматично застосується до всіх методів з @Transactional» (Якщо над методом стоїть
@Transactional, класовий таймаут затирається дефолтним). - ❌ «@Transactional на контролері — це зручно, щоб не писати DTO і віддавати lazy-сутності прямо в JSON» (Це шлях до Connection Pool Exhaustion та деградації бази даних).