@Async 深度避坑:事务丢失、MDC 上下文丢失、异常静默吞掉完整修复

业务开发里,为了提升接口响应速度,我们经常用@Async把非核心逻辑异步化:短信推送、日志记录、消息投递、统计计算。

升级到 SpringBoot3 + JDK17/JDK21 之后,很多老项目直接沿用旧的@Async写法,线上出现一堆诡异问题:

  • 主方法事务提交成功,异步方法数据库操作不回滚、也不报错
  • 日志链路 ID 直接断了,异步线程打印日志没有 traceId,排查问题无从下手;
  • 异步抛出异常,主线程完全感知不到,错误直接被静默吃掉;
  • 开启虚拟线程后,默认线程池配置失效,异步任务全部跑在平台线程。

很多人以为加个@EnableAsync就万事大吉,实际上线程切换之后,事务、ThreadLocal、MDC、安全上下文全部不会自动传递

本文结合线上故障,拆解 4 个高频坑,给出可直接复制上线的解决方案。

前置知识:SpringBoot3 中 @Async 默认线程池为SimpleAsyncTaskExecutor,它不是真正的线程池,每次新建线程;JDK21 开启虚拟线程后,@Async 也需要单独适配。

@Async 只负责"换线程执行",不负责事务一致性、上下文复制、异常可靠投递。

真正根治要靠:明确事务边界 + 统一线程池装饰 + 可观察异常 + 关键任务持久化。

一、坑 1:@Async 导致事务丢失,异常不会回滚

❌错误 Demo

less 复制代码
@Service
public class AsyncWorker {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private AsyncBizService asyncBizService;

    @Transactional(rollbackFor = Exception.class)
    public void createOrder() {
        orderMapper.insertOrder(new Order());
        //异步调用
        asyncBizService.afterCreateOrder();
        int i = 1 /0;
    }
}

@Service
class AsyncBizService{
    @Async
    @Transactional(rollbackFor = Exception.class)
    public void afterCreateOrder(){
        //业务操作
    }
}

现象: 主方法抛出异常,主事务回滚。但是异步方法已经在另一个线程执行,属于全新独立事务,不受主线程异常影响

很多人误以为异步方法和主方法共用同一个事务,这是巨大误区。

事务绑定当前执行线程,@Async 会切换线程,事务上下文不会跨线程传递。

✅生产准则

  1. 不要期望异步方法和主方法共用事务
  2. 如果业务必须原子性,不能异步;
  3. 异步任务自己维护独立事务,失败要做重试、补偿、消息兜底,不要依赖主流程回滚。

给异步方法加事务只能创建一个新事务,不能与调用方组成同一个原子事务:

kotlin 复制代码
@Service
public class AsyncWorker {

    @Async("bizExecutor")
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public CompletableFuture<Void> handle(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        // 独立事务内处理
        return CompletableFuture.completedFuture(null);
    }
}

如果任务允许极小概率丢失,可以提交后再触发:

vbnet 复制代码
@Transactional
public void createOrder() {
    Order order = orderRepository.save(...);
    eventPublisher.publishEvent(new OrderCreated(order.getId()));
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderCreated event) {
    asyncWorker.handle(event.orderId())
        .whenComplete((unused, error) -> {
            if (error != null) {
                alarmService.report(event.orderId(), error);
            }
        });
}

这里最好只传 ID 或不可变 DTO,不要传 JPA Entity、HttpServletRequest、Session、EntityManager。 如果任务绝对不能丢,例如扣库存、发券、财务处理、通知外部系统,AFTER_COMMIT + @Async 依然不够。应使用:

bash 复制代码
业务事务
  ├─ 写业务表
  └─ 写 outbox_event 表
          ↓
后台消费者扫描并投递
          ↓
成功标记 / 失败重试 / 死信告警

业务数据和事件记录在同一个数据库事务里,消费者按事件 ID 幂等处理。这才消除了"提交成功但任务没入队"的崩溃窗口

