一个"发送通知"的方法加了 @Async,测试通过、日志正常,只是线上偶发"通知没发出去"。查下来发现三件事:注解因同类自调用没生效(同步执行,阻塞了主流程)、异步线程池的队列满了(任务被静默丢弃)、异步里抛的异常根本没进日志(因为没有配置异常处理器)。
@Async 是"看起来最省事、最容易半生效"的注解之一。这四个坑有个共同特征:没有一个会报错,它们只是"行为与你以为的不同"。所以在 code review 里也很难看出来——代码形态是对的,只是注解没生效。
它的坑集中在四个地方:代理、线程池、异常、上下文。
1. 坑一:自调用与代理
| 写法 | 是否异步 |
|---|---|
| 从别的 Bean 调用 | 是 |
同类内 this.method() |
否 |
| 同类内注入自己再调 | 是(可用但不优雅) |
| 私有方法 | 否 |
第一项与第二项的差别来自"代理":@Async 依赖 Spring 生成代理,而自调用不经过代理,于是注解被静默忽略。
后果不是报错,而是"看起来工作了"——同步执行、阻塞调用方第二项(自调用)有一个"更隐蔽"的版本:跨 Bean 调用但用了 new 出来的对象(不是注入的代理),于是注解同样失效。而这类写法在"工具类封装"里很常见。
,而在测试里因为没测吞吐,完全看不出来。
// 反例:自调用,注解失效(实际同步执行)
public void handle() { sendAsync(); } // 走 this,不经过代理
@Async
public void sendAsync() { /* 实际是同步执行的 */ }
// 正解一:拆到另一个 Bean
@Service class Notifier { @Async public void send() {...} }
@Service class Handler { Handler(Notifier n) {...} n.send(); } // 走代理
// 正解二:注入自己(可行但需注意循环依赖)
@Lazy @Autowired Handler self;
public void handle() { self.sendAsync(); }
2. 坑二:默认线程池
| 未配置时 | 后果 |
|---|---|
| 使用默认执行器 | 单线程(Spring Boot 2.1+ 前的行为差异) |
或 SimpleAsyncTaskExecutor |
每次新建线程,无线程上限 |
| 队列无界 | 任务堆积,OOM |
第一项(默认执行器)的行为在不同 Spring Boot 版本里变过:早期版本每次新建线程,于是"线程数随任务数增长"——而它在低峰期完全正常,只在压力上来时爆。
第三项最危险:默认队列常常是无界的,于是"任务堆积"不会拒绝、不会告警,只会让内存缓慢上涨,直到某天 OOM。
所以 @Async 必须配一个显式配置的线程池(有界队列 + 拒绝策略)。第三项(拒绝策略)的选择要按"任务可丢性":通知类任务用 CallerRuns(对调用方形成背压),埋点类可用 Discard(丢也无所谓)。而"默认的 Abort"会抛异常,反而最容易被发现——所以它未必是坏选择。
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("notifyExecutor")
public Executor notifyExecutor() {
var q = new ArrayBlockingQueue<>(1000); // 有界
var ex = new ThreadPoolExecutor(8, 8, 60, SECONDS, q,
new ThreadFactoryBuilder().setNameFormat("notify-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()); // 满了由调用方执行(有背压)
return ex;
}
}
// @Async("notifyExecutor") 显式指定,避免落到默认池
3. 坑三:异常去哪了
| 异常位置 | 去向 |
|---|---|
返回 void 的方法 |
AsyncUncaughtExceptionHandler |
返回 Future/CompletableFuture |
封装在 Future 里 |
| 未配置 handler | 只打一行 WARN(默认)或完全丢失 |
第一项还有一个"版本"差异:Spring 4.1 之前 void 方法的异常直接被吞(连 WARN 都没有),所以"异步任务失败无感知"在遗留系统里非常常见。
第一项最麻烦:void 方法的异常没有"接收者",所以默认只在日志里留一句 stub,且不含业务上下文。
第二项也不理想:CompletableFuture 若没人 get(),异常就被静默持有第二项的正确用法是"链上 exceptionally 或 whenComplete",把错误转成日志或补偿动作,而不是让它躺在 Future 里。
——而"没人 get"在发通知这类场景里是常态。
// 必须配:异常处理器(否则等于把错误吞掉)
@Configuration
public class AsyncErrorConfig implements AsyncConfigurer {
@Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("async failed method={} params={}", method.getName(), params, ex);
}
}
// 注意:它只在"未捕获异常"时生效;捕获后不抛的异常它管不到
4. 坑四:上下文与事务
| 上下文 | 是否传递 |
|---|---|
ThreadLocal(如用户、traceId) |
否 |
SecurityContext |
否(默认) |
| 事务 | 否(异步在新线程,事务已结束) |
| MDC 日志上下文 | 否 |
第一项与第四项导致"异步日志里没有 traceId"——于是一次请求的日志被拆成两段,无法关联。
第三项的机制值得说清:异步线程用的是另一个数据库连接,而它看不到主线程未提交的修改(隔离级别使然)。所以"异步里查不到刚写入的数据"不是 bug,是隔离性的必然结果。
第三项导致"异步任务里读不到未提交的数据":主线程事务还没提交,异步线程就开始了,于是它读到旧值。
// 手动传递关键上下文(最可靠)
var ctx = RequestContextHolder.getRequestAttributes();
var traceId = MDC.get("traceId");
executor.submit(() -> {
RequestContextHolder.setRequestAttributes(ctx);
MDC.put("traceId", traceId); // 否则日志断链
try { doWork(); } finally { MDC.clear(); }
});
5. 什么时候该用异步
| 场景 | 适合 |
|---|---|
| 通知、埋点、日志上报 | 适合(可丢) |
| 图片/文件处理 | 适合(耗时长) |
| 事务内的数据一致性操作 | 不适合 |
| 需要立即结果的计算 | 不适合 |
第三项是最常见的误用:"事务提交后再异步"需要"事务同步回调"(TransactionSynchronization)第三项还有一个"事件"版本:用 @TransactionalEventListener(phase = AFTER_COMMIT) 比手写回调更简洁,而且它天然带"只在该事务里触发"的语义。
,而不是简单地加 @Async。
// 事务提交后再执行(否则异步线程可能读到未提交数据)
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { notifier.send(orderId); }
});
6. 排错:五种"@Async 没生效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 主流程仍然被阻塞 | 自调用/私有方法 | 拆 Bean 或注入自己 |
| 任务被静默丢弃 | 有界队列 + 丢弃策略 | 改 CallerRuns 或加告警 |
| 内存缓慢上涨 | 无界队列 | 设有界 + 监控队列长度 |
| 异步里报错无日志 | 未配异常处理器 | 配 AsyncConfigurer |
| 异步日志缺 traceId | 上下文未传递 | 手动传递 |
第三项(内存上涨)的监控手段很直接:把队列长度与活跃线程数作为指标上报。队列长度持续增长是"消费不过来"的最早信号——比 OOM 早几十分钟。
第四项最容易被忽略:代码"看起来没错",只是错误没有出现在任何地方。
7. 边界:什么情况不必用 @Async
| 场景 | 为什么 | 做法 |
|---|---|---|
| 任务很快(< 10ms) | 异步开销更大 | 同步即可 |
| 需要强一致(同事务) | 异步破坏一致性 | 同步或事务回调 |
| 已有消息队列 | MQ 更可靠(可重试、可持久) | 发消息 |
| 单机脚本 | 无并发需求 | 同步 |
判断口径一句话:这个任务失败了,能不能接受"没人知道"。最后一条与"选择"有关的边界:已经有 MQ 的系统里,@Async 通常应当被替换掉。因为 MQ 自带持久化、重试、死信,而"线程池 + 内存队列"这三样都要自己补——补到最后会发现不如直接用 MQ。
能(埋点、通知),@Async 够用;不能(有业务后果),就该用消息队列——因为它有持久化、重试与死信。
8. 自测
- 自调用为什么会让
@Async失效?它造成的后果为什么在测试里看不出来? - 默认线程池的两个问题是什么?为什么"无界队列"比"拒绝"更危险?
void方法与CompletableFuture方法的异常分别去哪了?为什么"没人 get"是常态问题?- 哪四种上下文不会自动传递?为什么"异步日志缺 traceId"影响排障?
- "事务提交后再异步"为什么需要事务同步回调?直接加
@Async会怎样? - 判断"该用
@Async还是消息队列"的那个问题是什么?