Як насправді працює @Async у Spring і коли його використання створює більше проблем, ніж вирішує

Зміст

В теорії, анотація @Async у Spring виглядає як магічне рішення для асинхронного виконання методів. Додали анотацію, і метод виконується в окремому потоці, Але за цією простотою ховаються нюанси, які можуть призвести до серйозних проблем.

Як це працює за кулісами

Коли ви анотуєте метод за допомогою @Async, Spring створює проксі-об'єкт навколо вашого компонента. При виклику асинхронного методу, Spring перехоплює виклик і делегує його виконання в окремий пул потоків (TaskExecutor).

@Service
public class EmailService {
    @Async
    public CompletableFuture<Boolean> sendEmail(String to, String content) {
        // Довга операція
        return CompletableFuture.completedFuture(true);
    }
}

Здавалося б, просто і зручно. Але тут починаються проблеми :)

Проблема #1. Неможливість перехоплення виключень

Оскільки метод виконується асинхронно, виключення, які виникають у ньому, не будуть автоматично перехоплені блоками try-catch навколо виклику методу.

try {
    emailService.sendEmail("[email protected]", "Hello"); // Помилка не буде перехоплена!
} catch (Exception e) {
    // Цей код ніколи не виконається
}

Єдиним способом обробити такі виключення є використання спеціального AsyncUncaughtExceptionHandler або обробка помилок через CompletableFuture.

Проблема #2. Втрата транзакційного контексту

Один з найнеприємніших сюрпризів: @Async і @Transactional погано поєднуються. Коли метод виконується в іншому потоці, він втрачає транзакційний контекст викликаючого методу.

@Service
public class UserService {
    @Transactional
    public void registerUser(User user) {
        repository.save(user);
        notificationService.sendWelcomeEmail(user); // Асинхронний метод
        // Якщо sendWelcomeEmail() викине виключення, user все одно збережеться
    }
}

Проблема #3. Непередбачувана поведінка при self-invocation

Виклик асинхронного методу з того ж класу (this.asyncMethod()) не працюватиме, оскільки Spring використовує проксі для перехоплення викликів, а при self-invocation проксі не задіюється.

@Service
public class AnalyticsService {
    public void process(Data data) {
        // Це НЕ буде асинхронним
        this.processAsync(data);
    }
    
    @Async
    public void processAsync(Data data) {
        // ...
    }
}

Проблема #4. Важко тестувати

Асинхронні методи надзвичайно складно тестувати в юніт-тестах. Вам потрібно або мокати TaskExecutor, або використовувати складні конструкції з CountDownLatch для очікування завершення виконання. Або додаткові ліби по типу Awaitility.

Проблема #5. Дефолтний TaskExecutor

Якщо ви не налаштували власний TaskExecutor, Spring використовуватиме SimpleAsyncTaskExecutor, який створює новий потік для кожного виклику. При високому навантаженні це призведе до вичерпання ресурсів сервера.

Крім того, при використанні асинхронних методів часто втрачається контекст логування (MDC), що ускладнює відстеження запитів у логах.

Створення власного TaskExecutor

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        
        // Основні налаштування пулу потоків
        executor.setCorePoolSize(5);          // Базова кількість потоків
        executor.setMaxPoolSize(10);          // Максимальна кількість потоків
        executor.setQueueCapacity(25);        // Розмір черги завдань
        
        // Що робити, коли черга заповнена
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        
        // Важливо для відстеження в логах!
        executor.setThreadNamePrefix("AsyncTask-");
        
        // Копіювання MDC контексту
        executor.setTaskDecorator(new MdcTaskDecorator());
        
        // Не забудьте це для коректної ініціалізації
        executor.initialize();
        
        return executor;
    }
    
    // Обробка виключень у асинхронних методах
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new CustomAsyncExceptionHandler();
    }
}

Вирішення проблеми втрати MDC контексту

MDC (Mapped Diagnostic Context) використовується для збереження контексту запиту в логах. При асинхронному виконанні цей контекст втрачається, оскільки потоки не успадковують MDC автоматично.

Ось імплементація декоратора, який зберігає MDC контекст:

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        // Копіюємо поточний MDC контекст
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        
        return () -> {
            try {
                // Відновлюємо MDC контекст у новому потоці
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                }
                // Виконуємо нашу таску
                runnable.run();
            } finally {
                // Очищаємо контекст після виконання
                MDC.clear();
            }
        };
    }
}

Обробка асинхронних виключень

public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
    
    private static final Logger logger = LoggerFactory.getLogger(CustomAsyncExceptionHandler.class);
    
    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        logger.error("Async exception in method: {}.{} with params: {}",
            method.getDeclaringClass().getSimpleName(),
            method.getName(),
            Arrays.toString(params),
            ex);
            
        // Можна також надіслати повідомлення в систему моніторингу
        // або додати запис у базу даних помилок
    }
}

Використання налаштованого TaskExecutor

@Service
public class EmailService {
    
    @Async("taskExecutor")  // Явно вказуємо ім'я нашого пулу потоків
    public CompletableFuture<Boolean> sendEmail(String to, String content) {
        // MDC контекст збережеться завдяки декоратору
        logger.info("Sending email to: {}", to); // traceId/requestId збережеться
        
        // Довга операція...
        
        return CompletableFuture.completedFuture(true);
    }
}

Приклад проблеми з логами без збереження MDC

Без збереження MDC:

[2023-03-01 10:15:23.456] [requestId=abc-123] [Thread-1] INFO - Отримано запит надіслати email
[2023-03-01 10:15:23.458] [Thread-2] INFO - Sending email to [email protected]

Зі збереженням MDC:

[2023-03-01 10:15:23.456] [requestId=abc-123] [Thread-1] INFO - Отримано запит надіслати email
[2023-03-01 10:15:23.458] [requestId=abc-123] [AsyncTask-2] INFO - Sending email to [email protected]
Поділись своїми ідеями в новій публікації.
Ми чекаємо саме на твій довгочит!
Oleksandr Klymenko
Oleksandr Klymenko@overpathz

Java Software Engineer

5.5KПрочитань
1Автори
81Читачі
На Друкарні з 19 квітня

Більше від автора

  • RFC 7807. Що це і для чого він потрібен бекенд розробникам

    Як стандарт RFC 7807 змінює підхід до обробки помилок у Java розробці. У статті: що це таке, як працює формат "Problem Details", приклади використання та готовий код для інтеграції у Spring Boot

    Теми цього довгочиту:

    Java
  • Java. jOOQ

    Довгочит буде про jOOQ — бібліотеку, яка зручно поєднує світ Java і SQL. Якщо ви працюєте з базами даних у Java, то, скоріш за все, зустрічались з такими дилемами:

    Теми цього довгочиту:

    Java
  • Secure networking. Deep Dive

    Глибоке занурення в протоколи TLS/SSL та інфраструктуру відкритих ключів (PKI). Основні поняття, процес встановлення захищеного з'єднання, роль сертифікатів та ланцюжка довіри

    Теми цього довгочиту:

    Security

Вам також сподобається

Коментарі (0)

Підтримайте автора першим.
Напишіть коментар!

Вам також сподобається