二、坑 2:MDC 链路上下文丢失,日志 traceId 断裂(上下文必须显式传播)

MDC 底层基于ThreadLocal。 主线程设置的 traceId,切换到异步线程,ThreadLocal 不会自动复制,异步打印日志看不到链路 ID,线上排障非常痛苦。

❌现象: 接口请求日志有 traceId,@Async 内部输出日志 traceId 为空,链路直接断开。

✅解决方案:包装线程池,为线程池统一配置 TaskDecorator,任务提交时复制 MDC 上下文

java 复制代码
import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("bizAsyncExecutor")
    public Executor bizAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(200);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("biz-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

        //包装任务,复制MDC上下文
        executor.setTaskDecorator(runnable -> {
            Map<String, String> context = MDC.getCopyOfContextMap();
            return () -> {
                if(context != null){
                    MDC.setContextMap(context);
                }
                try {
                    runnable.run();
                }finally {
                    MDC.clear();
                }
            };
        });
        executor.initialize();
        return executor;
    }
}

使用的时候指定线程池:

typescript 复制代码
@Async("bizAsyncExecutor")
public void afterCreateOrder(){
    //业务
}

关键点是 finally 清理,否则线程复用时可能把 A 用户的上下文泄漏给 B 用户。不要用 InheritableThreadLocal 解决线程池传播,它只在线程创建时继承,无法正确应对线程复用。

其他上下文建议:

  • Spring Security:使用 DelegatingSecurityContextAsyncTaskExecutor
  • 日志与观测上下文:可使用 Micrometer ContextSnapshot
  • 租户、操作者等业务关键数据:优先作为任务参数显式传递,不能只依赖 ThreadLocal

注意:SpringSecurity 安全上下文、自定义 ThreadLocal 变量,同样需要在 TaskDecorator 里面手动拷贝。

三、坑 3:异步异常被静默吞噬,线上看不到报错(禁止业务级 @Async void

默认情况下,如果@Async返回 void,异步抛出异常,主线程捕获不到,日志里甚至看不到异常堆栈,故障悄无声息发生。

两种返回模式区别:

  1. void:异常交给异常处理器,主线程完全无感;
  2. Future<T>:调用 get () 才会抛出异常。

✅方案 1:实现 AsyncUncaughtExceptionHandler,统一捕获 void 返回异步异常

kotlin 复制代码
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            //日志告警,可接入监控告警
            System.err.printf("异步方法执行异常,method:%s%n",method.getName());
            ex.printStackTrace();
        };
    }
}

✅方案 2:重要异步逻辑尽量返回 Future,业务主动捕获

kotlin 复制代码
@Async("bizAsyncExecutor")
public Future<Void> asyncTask(){
    //业务
    return CompletableFuture.runAsync(()->{});
}

调用端必须观察结果

scss 复制代码
asyncService.sendMessage(command)
    .orTimeout(10, TimeUnit.SECONDS)
    .whenComplete((result, error) -> {
        if (error != null) {
            failureRecorder.record(command.id(), error);
            meterRegistry.counter("async.failed").increment();
        }
    });

仅仅返回 CompletableFuture 还不够------如果调用方直接忽略它,异常仍然等于没人处理。关键任务应该把执行状态持久化,并具备:

  • 超时;
  • 有上限的指数退避重试;
  • 幂等键;
  • 死信或人工补偿;
  • 失败指标和告警。

四、坑 4:SpringBoot3 JDK21 开启虚拟线程,@Async 默认不使用虚拟线程

开启spring.threads.virtual.enabled=true只是 web 请求用虚拟线程。 @Async 默认线程池依旧是平台线程,不会自动使用虚拟线程。

如果想要 @Async 使用虚拟线程,需要自定义虚拟线程执行器。

✅虚拟线程版 Async 执行器示例

kotlin 复制代码
@Bean("virtualAsyncExecutor")
public Executor virtualAsyncExecutor(){
    return Executors.newVirtualThreadPerTaskExecutor();
}

