业务开发里,为了提升接口响应速度,我们经常用@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 会切换线程,事务上下文不会跨线程传递。
✅生产准则
- 不要期望异步方法和主方法共用事务;
- 如果业务必须原子性,不能异步;
- 异步任务自己维护独立事务,失败要做重试、补偿、消息兜底,不要依赖主流程回滚。
给异步方法加事务只能创建一个新事务,不能与调用方组成同一个原子事务:
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,异步抛出异常,主线程捕获不到,日志里甚至看不到异常堆栈,故障悄无声息发生。
两种返回模式区别:
void:异常交给异常处理器,主线程完全无感;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出来的对象,也不会生效。thenApplyAsync、supplyAsync未指定线程池时,可能跑进公共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、安全上下文全部失效。
线上出问题优先排查三点:
- 是否误以为异步和主流程共用事务;
- MDC 上下文有没有拷贝,日志链路是否断裂;
- 异步异常会不会被静默吞掉。
欢迎点赞收藏关注,持续更新线上踩坑实录。