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

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

Если вы вызываете @Transactional метод из другого метода того же класса — аннотация будет проигнорирована, и транзакция не начнётся.

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Если вы вызываете @Transactional метод из другого метода того же классааннотация будет проигнорирована, и транзакция не начнётся.

Это называется Self-invocation problem (проблема самовызова).

Простой пример

@Service
public class OrderService {

    public void createOrderExternal() {
        saveOrder();  // Вызов внутри класса — @Transactional НЕ работает!
    }

    @Transactional
    public void saveOrder() {
        // Эта логика выполнится БЕЗ транзакции
        orderRepository.save(order);
    }
}

Аналогия

Представьте что вы звоните другу через секретаря (Proxy). Секретарь записывает разговор (управляет транзакцией). Но если вы крикните другу через стену (this) — секретарь ничего не знает о разговоре.

Почему это происходит

Spring управляет транзакциями через Proxy-объект (обёртку). Когда внешний код вызывает метод — он идёт через прокси. Но когда метод вызывает другой метод того же класса — вызов идёт напрямую через this, минуя прокси.

Механизм: Spring создаёт прокси-объект, который перехватывает вызовы извне. Но когда метод A вызывает метод B того же объекта, this.methodB() идёт напрямую — прокси не участвует, TransactionInterceptor не срабатывает.

Важные правила

  • @Transactional работает только при вызове извне класса
  • this.method() — всегда bypass proxy
  • Private/protected методы никогда не перехватываются

Как решить

  1. Вынести метод в другой бин (лучший вариант)
  2. Self-injection: внедрить сервис сам в себя с @Lazy
  3. TransactionTemplate: программное управление транзакциями

Когда self-invocation НЕ проблема

  1. Метод не требует транзакции — если @Transactional стоит для консистентности, но метод read-only
  2. Внешний вызов уже в транзакции — если outerMethod уже @Transactional, innerMethod работает в той же транзакции (propagation = REQUIRED по умолчанию)
  3. Используете AspectJ mode — compile-time/load-time weaving перехватывает this вызовы

🟡 Middle Level

Механизм Proxy

Внешний вызов:
Client → Proxy → TransactionInterceptor → TransactionManager → Реальный метод
                       ↓
               Открыть транзакцию → Выполнить → COMMIT/ROLLBACK

Внутренний вызов (this):
Метод A → this.метод B() → Реальный метод (минуя Proxy!)
                              ↓
                        Нет транзакции!

Spring создаёт бин-обёртку (Proxy) вокруг вашего объекта. Когда внешний клиент вызывает метод, он вызывает его у Proxy. Proxy перехватывает вызов, открывает транзакцию и передаёт управление.

Вызов через this идёт напрямую к реальному объекту — proxy полностью bypassed.

Способы решения

А. Вынос метода в другой бин (Рекомендуемый)

@Service
public class OrderService {
    @Autowired
    private OrderRepositoryService orderRepositoryService;

    public void createOrderExternal() {
        orderRepositoryService.saveOrder();  // Через другой бин — работает!
    }
}

@Service
public class OrderRepositoryService {
    @Transactional
    public void saveOrder() {
        // Теперь транзакция работает
    }
}

Б. Self-injection

@Service
public class OrderService {

    @Autowired
    @Lazy  // Чтобы избежать циклической зависимости
    private OrderService self;

    public void createOrderExternal() {
        self.saveOrder();  // Через proxy — транзакция работает!
    }

    @Transactional
    public void saveOrder() {
        // Теперь транзакция работает
    }
}

В. TransactionTemplate

@Service
public class OrderService {

    @Autowired
    private TransactionTemplate transactionTemplate;

    public void createOrderExternal() {
        transactionTemplate.execute(status -> {
            saveOrder();  // Программная транзакция
            return null;
        });
    }

    public void saveOrder() {
        // Выполняется в транзакции через TransactionTemplate
    }
}

Г. AspectJ

Переход на AspectJ weaving модифицирует байт-код при компиляции, и вызовы внутри класса перехватываются корректно. Требует сложной настройки.

Распространённые ошибки

Ошибка Что происходит Как исправить
Не знать о self-invocation Код работает в тестах, падает в production Вынести в отдельный бин
Self-injection без @Lazy BeanCurrentlyInCreationException Добавить @Lazy
AopContext.currentProxy() без exposeProxy IllegalStateException: Cannot find current proxy @EnableAspectJAutoProxy(exposeProxy = true)
Смешивание подходов Непредсказуемое поведение Выбрать один подход

Сравнение решений

Решение Сложность Self-invocation Private методы Рекомендуется
Вынос в отдель бин Низкая ✅ Лучший
Self-injection (@Lazy) Низкая Хороший
AopContext.currentProxy() Средняя OK
TransactionTemplate Средняя Хороший для сложной логики
AspectJ weaving Высокая Только если нужно

Когда НЕ использовать решения

