🍃 Розділ 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)?

Відповідь: Транзакція не відкриється взагалі. Виклик піде напряму в оригінальний метод в обхід проксі. Метод виконається без транзакційного контексту, у режимі авто-фіксації (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.

Пов’язані теми