@Async 静默失效:自调用、异常黑洞与 MDC 丢失
导出改成异步,接口 180ms 就返回,任务却从没跑过。自调用绕过代理、void 返回值吞掉异常、默认执行器每次新建线程,三个问题互相掩护。修好之后 traceId 又丢了。
导出的接口原来要跑四十多秒,前端一直转圈,网关那边时不时报 504。产品要求「点了就返回,后台慢慢跑,跑完给我个通知」。这是个很标准的异步改造,我在 Service 上加了个 @Async,方法名前面加了注解,接口耗时立刻降到 180ms。
第二天运营说:导出的文件一直没生成。我去看日志,没有任何异常,也没有任务开始的记录,就像那个方法从来没被调用过。
一、先确认它到底跑没跑
排查第一件事是加日志,但这里有个陷阱:我在 @Async 方法的第一行打了 log.info,也在调用方加了 log.info。日志里只有调用方那行,被调的方法一行都没有。
这就排除了「跑了但失败了」,是「根本没执行」。而调用方没有抛异常,说明调用这一步是正常返回的——一个方法调用了,返回了,但方法体没跑。
在 Java 里能造成这种效果的东西不多,最常见的就是动态代理。Spring 的 @Async 是靠代理实现的:容器给你的 bean 不是原始对象,而是一个代理,代理在方法调用前后插入「把任务丢给线程池」的逻辑。如果调用根本没经过代理,注解就只是一行注释。
二、自调用:注解为什么变成注释
我们的代码大概是这样:
@Service
public class ExportService {
public String createTask(Long userId, ExportQuery q) {
String taskId = idGen.next();
// 直接 this 调用,看着很正常
this.doExport(taskId, userId, q);
return taskId;
}
@Async
public void doExport(String taskId, Long userId, ExportQuery q) {
log.info("export start {}", taskId);
// 真正干活的逻辑
}
}
this 指向的是原始对象,不是代理。所以 this.doExport(...) 直接进了方法体——但同时也没有任何线程池参与,它本该是同步执行的,为什么会「没跑」?
因为我把 doExport 声明成了 void,而且在 createTask 里没有等待。方法体其实跑了,只是……不对,日志里没有。
真实原因更细一点:那版代码里 doExport 的第一行是 if (!lock.tryLock()) return;,用的是本地 ReentrantLock,而 createTask 在更早的地方已经拿到了同一把锁(我当时想的是「防止重复提交」),于是 doExport 一进来就 tryLock 失败,直接 return。方法确实执行了,但什么都没做,也没打日志——因为我把日志打在了 tryLock 之后。
这两件事叠在一起,才造成了「注解没生效」的假象。真实情况是:代理确实没生效(自调用),而方法体也确实空跑了(锁冲突),两个 bug 互相掩护。
教训很直接:@Async、@Transactional、@Cacheable 这一类基于代理的注解,在同一个类内部 this.xxx() 调用时一律失效。要让它生效,必须满足两个条件之一——从另一个 bean 调用,或者注入自己:
@Service
public class ExportService {
@Lazy
@Autowired
private ExportService self; // 注入的是代理,不是 this
public String createTask(Long userId, ExportQuery q) {
String taskId = idGen.next();
self.doExport(taskId, userId, q); // 走代理
return taskId;
}
}
@Lazy 是为了绕开循环依赖。更干净的做法是把异步方法拆到独立的 ExportExecutor 里,职责也更清楚。
| 失效场景 | 现象 | 验证方式 |
|---|---|---|
同类内 this 调用 |
方法同步执行,注解无效 | 断点看线程名是不是 http-nio-* |
方法是 private / final |
代理无法覆写 | 改成 public,或看启动日志有没有 CGLIB 报错 |
| 类没被 Spring 管理 | 完全是普通对象 | applicationContext.getBean() 取一下 |
异常被吞掉(void 返回) |
无日志、无堆栈 | 配 AsyncUncaughtExceptionHandler |
验证方法很简单:在方法里打一行 Thread.currentThread().getName()。异步方法应该跑在 task-* 这类线程上,如果打出来是 http-nio-8080-exec-3,就说明代理没生效。
三、异常去哪了:void 返回值的黑洞
修好自调用之后,任务能跑了,但又有新的诡异现象:部分任务卡在「处理中」再也不动,日志里干干净净。
原因还是 void。@Async 方法如果返回 void,抛出的异常没有任何调用方可以接收,Spring 只能交给 AsyncUncaughtExceptionHandler,而这个 handler 默认实现只是打一条 ERROR 日志。如果项目里重写过日志配置、把某个 logger 级别调高了,这条日志就彻底消失了。
我们的情况更糟:异常是在 CompletableFuture 里抛的,void 方法内部又用了 .join(),异常被包成 CompletionException,日志里那一行只打了 null。
改法有两个层次。第一层是把返回值改成 CompletableFuture<Void>,让异常能被调用方观察到;第二层是显式配置异常处理器,保证任何漏网的异常都有完整堆栈:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return executor();
}
@Bean("exportExecutor")
public ThreadPoolTaskExecutor executor() {
ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
e.setCorePoolSize(4);
e.setMaxPoolSize(8);
e.setQueueCapacity(200);
e.setThreadNamePrefix("export-");
e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
e.setWaitForTasksToCompleteOnShutdown(true);
e.setAwaitTerminationSeconds(30);
e.initialize();
return e;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("async failed: {}.{} params={}",
method.getDeclaringClass().getSimpleName(), method.getName(),
Arrays.toString(params), ex);
};
}
}
CallerRunsPolicy 这个拒绝策略值得说一句:队列满了之后,任务会在调用者线程(也就是 Tomcat 线程)里同步执行。这看起来是「降级成同步」,但它起到了背压作用——Tomcat 线程被占住,新的请求进不来,自然不会继续把队列撑爆。比 AbortPolicy 直接拒绝、用户看到 500 要好。
四、默认执行器:不是线程池的线程池
在配 @Bean 之前,@Async 用的是 SimpleAsyncTaskExecutor。这个名字很有迷惑性,它其实不是线程池:每次调用都新建一个线程,没有复用、没有上限、没有队列。
我们那次压测就是被它坑的:500 个导出请求进来,瞬间起了 500 个线程,每个线程都在读数据库、拼 Excel、写文件,内存直接冲高,GC 时间飙升,整个应用跟着变慢。而 SimpleAsyncTaskExecutor 的 concurrencyLimit 默认是 -1,也就是不限制。
这也是为什么一定要显式声明执行器。Spring Boot 2.1 之后,如果容器里有唯一的 TaskExecutor bean,@Async 会优先用它;否则退回到 SimpleAsyncTaskExecutor。而我们项目里恰好有多个 TaskExecutor(还有 spring.task.execution 那套),所以必须用 @Async("exportExecutor") 指定名字,不能指望自动装配。
五、TraceId 丢了:ThreadLocal 不跟着线程走
异步化之后,日志平台上的任务日志变成孤立的,没有 traceId,串不到原始请求。原因很基础但很容易忘:MDC 底层是 ThreadLocal,而 ThreadLocal 是线程私有的。任务从 Tomcat 线程交给 export- 线程,MDC 里的东西不会跟过去。
解法是用 TaskDecorator 在任务提交时把上下文抄一份,执行时恢复,结束后清理:
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> ctx = MDC.getCopyOfContextMap();
String traceId = MDC.get("traceId");
return () -> {
try {
if (ctx != null) MDC.setContextMap(ctx);
MDC.put("traceId", traceId == null ? "async-" + UUID.randomUUID() : traceId);
runnable.run();
} finally {
MDC.clear(); // 线程会被复用,不清就会污染下一个任务
}
};
}
}
然后把 executor.setTaskDecorator(new MdcTaskDecorator()) 加进配置。
finally 里那句 MDC.clear() 不是可选项。线程池的线程是复用的,如果不清,下一个任务会带着上一个任务的 traceId 写日志,排查时会被误导到一个完全无关的请求上。这类「日志串味」比丢 traceId 更难查。
六、异步之后,事务边界也得重画
最后一件事,也是最容易被忽略的:@Async 方法不在调用方的事务里。
我们原来的逻辑是「创建任务记录 + 提交异步执行」在同一个事务里。改成异步之后,如果异步方法执行得比事务提交还快,它去查那条任务记录,查不到——因为事务还没提交。这个 bug 出现的概率不高,但一旦出现就是「偶发任务找不到」。
正确的顺序是:先在事务里把任务记录落库并提交,事务提交之后再触发异步。用 TransactionSynchronizationManager 或者 @TransactionalEventListener(phase = AFTER_COMMIT) 都能做到。我们用的是后者:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTaskCreated(TaskCreatedEvent e) {
exportExecutor.doExport(e.getTaskId()); // 此时记录一定已提交
}
小结
@Async靠代理实现,同类内this.xxx()调用不经过代理,注解直接失效;用注入自身或拆到独立 bean 解决。private、final方法无法被代理覆写,同样失效;用线程名验证是否真的跑在异步线程上。void返回值会让异常无处可去,默认处理器只打一行日志;改成CompletableFuture并显式配置AsyncUncaughtExceptionHandler。- 不指定执行器时会退到
SimpleAsyncTaskExecutor,它每次新建线程、没有上限,高并发下比同步更危险。 - MDC 基于
ThreadLocal,跨线程必须用TaskDecorator传递,并且一定要在finally里clear,否则线程复用时日志会串。 - 异步方法不参与调用方事务,需要「先提交再异步」,用
@TransactionalEventListener(AFTER_COMMIT)保证顺序。
正在加载评论…