Чому @Transactional не працює при self-invocation
У Spring при такому виклику анотація @Transactional (а також @Async, @Cacheable тощо) повністю ігнорується, і транзакція не відкривається.
🟢 Junior Level
Self-invocation (само-виклик) — це ситуація, коли один метод класу викликає інший метод того самого класу напряму через ключове слово this (явне або неявне).
У Spring при такому виклику анотація @Transactional (а також @Async, @Cacheable тощо) повністю ігнорується, і транзакція не відкривається.
Наочний приклад дефекту
package com.example.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
// Зовнішній метод БЕЗ транзакції
public void processOrder(Long orderId) {
System.out.println("Крок 1: Підготовка замовлення");
// Внутрішній виклик (Self-invocation): еквівалентно this.saveToDatabase()
saveToDatabase(orderId); // ❌ ПОМИЛКА: @Transactional НЕ СПРАЦЮЄ!
}
// Метод, що вимагає транзакції
@Transactional
public void saveToDatabase(Long orderId) {
System.out.println("Крок 2: Запис у базу даних БЕЗ транзакції!");
// SQL-запити виконаються в режимі autocommit=true
}
}
Чому це відбувається: Проста аналогія
Уявіть, що транзакційний проксі — це секретар на вході до кабінету директора, який перевіряє документи та реєструє відвідувачів. Коли клієнт звертається до сервісу «ззовні», він обов’язково проходить через секретаря (проксі). Але коли директор усередині свого кабінету бере ручку зі свого столу, секретар у цьому участі не бере — дія відбувається напряму всередині об’єкта.
🟡 Middle Level
Архітектурна причина: JVM invokevirtual та обхід AOP-проксі
Spring AOP базується на патерні Proxy (Заступник). Бін, що впроваджується в інші компоненти, — це не оригінальний екземпляр класу, а згенерований динамічний проксі-об’єкт.
ЗОВНІШНІЙ ВИКЛИК (Працює штатно):
[ Client ] ──> [ OrderService Proxy ] ──> [ TransactionInterceptor ] ──> [ Target OrderService ]
(Відкриває транзакцію) (saveToDatabase)
САМО-ВИКЛИК (Self-Invocation — НЕ працює):
[ Client ] ──> [ OrderService Proxy ]
│
▼
[ Target OrderService ]
│
└──> processOrder()
│
└── (this.saveToDatabase()) ──> повз Proxy!
- Клієнт викликає
orderService.processOrder(). - Виклик перехоплюється проксі. Оскільки на методі
processOrder()немає@Transactional, проксі просто передає виклик реальному об’єктуTarget. - Усередині методу
processOrder()виконується викликsaveToDatabase(). - На рівні байт-коду JVM виконується інструкція
invokevirtualза посиланнямthis. - Посилання
thisвказує на реальний цільовий об’єкт у пам’яті, а не на проксі-обгортку. - Виклик потрапляє напряму у vtable цільового об’єкта, оминаючи
TransactionInterceptor. Транзакція не відкривається!
Чому CGLIB не рятує ситуацію
Часта помилкова думка розробників: «CGLIB створює підклас, отже, перевизначає метод і перехоплює всі виклики».
Це не так:
- CGLIB генерує клас-нащадок:
OrderService$$SpringCGLIB$$0 extends OrderService. - У проксі перевизначено метод
saveToDatabase(), що містить звернення доMethodInterceptor. - Але реальний бізнес-код методу
processOrder()виконується в контексті оригінального екземпляра суперкласу. - Коли всередині
processOrder()викликаєтьсяthis.saveToDatabase(), посиланняthisвсередині методу батька вказує на самого себе, а не на інтерфейс перехоплювача CGLIB.
Які ще анотації не працюють при Self-Invocation
Проблема само-виклику актуальна для абсолютно всіх AOP-анотацій Spring:
| Анотація | Наслідок при self-invocation |
|---|---|
@Transactional |
Дані зберігаються без транзакції; стратегія REQUIRES_NEW ігнорується |
@Async |
Метод виконується синхронно у викликаючому потоці |
@Cacheable / @CacheEvict |
Кеш ігнорується, повторний виклик завжди йде в базу даних |
@PreAuthorize / @Secured |
Перевірка прав доступу пропускається (критична вразливість безпеки!) |
@Retryable |
При виникненні винятку повторні спроби не виконуються |
@Validated |
Валідація аргументів методу не активується |
🔴 Senior Level
Спотворення параметрів Propagation при Self-Invocation
Якщо зовнішній метод outer() вже анотований @Transactional, а внутрішній метод inner() позначений специфічною стратегією розповсюдження:
@Service
public class PaymentService {
@Transactional // Транзакція 1 (REQUIRED)
public void processPayment() {
// ... списання коштів ...
try {
logAuditInfo(); // Self-invocation!
} catch (Exception ex) {
// Намагаємося приховати помилку аудиту, щоб платіж пройшов
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditInfo() {
// ПОМИЛКА: REQUIRES_NEW НЕ СПРАЦЮЄ!
// Метод виконається в межах ТРАНЗАКЦІЇ 1, а не в новій транзакції!
auditRepo.save(new AuditRecord());
}
}
Наслідки в Production:
- Розробник очікує, що
logAuditInfo()виконається в окремій транзакції і збережеться, навіть якщо основний платіж впаде, або його помилка не поламає платіж. - У реальності через self-invocation метод
logAuditInfo()виконується у фізичному контексті зовнішньої транзакції. - Якщо
logAuditInfo()падає з помилкою, сесія позначається якRollback Only. Зовнішня транзакція відкочується, незважаючи на блокtry-catch!
Програмна діагностика в рантаймі
Щоби переконатися, чи відкрита реальна транзакція Spring у поточній точці виконання, використовують статичний клас TransactionSynchronizationManager:
import org.springframework.transaction.support.TransactionSynchronizationManager;
public void saveToDatabase(Long orderId) {
boolean isTxActive = TransactionSynchronizationManager.isActualTransactionActive();
String currentTxName = TransactionSynchronizationManager.getCurrentTransactionName();
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
System.out.println("Транзакція активна? " + isTxActive);
System.out.println("Ім'я транзакції: " + currentTxName);
}
Якщо метод викликано через self-invocation із не-транзакційного методу, isActualTransactionActive() поверне false.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
Self-invocation — це виклик методу того самого класу через неявний або явний покажчик
this. У Spring AOP наскрізна функціональність (@Transactional,@Async,@Cacheable,@PreAuthorize) реалізована через динамічні проксі (CGLIB / JDK). Проксі перехоплює виклики лише із зовнішніх об’єктів. При self-invocation інструкція байт-кодуinvokevirtualвиконується безпосередньо на об’єктіTarget, оминаючи ланцюжокMethodInterceptor. У результаті анотація@Transactionalігнорується, і код виконується без транзакції (або параметриREQUIRES_NEWне враховуються).
4 каверзних питання з відповідями
1. Що станеться, якщо метод без транзакції викличе всередині свого класу метод з @Transactional(propagation = Propagation.REQUIRES_NEW)?
Відповідь: Транзакція не відкриється взагалі. Виклик піде напряму в оригінальний метод в обхід проксі. Метод виконається без транзакційного контексту, у режимі авто-фіксації (autocommit=true) JDBC-з’єднання.
2. Чому CGLIB-проксі не перехоплює виклики всередині класу, якщо він успадковує цільовий клас?
Відповідь: CGLIB створює підклас, що перевизначає методи з делегуванням в інтерцептори. Проте бізнес-код викликаючого методу виконується всередині цільового об’єкта. Покажчик this посилається на екземпляр цільового класу, і виклик другого методу адресується безпосередньо його таблиці методів (vtable), не потрапляючи в згенеровані методи підкласу-проксі.
3. Що станеться з анотацією @Async, якщо викликати позначений нею метод через self-invocation?
Відповідь: Метод виконається синхронно в тому самому потоці, з якого був викликаний. Проксі-інтерцептор AsyncExecutionInterceptor, який відповідає за передачу задачі в пул TaskExecutor, викликаний не буде.
4. Як у коді гарантовано перевірити, чи працює метод усередині активної Spring-транзакції?
Відповідь: Викликати TransactionSynchronizationManager.isActualTransactionActive(). Якщо транзакція активна, метод поверне true.
Червоні прапорці (чого категорично не можна говорити)
- ❌ «CGLIB вирішує проблему self-invocation, вона присутня лише в JDK Dynamic Proxy» — грубе нерозуміння архітектури:
thisвказує на цільовий об’єкт в обох реалізаціях. - ❌ «Якщо поставити
@Transactionalнад усім класом, self-invocation запрацює» — анотація над класом лише позначає всі public-методи для проксіювання ззовні; внутрішні виклики черезthisусе одно оминають проксі. - ❌ «При self-invocation транзакція відкотиться з помилкою
TransactionRequiredException» — жодної помилки не виникне, код мовчки виконається без транзакції (тихий дефект). - ❌ «Проблема стосується виключно анотації
@Transactional» — ламаються абсолютно всі AOP-механізми: кешування, асинхронність, безпека, retry.