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

Что произойдёт при вызове @Transactional метода из другого метода того же класса

Если внутри сервисного класса вызвать метод, помеченный аннотацией @Transactional, из другого метода того же самого класса (через неявный или явный указатель this), то аннотация...


🟢 Junior Level

Если внутри сервисного класса вызвать метод, помеченный аннотацией @Transactional, из другого метода того же самого класса (через неявный или явный указатель this), то аннотация @Transactional будет полностью проигнорирована, и новая транзакция НЕ начнется.

В архитектуре Spring это явление называется Self-Invocation Problem (проблема самовызова).

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

Spring Framework управляет транзакциями с помощью AOP-прокси (динамических объектов-оберток):

  1. Когда внешний клиент (контроллер или другой сервис) вызывает метод бина, вызов сначала попадает в Spring AOP Proxy, который открывает транзакцию и передает управление в реальный объект.
  2. Но когда внутри одного метода вызывается другой метод того же класса (this.innerMethod()), вызов происходит напрямую внутри JVM мимо прокси-обертки.
  3. 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».

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

  1. Причина проблемы: Вызов через this идет в обход Spring AOP Proxy.
  2. REQUIRES_NEW игнорируется: Внутренний вызов с REQUIRES_NEW молча выполняется в родительской транзакции.
  3. Рекомендуемое решение: Вынесение метода в отдельный класс по Single Responsibility Principle.
  4. Self-Injection: Внедрение самого себя с аннотацией @Lazy для получения ссылки на собственный прокси.
  5. ArchUnit: Архитектурные тесты позволяют отловить самовызовы на этапе компиляции.

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

  • ❌ «Если поставить @Transactional(propagation = REQUIRES_NEW), то самовызов сработает» (REQUIRES_NEW тоже требует прокси и точно так же игнорируется).
  • ❌ «AopContext.currentProxy() — это лучший способ решения проблемы» (Это антипаттерн, создающий жесткую связанность с Spring AOP и требующий небезопасного exposeProxy).
  • ❌ «CGLIB умеет перехватывать вызовы через this» (CGLIB создает подкласс, но this внутри методов все равно указывает на целевой экземпляр).

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