Що станеться при виклику @Transactional методу з іншого методу того ж класу
Якщо всередині сервісного класу викликати метод, позначений анотацією @Transactional, з іншого методу того самого класу (через неявний або явний покажчик this), то анотація @Tra...
🟢 Junior Level
Якщо всередині сервісного класу викликати метод, позначений анотацією @Transactional, з іншого методу того самого класу (через неявний або явний покажчик this), то анотація @Transactional буде повністю проігнорована, і нова транзакція НЕ розпочнеться.
В архітектурі Spring це явище називається Self-Invocation Problem (проблема самовиклику).
Суть у 30 секундах
Spring Framework керує транзакціями за допомогою AOP-проксі (динамічних об’єктів-обгорток):
- Коли зовнішній клієнт (контролер або інший сервіс) викликає метод біна, виклик спочатку потрапляє в Spring AOP Proxy, який відкриває транзакцію та передає керування в реальний об’єкт.
- Але коли всередині одного методу викликається інший метод того самого класу (
this.innerMethod()), виклик відбувається безпосередньо всередині JVM повз проксі-обгортку. TransactionInterceptorпросто не отримує керування. Налаштування@Transactional(включно зpropagation = REQUIRES_NEW,isolation,timeout) не застосовуються!
[ Зовнішній виклик від Контролера ]:
Client ───> [ Spring AOP Proxy ] ───> BEGIN TX ───> [ Target Bean: methodA() ] ───> COMMIT TX
^
│ (Транзакція перехоплена і відкрита!)
[ Внутрішній самовиклик (Self-Invocation) ]:
[ Target Bean: methodA() ] ──(this.methodB())──> [ Target Bean: methodB() ]
│
v
❌ Проксі оминуто стороною!
Транзакція НЕ створюється!
Простий приклад проблеми
package com.example.service;
import com.example.entity.Order;
import com.example.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
// Зовнішній метод без транзакції:
public void processOrder(Order order) {
// ПОМИЛКА! Виклик this.saveOrderInNewTx() омине Spring Proxy!
saveOrderInNewTx(order);
}
// Розробник очікує відкриття нової ізольованої транзакції:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOrderInNewTx(Order order) {
// ФАКТИЧНО: Виконається БЕЗ транзакції (у режимі autocommit),
// анотацію @Transactional повністю проігноровано!
orderRepository.save(order);
}
}
🟡 Middle Level
Коли Self-Invocation стає критичною аварією
Існує 2 типові виробничі сценарії, в яких самовиклик призводить до втрати даних або деградації системи:
Сценарій 1: Батьківський метод ВЖЕ в транзакції, а дочірній вимагає REQUIRES_NEW
@Service
public class BillingService {
@Transactional // Транзакція #1
public void executePayment() {
debitAccount();
// Розробник хотів записати аудит незалежно в новій транзакції:
this.logAuditInNewTx();
throw new RuntimeException("Crash");
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAuditInNewTx() {
auditRepo.save(new AuditEntry("Платіж розпочато"));
}
}
Що відбудеться: Виклик this.logAuditInNewTx() піде повз проксі. Атрибут REQUIRES_NEW буде проігноровано. Запис аудиту виконається всередині Транзакції #1. У разі виникнення Crash Транзакція #1 відкотиться цілком, і запис аудиту буде стерто з бази даних!
Сценарій 2: Батьківський метод без транзакції, а дочірній виконує складний запис
Якщо батьківський метод public void importBatch() без транзакції викликає в циклі this.saveRow(), позначений @Transactional, кожна операція INSERT виконуватиметься в режимі JDBC autoCommit = true. При збої на середині пачки частина рядків залишиться в базі, порушивши атомарність даних.
🔴 Senior Level
4 Промислові способи розв’язання проблеми Self-Invocation
Senior Engineer обирає рішення залежно від архітектурної чистоти та вимог до продуктивності:
1. Винесення методу в окремий сервіс (Best Practice — 100% рекомендація)
Найчистіше рішення з погляду принципу єдиної відповідальності (Single Responsibility Principle):
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderAuditService auditService; // Окремий бін-проксі!
@Transactional
public void executeOrder() {
// Виклик іде через Spring Proxy іншого біна — REQUIRES_NEW спрацює ідеально!
auditService.logAuditInNewTx();
}
}
2. Впровадження біна самого в себе (Self-Injection з @Lazy)
Якщо поділ класу на кілька бінів є недоцільним:
@Service
public class OrderService {
@Autowired
@Lazy // @Lazy запобігає циклічній залежності (Circular Dependency)
private OrderService self;
public void processOrder(Order order) {
// Виклик через інжектований проксі-об'єкт:
self.saveOrderInNewTx(order); // @Transactional перехоплюється!
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveOrderInNewTx(Order order) {
orderRepository.save(order);
}
}
3. Використання TransactionTemplate (Програмне керування)
Повна відмова від проксіювання на користь явних меж транзакції:
@Service
@RequiredArgsConstructor
public class OrderService {
private final TransactionTemplate transactionTemplate;
private final OrderRepository orderRepository;
public void processOrder(Order order) {
// Програмне відкриття транзакції без викликів через проксі:
transactionTemplate.execute(status -> {
orderRepository.save(order);
return true;
});
}
}
4. AspectJ Load-Time / Compile-Time Weaving (Байткод-інструментування)
Якщо в проєкті налаштовано компілятор AspectJ (ajc) або Java Agent:
- AspectJ не використовує динамічні проксі CGLIB/JDK. Він впроваджує інструкції транзакцій безпосередньо в байткод класів (
Bytecode Weaving). - При AspectJ виклики
this.method()і навіть викликиprivate-методів перехоплюються й працюють транзакційно! Проте цей підхід суттєво ускладнює збірку та налагодження застосунку.
4 Tricky Questions
1. Що станеться, якщо батьківський метод outer() уже перебуває в @Transactional, і викликає this.inner() з @Transactional(propagation = Propagation.REQUIRES_NEW)?
Відповідь:
Метод inner() виконається всередині поточної відкритої батьківської транзакції, ніби для нього було задано Propagation.REQUIRED!
Причина: Оскільки виклик виконується через покажчик this, AOP-проксі повністю виключений з ланцюжка викликів. Анотація над inner() не аналізується перехоплювачем TransactionInterceptor. Призупинення батьківської транзакції (suspend) та відкриття нового фізичного з’єднання не відбудеться. Якщо батьківський метод надалі впаде й відкотиться, зміни, внесені методом inner(), також будуть повністю скасовані.
2. Чому стандартні Unit-тести на Mockito (наприклад, @ExtendWith(MockitoExtension.class)) часто маскують цю помилку та не виявляють проблему?
Відповідь:
Тому що в легковагих модульних тестах на Mockito тестується чистий Java-об’єкт без розгортання Spring ApplicationContext та без генерації CGLIB/JDK-проксі.
Коли в тесті викликається orderService.processOrder(), виклик this.saveOrderInNewTx() відпрацьовує безпосередньо в пам’яті, моковані репозиторії (@Mock Repository) успішно зберігають сутності, і тести проходять зі статусом «зелений». Помилка самовиклику розкривається лише в інтеграційних тестах із середовищем Spring (@SpringBootTest / @DataJpaTest) з перевіркою реального відкату транзакції в базі даних або вже на продакшені.
3. Як гарантовано заборонити самовиклики методів з @Transactional у всьому проєкті за допомогою ArchUnit?
Відповідь: За допомогою архітектурного тесту ArchUnit на рівні CI/CD:
package com.example.architecture;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.transaction.annotation.Transactional;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.example")
public class TransactionArchitectureTest {
@ArchTest
public static final ArchRule no_self_invocation_of_transactional_methods =
noClasses().should().callMethodWhere(
target -> target.isAnnotatedWith(Transactional.class)
&& target.getOwner().equals(target.getDeclaringClass())
).because("Виклик @Transactional методу з того самого класу оминає Spring AOP Proxy!");
}
Цей тест статично аналізує байткод усіх класів і зупиняє збірку проєкту з помилкою, якщо виявляє виклик транзакційного методу через this.
4. У чому фундаментальна відмінність роботи @Transactional при CGLIB-проксіюванні від AspectJ Weaving у контексті викликів через this?
Відповідь:
- CGLIB / JDK Dynamic Proxy: Базується на патерні Decorator/Wrapper. Створюється окремий синтетичний клас-проксі (
Service$$SpringCGLIB), який перехоплює зовнішні виклики. Посиланняthisвсередині методів оригінального класу завжди вказує на сам реальний об’єкт, а не на проксі-обгортку, тому викликиthis.method()у принципі неможливо перехопити проксі-об’єктом. - AspectJ Weaving (LTW/CTW): Модифікує байткод цільового класу безпосередньо. Виклики
this.innerMethod()на рівні байткоду огортаються у виклики аспектуTransactionAspect.aj. Тому при AspectJ транзакції працюють за будь-яких внутрішніх викликів і навіть наprivateтаprotectedметодах.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
«При виклику
@Transactionalметоду з іншого методу того самого класу анотація ігнорується, і нова транзакція не відкривається. Це відбувається тому, що Spring реалізує декларативні транзакції через AOP-проксі. При виклику черезthisкерування передається напряму всередині оригінального екземпляра класу, оминаючи проксі таTransactionInterceptor. Найкраще архітектурне рішення — винести метод в окремий бін. Альтернативи: впровадження біна самого в себе (@Autowired @Lazy self), використанняTransactionTemplateабо перехід на AspectJ Weaving».
Чек-лист ключових понять
- Причина проблеми: Виклик через
thisіде в обхід Spring AOP Proxy. - REQUIRES_NEW ігнорується: Внутрішній виклик із
REQUIRES_NEWмовчки виконується в батьківській транзакції. - Рекомендоване рішення: Винесення методу в окремий клас за Single Responsibility Principle.
- Self-Injection: Впровадження самого себе з анотацією
@Lazyдля отримання посилання на власний проксі. - ArchUnit: Архітектурні тести дозволяють виявляти самовиклики ще на етапі компіляції.
Червоні прапорці (чого в жодному разі не можна говорити)
- ❌ «Якщо поставити @Transactional(propagation = REQUIRES_NEW), то самовиклик спрацює» (REQUIRES_NEW також вимагає проксі й так само ігнорується).
- ❌ «AopContext.currentProxy() — це найкращий спосіб вирішення проблеми» (Це антипатерн, який створює жорстку зв’язність зі Spring AOP і вимагає небезпечного exposeProxy).
- ❌ «CGLIB вміє перехоплювати виклики через this» (CGLIB створює підклас, але this всередині методів все одно вказує на цільовий екземпляр).