注意:虚拟线程异步任务,同样要处理 MDC 上下文拷贝;虚拟线程不代表无限并发,异步任务也要做好限流、队列保护。

另外几个常见暗雷

  • 同类内部调用 this.asyncMethod() 不经过 Spring 代理,@Async 不生效。
  • private 方法、手动 new 出来的对象,也不会生效。
  • thenApplyAsyncsupplyAsync 未指定线程池时,可能跑进公共 ForkJoinPool
  • 无界队列会把流量洪峰变成内存和延迟事故;线程池、队列必须有界。
  • CallerRunsPolicy 会让任务突然回到请求线程执行,可能重新卷入原事务;业务系统通常更适合显式拒绝、记录和降级。
  • 换成虚拟线程不会自动解决事务、上下文或异常问题。

最终可以按重要性选型:

  • 非关键日志、缓存预热:@Async + 有界线程池 + TaskDecorator
  • 只要求提交后执行、允许极小概率丢失:AFTER_COMMIT + @Async + 独立事务
  • 订单、库存、资金、外部通知等不能丢:Transactional Outbox/MQ + 幂等消费 + 重试与告警

五、生产编码 CheckList SpringBoot3 + JDK17/21

  • ✅禁止直接使用默认SimpleAsyncTaskExecutor,生产必须自定义线程池;
  • ✅清楚认知:@Async 会切换线程,事务不能跨线程传递,原子性业务不能异步;
  • ✅异步任务需要链路日志,必须通过TaskDecorator拷贝 MDC 上下文;
  • ✅void 返回的异步方法配置全局异步异常处理器,防止异常静默丢失;
  • ✅开启 JDK21 虚拟线程时,Web 请求与 @Async 是两套线程模型,不会自动互通;
  • ✅异步任务失败要有补偿机制:重试、消息队列、失败表记录,不要依赖主线程;
  • ✅ThreadLocal、Security 上下文等,跨异步线程都需要手动复制,不会自动继承。

六、AI 代码坑点

AI 生成 @Async 示例,大多只写@EnableAsync + @Async最简 demo。 完全忽略 MDC 丢失、事务隔离、异常吞噬等生产问题。复制之后本地测试正常,上线之后出现隐蔽故障。

@Async不是简单加一个注解就完事。 本质是线程切换,所有绑定在线程上的资源:事务、MDC、ThreadLocal、安全上下文全部失效。

线上出问题优先排查三点:

  1. 是否误以为异步和主流程共用事务;
  2. MDC 上下文有没有拷贝,日志链路是否断裂;
  3. 异步异常会不会被静默吞掉。

欢迎点赞收藏关注,持续更新线上踩坑实录。

相关推荐
随遇而安zx16 分钟前
【地基篇】---Java 8 JVM 知识大纲
java·开发语言·jvm
雾隐隐o17 分钟前
Docker 镜像归档与容器管理
java·docker·eureka
Meta3922 分钟前
Java八股文之Spring Boot 中解决 MySQL 和 Elasticsearch (ES) 的数据一致性问题
java·spring boot·mysql
liangbo732 分钟前
01-JVM 内存模型全景
java·jvm
橘子编程33 分钟前
Java邮件发送全攻略:从入门到实战
java·开发语言·spring boot·spring·spring cloud·maven
阿哉36 分钟前
一次 Lombok 静默失效排查:JDK 23 注解处理默认策略变更引发的满屏「找不到符号」
java
纪伊路上盛名在36 分钟前
了解一点Docker
java·docker·容器
码农阿豪39 分钟前
Seedance 2.0/2.5 虚拟素材能跨 Key 共用吗?一次讲清 Asset ID、账号隔离与 SaaS 素材架构
java·运维·架构
Meta3944 分钟前
Java八股文之Spring Boot中解决Redis和MySQL的数据一致性问题
java·spring boot·redis