2269 字
约 7 分钟
17
S07-A18 · `@Async` 的四个坑:注解生效了吗、异常去哪了

一个"发送通知"的方法加了 @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. 自测

  1. 自调用为什么会让 @Async 失效?它造成的后果为什么在测试里看不出来?
  2. 默认线程池的两个问题是什么?为什么"无界队列"比"拒绝"更危险?
  3. void 方法与 CompletableFuture 方法的异常分别去哪了?为什么"没人 get"是常态问题?
  4. 哪四种上下文不会自动传递?为什么"异步日志缺 traceId"影响排障?
  5. "事务提交后再异步"为什么需要事务同步回调?直接加 @Async 会怎样?
  6. 判断"该用 @Async 还是消息队列"的那个问题是什么?
S07-A18 · `@Async` 的四个坑:注解生效了吗、异常去哪了
https://www.clxhxhhr.top/posts/6530/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。