В теорії, анотація @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]