@Async Deep Dive: Fixing Transaction Loss, MDC Context Loss, and Silent Exception Swallowing
This article explains why Spring's @Async annotation causes transaction rollback failures, MDC traceId loss in async threads, and silent exception swallowing, then provides production-ready solutions including separate service classes, TaskDecorator for MDC propagation, custom AsyncUncaughtExceptionHandler, and proper thread pool configuration.
1. @Async Underlying Mechanism
@Asyncworks via AOP dynamic proxy, similar to @Transactional. Spring scans beans with @Async at startup and creates proxy objects. The caller injects the proxy, not the original bean. The proxy intercepts calls via AsyncExecutionInterceptor:
public class AsyncExecutionInterceptor extends AsyncExecutionAspectSupport implements MethodInterceptor, Ordered { @Override public Object invoke(final MethodInvocation invocation) throws Throwable { Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null); Method method = invocation.getMethod(); AsyncTaskExecutor executor = determineAsyncExecutor(method); if (executor == null) { throw new IllegalStateException("No executor specified"); } Callable<Object> task = () -> { try { Object result = invocation.proceed(); if (result instanceof Future) { return ((Future<?>) result).get(); } return null; } catch (Throwable ex) { throw new ExecutionException(ex); } }; if (Future.class.isAssignableFrom(method.getReturnType())) { return executor.submit(task); } else { try { executor.submit(task); } catch (RejectedExecutionException ex) { handleRejectedException(ex, method, invocation.getArguments()); } return null; } }}Key points: @Async methods execute in a separate thread pool, not the caller's thread
Void methods: exceptions handled by AsyncUncaughtExceptionHandler (default logs only)
Future methods: exceptions wrapped in Future, thrown on get() Self-invocation ( this.method()) bypasses proxy — both @Async and @Transactional fail
1.2 Default Thread Pool: SimpleAsyncTaskExecutor (Dangerous)
Without custom configuration, @Async uses SimpleAsyncTaskExecutor which creates a new thread per call with no limit ( concurrencyLimit = -1):
public class SimpleAsyncTaskExecutor extends CustomizableThreadCreator implements AsyncListenableTaskExecutor, Serializable { private int concurrencyLimit = -1; protected void doExecute(Runnable task) { Thread thread = (this.threadFactory != null ? this.threadFactory.newThread(task) : createThread(task)); thread.start(); }}Production risks: OOM (1MB stack per thread, 1000 threads = 1GB), poor performance from thread churn, no queue buffering or rejection policy. Must configure custom thread pool in production.
2. Transaction Loss — @Async + @Transactional
2.1 Scenario
@Servicepublic class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Async @Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { orderMapper.insert(dto); stockMapper.decrease(dto.getProductId(), dto.getQuantity()); int i = 1 / 0; // Exception, but transaction does NOT rollback! }}Result: Order inserted, stock deducted, transaction not rolled back .
2.2 Root Causes
Cause 1: Self-invocation — If createOrder called via this.createOrder(), both annotations bypass proxy.
Cause 2: AOP Proxy Order — Spring AOP proxies nest; outer proxy executes first. Order depends on @Order.
If @Async outer: Caller → @Async proxy → @Transactional proxy → Method. Transaction proxy runs in async thread — works.
If @Transactional outer: Caller → @Transactional proxy → @Async proxy → Method. Transaction proxy opens transaction, method returns immediately (async), transaction commits. Async thread executes outside transaction context — rollback fails.
Spring has default ordering but safest: Never put @Async and @Transactional on same method.
2.3 Solution: Split Classes
// Async service: scheduling only@Servicepublic class OrderAsyncService { @Autowired private OrderTransactionalService orderTransactionalService; @Async("orderTaskExecutor") public void createOrderAsync(OrderDTO dto) { // Cross-class call → proxy → transaction works orderTransactionalService.createOrder(dto); }}// Transaction service: transaction logic only@Servicepublic class OrderTransactionalService { @Autowired private OrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { orderMapper.insert(dto); stockMapper.decrease(dto.getProductId(), dto.getQuantity()); int i = 1 / 0; // Exception → transaction rolls back correctly }}Key: @Async on async service, @Transactional on transaction service. Cross-class call goes through proxy.
2.4 Alternative: Programmatic Transaction
@Servicepublic class OrderService { @Autowired private TransactionTemplate transactionTemplate; @Async("orderTaskExecutor") public void createOrder(OrderDTO dto) { transactionTemplate.execute(status -> { orderMapper.insert(dto); stockMapper.decrease(dto.getProductId(), dto.getQuantity()); int i = 1 / 0; return null; }); }} TransactionTemplatedoesn't rely on AOP proxy, works in async thread. Less elegant than declarative — prefer class split.
3. MDC Context Loss — traceId Missing in Async Thread
3.1 Scenario
@RestControllerpublic class OrderController { @Autowired private OrderService orderService; @GetMapping("/order") public String createOrder() { MDC.put("traceId", UUID.randomUUID().toString()); log.info("收到订单请求"); // Has traceId orderService.createOrderAsync(); // Async call return "ok"; }}@Servicepublic class OrderService { @Async("orderTaskExecutor") public void createOrderAsync() { log.info("异步创建订单"); // NO traceId! MDC lost }}Output: 2026-09-09 10:00:00 [traceId=abc123] 收到订单请求 then
2026-09-09 10:00:00 [] 异步创建订单 ← traceId gone!3.2 Cause
MDC uses ThreadLocal — each thread has isolated copy. Main thread sets MDC in its ThreadLocal; async thread is new, its ThreadLocal empty. ThreadLocal doesn't propagate across threads.
// MDC simplified implementationpublic class MDC { static final ThreadLocal<Map<String, String>> mdcAdapter = new ThreadLocal<>(); public static void put(String key, String val) { mdcAdapter.get().put(key, val); } public static String get(String key) { return mdcAdapter.get().get(key); }}3.3 Solution 1: Custom TaskDecorator (Recommended)
Spring thread pools support TaskDecorator to wrap tasks. Copy MDC in submitting thread, set in worker thread, clear after:
public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { try { if (contextMap != null) { MDC.setContextMap(contextMap); } runnable.run(); } finally { MDC.clear(); // Prevent context pollution from thread reuse } }; }}Configure in thread pool:
@Configuration@EnableAsyncpublic class ThreadPoolConfig { @Bean("orderTaskExecutor") public ThreadPoolTaskExecutor orderTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(200); executor.setThreadNamePrefix("order-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setTaskDecorator(new MdcTaskDecorator()); // MDC propagation executor.initialize(); return executor; }}3.4 Solution 2: TransmittableThreadLocal (TTL)
For multiple contexts (MDC, SecurityContext, RequestContext), use Alibaba's TTL:
<dependency> <groupId>com.alibaba</groupId> <artifactId>transmittable-thread-local</artifactId> <version>2.14.2</version></dependency>// Replace ThreadLocal with TTLpublic class TraceContext { private static final TransmittableThreadLocal<String> traceId = new TransmittableThreadLocal<>(); public static void setTraceId(String id) { traceId.set(id); } public static String getTraceId() { return traceId.get(); } public static void clear() { traceId.remove(); }}// Wrap executor with TTL@Bean("orderTaskExecutor")public Executor orderTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // ... config executor.initialize(); return TtlExecutors.getTtlExecutor(executor.getThreadPoolExecutor());}TTL advantages: no per-context TaskDecorator, auto propagation/cleanup, parent-child inheritance, zero business code intrusion.
MDC is ThreadLocal ; to propagate via TTL, use TTL's MDC adapter or manually copy MDC values into TTL. Simple cases: TaskDecorator . Complex: TTL.
4. Silent Exception Swallowing
4.1 Scenario
@Servicepublic class OrderService { @Async("orderTaskExecutor") public void createOrderAsync() { int i = 1 / 0; // Exception }}@RestControllerpublic class OrderController { @Autowired private OrderService orderService; @GetMapping("/order") public String createOrder() { orderService.createOrderAsync(); // Exception swallowed return "ok"; // Main thread returns normally, unaware }}Result: HTTP 200 OK, but ArithmeticException in async thread. If no logging, exception vanishes — impossible to debug.
4.2 Cause
Two cases:
Void return: Exception handled by AsyncUncaughtExceptionHandler. Default SimpleAsyncUncaughtExceptionHandler only logs:
public class SimpleAsyncUncaughtExceptionHandler implements AsyncUncaughtExceptionHandler { private static final Log logger = LogFactory.getLog(SimpleAsyncUncaughtExceptionHandler.class); @Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { logger.error("Unexpected exception occurred invoking async method: " + method, ex); }}If log level > ERROR or config broken, exception swallowed. No alerting, retry, fallback.
Future return: Exception wrapped in Future, thrown on get(). If caller never calls get(), exception never surfaces:
@Asyncpublic Future<String> asyncMethod() { int i = 1 / 0; return AsyncResult.forValue("ok");}// CallerFuture<String> future = asyncMethod();// No future.get() → exception never discovered!4.3 Solution 1: Global AsyncUncaughtExceptionHandler (Recommended)
Implement AsyncConfigurer:
@Configuration@EnableAsyncpublic class AsyncConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new CustomAsyncUncaughtExceptionHandler(); } public static class CustomAsyncUncaughtExceptionHandler implements AsyncUncaughtExceptionHandler { private static final Logger log = LoggerFactory.getLogger(CustomAsyncUncaughtExceptionHandler.class); @Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { log.error("异步方法执行异常!方法:{},参数:{}", method.getDeclaringClass().getSimpleName() + "." + method.getName(), params, ex); AlertService.sendAlert("异步方法异常", String.format("方法:%s,异常:%s", method.getName(), ex.getMessage())); if (ex instanceof BusinessException) { // Fallback handling } } }}All void @Async exceptions now log, alert, fallback centrally.
4.4 Solution 2: Internal Try-Catch
@Async("orderTaskExecutor")public void createOrderAsync(OrderDTO dto) { try { orderTransactionalService.createOrder(dto); } catch (Exception e) { log.error("异步创建订单失败,订单号:{}", dto.getOrderNo(), e); failOrderRepository.save(dto); // Fallback: save for retry AlertService.sendAlert("创建订单失败", e.getMessage()); }}Flexible per-method handling, but verbose and easy to miss.
Best practice: Global handler for safety net (log+alert), critical methods add try-catch for business fallback.
4.5 Solution 3: Return Future, Caller Handles
@Async("orderTaskExecutor")public Future<OrderResult> createOrderAsync(OrderDTO dto) { OrderResult result = orderTransactionalService.createOrder(dto); return AsyncResult.forValue(result);}// Callerpublic void create(OrderDTO dto) { Future<OrderResult> future = orderService.createOrderAsync(dto); try { OrderResult result = future.get(5, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (ExecutionException e) { Throwable cause = e.getCause(); log.error("异步创建订单失败", cause); } catch (TimeoutException e) { log.error("异步创建订单超时", e); }}Note: future.get() blocks caller. For non-blocking, use CompletableFuture + callbacks.
5. Production-Grade Complete Configuration
5.1 Thread Pool Config
@Configuration@EnableAsyncpublic class ThreadPoolConfig implements AsyncConfigurer { @Bean("orderTaskExecutor") public ThreadPoolTaskExecutor orderTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(500); executor.setThreadNamePrefix("order-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setTaskDecorator(new MdcTaskDecorator()); // MDC propagation executor.initialize(); return executor; } @Bean("notifyTaskExecutor") public ThreadPoolTaskExecutor notifyTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(200); executor.setThreadNamePrefix("notify-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setTaskDecorator(new MdcTaskDecorator()); executor.initialize(); return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new GlobalAsyncExceptionHandler(); }}5.2 MDC TaskDecorator
public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { try { if (contextMap != null) { MDC.setContextMap(contextMap); } runnable.run(); } finally { MDC.clear(); } }; }}5.3 Global Exception Handler
@Slf4jpublic class GlobalAsyncExceptionHandler implements AsyncUncaughtExceptionHandler { @Override public void handleUncaughtException(Throwable ex, Method method, Object... params) { String methodName = method.getDeclaringClass().getSimpleName() + "." + method.getName(); log.error("异步方法执行异常!方法:{},参数:{}", methodName, params, ex); try { AlertService.sendAlert("异步方法异常", String.format("方法:%s%n异常:%s%n参数:%s", methodName, ex.getMessage(), Arrays.toString(params))); } catch (Exception e) { log.error("发送告警失败", e); } }}5.4 Correct Usage Example
@Service@Slf4jpublic class OrderAsyncService { @Autowired private OrderTransactionalService orderTransactionalService; @Async("orderTaskExecutor") public void createOrderAsync(OrderDTO dto) { log.info("开始异步创建订单,订单号:{}", dto.getOrderNo()); try { orderTransactionalService.createOrder(dto); log.info("异步创建订单成功,订单号:{}", dto.getOrderNo()); } catch (Exception e) { log.error("异步创建订单失败,订单号:{}", dto.getOrderNo(), e); failOrderRepository.save(dto); // Fallback } }}Summary
Three Classic Pitfalls
Transaction Loss : @Async + @Transactional on same method → AOP order issue or self-invocation. Fix: split classes (async calls transactional cross-class) or programmatic transaction.
MDC Context Loss : MDC backed by ThreadLocal, async thread is new. Fix: TaskDecorator copies MDC before submit, sets in worker, clears after; or TTL for multiple contexts.
Silent Exception Swallowing : Void methods → AsyncUncaughtExceptionHandler (default logs only); Future methods → exception in Future, invisible without get(). Fix: global handler (log+alert), per-method try-catch for fallback, or return Future with caller handling.
Core Principles
@Asyncuses AOP proxy — same self-invocation/visibility issues as @Transactional Default thread pool unsafe — always customize in production
Async = another thread — all ThreadLocal contexts (MDC, SecurityContext, RequestContext) need explicit propagation
Async exceptions must be handled proactively — caller won't see them
Understanding @Async internals moves you from "add annotation" novice to senior developer who configures correctly and debugs precisely. Async boosts performance but misused introduces harder-to-trace issues. Mastering Spring async, thread pools, AOP proxy, context propagation is essential for backend advancement and incident prevention. This knowledge transfers to @Transactional, @Cacheable, @Retryable — all AOP-based annotations.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
