🍃 Раздел 5 · Вопрос #18

Почему @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!
  1. Клиент вызывает orderService.processOrder().
  2. Вызов перехватывается прокси. Поскольку на методе processOrder() нет @Transactional, прокси просто передает вызов реальному объекту Target.
  3. Внутри метода processOrder() выполняется вызов saveToDatabase().
  4. На уровне байткода JVM выполняется инструкция invokevirtual по указателю this.
  5. Ссылка this указывает на реальный целевой объект в памяти, а не на прокси-обертку.
  6. Вызов попадает напрямую в 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:

  1. Разработчик ожидает, что logAuditInfo() выполнится в отдельной транзакции и сохранится, даже если основной платеж упадет, либо его падение не сломает платеж.
  2. В реальности из-за self-invocation метод logAuditInfo() выполняется в физическом контексте внешней транзакции.
  3. Если 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)? Ответ: Транзакция не откроется вообще. Вызов пойдет напрямую в оригинальный метод в обход прокси. Метод выполнится без транзакционного контекста, в режиме авто-коммита 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.

Связанные темы