Ситуация Почему Альтернатива
Метод не требует отдельной транзакции Self-invocation не проблема если caller уже в tx Оставить как есть
Simple read-only метод ReadOnly не требует сложной транзакции readOnly = true на caller
Метод — утилитарный, не бизнес-логика Не belongs в сервисном слое Вынести в util class

🔴 Senior Level

Proxy-Based AOP — Root Cause Analysis

How Spring Creates Proxies

JDK Dynamic Proxy:

UserService proxy = (UserService) Proxy.newProxyInstance(
    classLoader,
    new Class[]{UserService.class},
    new TransactionalInvocationHandler(target, interceptor)
);

CGLIB:

UserService proxy = (UserService) Enhancer.create(
    UserService.class,
    new TransactionalMethodInterceptor(target, interceptor)
);

В обоих случаях proxy оборачивает target object. Внешние вызовы идут через proxy → interceptor → target. Внутренние вызовы (this.method()) идут напрямую: target → target. Это работает и для JDK Dynamic Proxy, и для CGLIB — в обоих случаях this ссылается на целевой объект, не на прокси. CGLIB создаёт подкласс, но this внутри методов подкласса всё равно указывает на целевой объект.

The this Reference Problem

public class OrderService {
    // this = OrderService@123 (the TARGET object, not the proxy)

    public void createOrderExternal() {
        // this.saveOrder() вызывает target напрямую
        // Bypasses: OrderServiceProxy@456.saveOrder()
        saveOrder();
    }
}

this всегда указывает на actual object, never на proxy. Нет способа для Spring перехватить this.*() вызовы со стандартным AOP.

Solution Deep Dive

Solution 1: Self-Injection with @Lazy

@Service
public class OrderService {
    @Autowired
    @Lazy
    private OrderService self;

    public void createOrderExternal() {
        // self = proxy (OrderService$$EnhancerBySpringCGLIB$$...)
        self.saveOrder();  // Goes through proxy → interceptor
    }
}

Why @Lazy: Без @Lazy, Spring пытается инжектировать OrderService во время его создания → circular dependency error. @Lazy создаёт lazy proxy, который резолвит реальный бин при первом использовании.

Solution 2: AopContext.currentProxy()

@EnableAspectJAutoProxy(exposeProxy = true)  // Required!

@Service
public class OrderService {
    public void createOrderExternal() {
        ((OrderService) AopContext.currentProxy()).saveOrder();
    }
}

How it works: exposeProxy = true сохраняет proxy в ThreadLocal. AopContext.currentProxy() извлекает его.

Drawbacks: Tight coupling к Spring API, ThreadLocal — не работает across threads, exposeProxy имеет performance overhead.

// exposeProxy = true добавляет overhead на каждый вызов (ThreadLocal). // Не используйте в highload. Альтернатива: вынесите метод в отдельный бин.

Solution 3: Programmatic Transactions

@Service
public class OrderService {
    @Autowired
    private PlatformTransactionManager transactionManager;

    public void createOrderExternal() {
        TransactionTemplate template = new TransactionTemplate(transactionManager);
        template.execute(status -> {
            saveOrder();  // In transaction
            return null;
        });
    }
}

Advantages: No proxy needed, explicit transaction boundaries, работает в любом контексте. Disadvantages: More verbose, mixing transaction management с business logic.

AspectJ Weaving — The Nuclear Option

Compile-Time Weaving

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>aspectj-maven-plugin</artifactId>
    <configuration>
        <aspectLibraries>
            <aspectLibrary>
                <groupId>org.springframework</groupId>
                <artifactId>spring-aspects</artifactId>
            </aspectLibrary>
        </aspectLibraries>
    </configuration>
</plugin>

Модифицирует bytecode at compile time:

// BEFORE
public void createOrderExternal() { saveOrder(); }

// AFTER (AspectJ-woven)
public void createOrderExternal() {
    TransactionInterceptor.invoke(this, "saveOrder", ...);
    saveOrder();
}

Benefits: Self-invocation работает, private/protected методы могут быть @Transactional, нет proxy limitations. Drawbacks: Complex build config, bytecode modification (debugging harder), limited IDE support, не совместим со всеми Spring Boot features.

Edge Cases (минимум 3)

  1. @Transactional на интерфейсе + CGLIB: Если proxy — CGLIB (class-based), аннотация на интерфейсе не видна. Self-invocation уже не работает, плюс аннотация не применяется к proxy. Двойная проблема.

  2. Nested self-invocation: methodA()this.methodB() (no tx) → this.methodC() (has @Transactional). Ни B, ни C не будут транзакционными. Вся цепочка bypassed.

  3. @Transactional(propagation = REQUIRES_NEW) при self-invocation: Даже REQUIRES_NEW не сработает при this.method() вызове. Новая транзакция не откроется — метод выполнится в контексте caller (без транзакции если caller тоже не в tx).

  4. Self-invocation и @Async: @Async методы тоже идут через proxy. this.asyncMethod() — метод выполнится синхронно, не асинхронно. Двойной proxy bypass: и транзакция, и async.

  5. Constructor вызовы: Если конструктор вызывает @Transactional метод — proxy ещё не создан (bean initialization не завершён). Вызов всегда идёт напрямую.

JDK Proxy vs CGLIB для Self-Invocation

Характеристика JDK Dynamic Proxy CGLIB
Self-invocation ❌ Не работает ❌ Не работает
Final методы N/A (interface) ❌ Не перехватываются
Private методы ❌ Не перехватываются ❌ Не перехватываются
Performance overhead Низкий Средний
Bytecode generation Нет Да (генерирует subclass)

Оба типа proxy не решают self-invocation проблему. Только AspectJ weaving решает.

Performance Numbers

Операция Время
Proxy invocation overhead ~1-2 μs
AopContext.currentProxy() lookup ~0.5 μs (ThreadLocal get)
exposeProxy overhead ~0.1 μs per call
Self-injection (@Lazy) resolution ~1 μs (first call), 0 (cached)

Memory Implications

  • exposeProxy = true: ThreadLocal для каждого proxy — ~1 KB per active thread
  • Self-injection:额外 proxy reference в бине — negligible (~8 bytes)
  • AspectJ weaving: Bytecode modification — larger class files, no runtime memory impact

Thread Safety

AopContext.currentProxy() использует ThreadLocal — не работает across threads. @Async методы в другом потоке не получат proxy из ThreadLocal caller. Self-injection proxy — thread-safe (singleton proxy reference).

Production War Story

Сервис обработки заказов: createOrder() вызывал this.validateOrder()@Transactional). Валидация писала audit log в БД. В тестах (один поток, без конкуренции) — работало. В production — audit log не писался, т.к. транзакция не открывалась. Данные терялись. Fix: validateOrder() вынесен в отдельный OrderValidationService.

Monitoring

Detect self-invocation at runtime:

@Bean
public BeanPostProcessor selfInvocationDetector() {
    return new BeanPostProcessor() {
        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) {
            if (AopUtils.isAopProxy(bean)) {
                // Log: this bean has a proxy — self-invocation will bypass it
            }
            return bean;
        }
    };
}

Actuator:

GET /actuator/beans  # Показывает какие бины обернуты в proxy

Highload Best Practices

  1. Extract to separate bean — always: Это не workaround, это proper separation of concerns. Если методу нужна своя транзакция — у него должна быть своя responsibility.
  2. Avoid AopContext.currentProxy(): Tight coupling, ThreadLocal issues, performance overhead. Не для production.
  3. Self-invocation = code smell: Если боретесь с self-invocation — сервис, вероятно, делает слишком много. Split по SRP.
  4. Test self-invocation behavior: Unit test с Mockito не ловит эту проблему (Mockito mocks идут через proxy). Нужен Spring integration test.
  5. Consider programmatic transactions: Для сложной условной логики TransactionTemplate чище чем fighting с proxy.
  6. Architecture rule: Ввести ArchUnit test: noClass().should().callMethodWhere(target declaresAnnotation(Transactional.class)).fromSameClass().

🎯 Шпаргалка для интервью

Обязательно знать:

  • Self-invocation problem: вызов @Transactional метода через this bypass-ит Spring Proxy
  • Spring управляет транзакциями через AOP Proxy (JDK Dynamic Proxy или CGLIB)
  • this.method() идёт напрямую к target object — TransactionInterceptor не срабатывает
  • 4 решения: вынести в другой бин (best), self-injection @Lazy, AopContext.currentProxy(), TransactionTemplate
  • REQUIRES_NEW тоже НЕ работает при self-invocation — новая транзакция не откроется
  • @Async при self-invocation выполняется синхронно — двойной proxy bypass

Частые уточняющие вопросы:

  • Почему proxy не перехватывает this вызовы? — this всегда ссылается на target object, не на proxy
  • Чем JDK Proxy отличается от CGLIB в контексте self-invocation? — Оба НЕ решают проблему self-invocation
  • Когда self-invocation НЕ проблема? — Если caller уже в @Transactional (propagation REQUIRED join-ит)
  • Почему Mockito не ловит эту проблему? — Mockito mocks = proxy, self-invocation работает на mock-е

Красные флаги (НЕ говорить):

  • “Self-invocation работает в production” — proxy bypassed, транзакция не открывается
  • “REQUIRES_NEW решает self-invocation” — REQUIRES_NEW тоже требует proxy
  • “AopContext.currentProxy() = best practice” — tight coupling к Spring API, ThreadLocal overhead

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

  • [[16. Что такое аннотация @Transactional]]
  • [[13. Что такое Propagation в Spring]]
  • [[17. На каком уровне можно использовать @Transactional]]
  • [[15. В чём разница между REQUIRED и REQUIRES_NEW]]