大概上个月,我们内部的会员通知系统频繁出现接口超时告警。排查下来,罪魁祸首是一段全量推送逻辑——同步遍历几十万用户,逐个发站内信和短信。当时最简单的方案自然是直接加一个@Async,但问题比想象中多:一方面不想让业务调用方感知异步细节,另一方面还需要统一的线程池、异常兜底和调用链追踪。最后我选择了用Spring Boot AOP 异步执行方案:自定义一个注解,让切面统一接管方法提交到线程池的全过程。
这事做完以后,整个异步逻辑变得非常收敛,业务代码里几乎看不到线程池的影子。这篇文章我就把这套方案的思考过程、完整代码、以及踩过的一些坑完整讲一遍,给正在做类似需求的朋友一个可以直接参考的落地方案。
1. 为什么放着一等公民的 @Async 不用,非要自己写 AOP 切面?
先说结论:@Async本身也是基于代理机制实现的,它就是一个被 Spring 封装好的“半成品切面”。它很好用,但在稍复杂的业务场景里,它暴露出几个让我比较难受的问题。
1.1 @Async 看着简单,用起来别扭的三个点
第一个问题:侵入性其实很强。@Async必须标在具体方法上,而且要求方法一定是void或者返回Future、CompletableFuture这类类型。如果团队里有人不熟悉 Spring 的异步限制,随手写成一个返回对象的同步方法,然后调用方傻乎乎地等着返回值,那异步就形同虚设。更麻烦的是,一旦方法被同类内部调用,@Async会直接失效,因为此时走的是this引用,而不是 Spring 容器里的代理对象。
第二个问题:线程池和异常处理被割裂了。官方虽然支持通过@Async("executorName")指定线程池,但异常处理还是要靠自定义AsyncUncaughtExceptionHandler,而且对返回Future的方法场景,异常处理非常容易漏。实际在日志里看到的经常是“任务被吞了”,排查半天才发现是异步线程核心里抛了异常,没有写日志。
第三个问题:无法做统一的切面行为。比如我需要所有异步任务执行前后打印调用链 TraceID、统计执行耗时、失败后自动降级重试。用@Async的状态下,这些逻辑要么重复写在每一个业务方法里,要么再单独叠一层自定义注解配合@Around,那为什么不直接一步到位做一套自己的异步切面呢?
1.2 手写 AOP 到底能拿到哪些控制权
用 AspectJ 的@Around注解实现自定义异步逻辑之后,我们能控制的东西就多了不少:
- 决定目标方法是否丢进线程池(可以依据方法参数、类名、执行时间做判断)
- 决定返回给调用方的是一个空对象,还是一个
CompletableFuture - 统一捕获异常,按“忽略 / 记录 / 重试 / 降级”四种策略处理
- 在提交线程池之前做参数校验、埋点,在任务结束之后做资源清理
这些能力本质上是把 Spring 对异步的“封装控制权”交回到我们自己手里,如果只是用@Async,这些全都绕不过去。
1.3 和单独手写线程池相比,AOP 的价值在哪
有些人会说,我已经在 Service 里写了一个线程池工具类,每个方法直接executor.submit(() -> xxx())不就行了?我从实际项目经验告诉你:这样写,短期看着直接,长期维护会崩溃。同一个异步任务可能在多个调用方被重复提交,线程池参数散落在各处,而且线程池和业务方法的生命周期完全不可控。
AOP 的异步方案,是让“异步执行”变成一种可以被集中配置和治理的横切关注点。业务方法本身甚至不知道它会被异步执行,切面决定了它的执行模式。这个思想的本质,其实是 AOP 的核心价值:把通用逻辑从业务代码中剥离出来。
2. 三条实现路线:从注解切面到编程式代理,分别适合什么场景
我能想到的实现“AOP 异步”的方式,至少有三种。这里把它们全部放在一起对比,方便大家在实际场景里选择。
| 实现路线 | 核心做法 | 侵入性 | 灵活度 | 适用场景 |
|---|---|---|---|---|
| 自定义注解 + 切面 | 自建@AsyncTask注解,切面拦截注解 | 低,需要标注注解 | 高,可细粒度指定线程池、异常策略 | 绝大多数业务场景,推荐首选 |
| 切点表达式 + 切面 | 按类名 / 包名 / 方法名配置切点 | 极低,完全无注解 | 中等,粒度较粗糙 | 老旧系统改造,不方便加注解 |
编程式ProxyFactory | 直接利用 Spring AOP 的ProxyFactory创建代理对象 | 低,但代码复杂 | 最高,可实现高度定制 | 需要动态生成代理的中间件场景 |
2.1 路线一:自定义注解 + 切面
这是我最推荐的路由。它把“异步能力”变成一个显式声明的属性:哪个方法需要异步执行,就加上注解并指定线程池。业务代码里没有线程池实例,也没有Runnable,干净利落。
后面第三部分我会给出完整代码,这里先不展开。
2.2 路线二:切点表达式 + 切面
如果项目里有一批老代码,所有 Service 方法都是同步实现的,但又因为性能原因必须异步化,这时候逐方法加注解工作量大且容易漏。可以用 AspectJ 表达式直接切到某个包下:
@Aspect @Component public class BatchServiceAsyncAspect { @Around("execution(* com.example.service.BatchService.*(..))") public Object asyncWrapper(ProceedingJoinPoint joinPoint) throws Throwable { Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); if (method.getReturnType().equals(Void.TYPE)) { asyncExecutor.execute(() -> { try { joinPoint.proceed(); } catch (Throwable e) { log.error("异步执行失败: {}", joinPoint.getSignature(), e); } }); return null; } return CompletableFuture.supplyAsync(() -> { try { return joinPoint.proceed(); } catch (Throwable ex) { throw new RuntimeException(ex); } }, asyncExecutor); } }这种做法的优点是不用改动业务代码,缺点也很明显:一旦BatchService里新加了一个“必须同步执行”的方法,切面会无条件把它也变成异步,这会造成很隐蔽的逻辑错误。所以我一般只建议把这类切面用在专门的异步批处理 Service 上,做到“类级隔离”。
2.3 路线三:编程式 ProxyFactory
这个路线很少在业务代码里出现,更多用在一些基础中间件或 SDK 研发场景。它的本质是不依赖 Spring 注解扫描,直接手工创建一个代理对象:
public class AsyncProxyFactory { public Object createProxy(Object target, Executor executor) { ProxyFactory factory = new ProxyFactory(); factory.setTarget(target); factory.addAdvice((MethodInterceptor) invocation -> { if (invocation.getMethod().getReturnType().equals(Void.TYPE)) { executor.execute(() -> { try { invocation.proceed(); } catch (Throwable e) { throw new RuntimeException(e); } }); return null; } return CompletableFuture.supplyAsync(() -> { try { return invocation.proceed(); } catch (Throwable e) { throw new RuntimeException(e); } }, executor); }); return factory.getProxy(); } }ProxyFactory的优点是动态、灵活,可以在运行时为不同目标对象装配不同的异步执行器。缺点则是和 Spring 的生命周期管理结合不强,如果代理对象要纳入容器依赖,还得自己处理 Bean 注册。说实话,业务项目里用到这层的概率很低。
3. 核心实现:基于自定义注解的异步切面,代码逐段解读
现在把最完整的一套方案放上来,这个版本我已经在多个项目里跑过,稳定性和可维护性都经过了验证。场景设定为“异步发送用户通知”:外部调用NotificationService.sendNotification(userId, content),这个方法的内部逻辑包括短信通道、站内信通道,耗时较长,需要切面异步化。
3.1 引入基础依赖
我们用的是 Spring Boot 项目,引入 AOP 场景依赖即可:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>如果项目中有其他情况需要直接使用 AspectJ 注解,也可以增加aspectjweaver依赖,但 Spring Boot 的 AOP starter 通常已经足够。
3.2 自定义一个异步任务注解
定义注解时,我特意加上了executor和throwException两个属性。前者用来指定线程池 Bean 的名称,后者控制异常是继续抛出还是只记录日志:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface AsyncTask { /** * 指定执行该任务的线程池 Bean 名称 */ String executor() default "asyncTaskExecutor"; /** * 异步任务内部发生异常时,是否向外抛出 */ boolean throwException() default false; }为什么需要这两个参数?因为一个系统里往往有多个不同的线程池,有些任务是 IO 密集型,有些是计算密集型;IO 密集型的线程池核心线程可以设大一点,计算密集型则要限制并发数避免 CPU 争抢。如果不加这个属性,所有异步任务都挤在一个线程池里,相互干扰。
3.3 配置线程池
线程池的参数没有绝对标准,我给出的是一个比较稳妥的通用配置:
@Configuration public class AsyncTaskThreadPoolConfig { @Bean("asyncTaskExecutor") public ThreadPoolTaskExecutor asyncTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix("async-task-"); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }这里有两个容易被忽略的细节。
第一个是setWaitForTasksToCompleteOnShutdown(true),配合setAwaitTerminationSeconds(30),保证应用停机时能等待已提交任务执行完,而不是直接杀死线程导致数据丢失。
第二个是CallerRunsPolicy拒绝策略,我强烈不建议用默认的AbortPolicy。因为异步切面背后通常是不希望阻塞主流程的操作,如果线程池满了直接抛异常,会影响主链路;CallerRunsPolicy则是在线程池满时,把任务“退回”给调用方线程执行,压力越大时主线程承担一部分执行,反而是一种天然背压机制。
3.4 核心切面实现
这是整个方案最核心的部分:
@Aspect @Component public class AsyncTaskAspect { private static final Logger log = LoggerFactory.getLogger(AsyncTaskAspect.class); private final Map<String, Executor> executorMap; public AsyncTaskAspect(Map<String, Executor> executorMap) { this.executorMap = executorMap; } @Around("@annotation(asyncTask)") public Object handleAsyncTask(ProceedingJoinPoint joinPoint, AsyncTask asyncTask) throws Throwable { Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); Executor executor = executorMap.getOrDefault( asyncTask.executor(), executorMap.get("asyncTaskExecutor") ); if (executor == null) { log.warn("未找到线程池 executor[{}],该方法将同步执行:{}", asyncTask.executor(), joinPoint.getSignature()); return joinPoint.proceed(); } // 返回值为 void 的方法:提交后直接返回 null,异常只记录 if (method.getReturnType().equals(Void.TYPE)) { executor.execute(() -> { try { joinPoint.proceed(); } catch (Throwable e) { log.error("异步任务执行失败: {}", joinPoint.getSignature(), e); } }); return null; } // 有返回值的方法:提交后返回 CompletableFuture,供调用方获取结果 return CompletableFuture.supplyAsync(() -> { try { Object result = joinPoint.proceed(); return result; } catch (Throwable e) { if (asyncTask.throwException()) { throw new RuntimeException("异步任务执行异常: " + joinPoint.getSignature(), e); } log.error("异步任务执行失败: {}", joinPoint.getSignature(), e); return null; } }, executor); } }有两个点我会单独说明一下。
一是Map<String, Executor> executorMap的注入方式。Spring 容器会把所有Executor类型的 Bean 都注入到这个Map里,key 是 Bean 名称。在切面里再根据注解的executor属性做路由。这种方式避免了在切面里写死线程池名称,后续新增线程池只需加一个@Bean方法即可。
二是joinPoint.proceed()是在提交到线程池之后执行的,这意味着整个方法块都被异步化。需要注意,joinPoint对象本身是线程安全的,因为MethodInvocation持有的是目标方法、参数和拦截器链,这些数据不会因为切换线程而失效。但这也同时引出一个问题:如果原方法内部依赖RequestContextHolder、TransactionSynchronizationManager这类线程本地变量,异步线程里就目前拿不到,这部分我会在第 4 章详细讲处理方案。
3.5 业务侧的使用示例
定义好切面后,业务侧就非常轻量了:
@Service public class NotificationService { @AsyncTask(executor = "asyncTaskExecutor", throwException = true) public CompletableFuture<SendResult> sendNotification(Long userId, String content) { // 模拟短信渠道耗时 SendResult smsResult = smsClient.send(userId, content); // 模拟站内信渠道耗时 SendResult messageResult = messageClient.push(userId, content); // 聚合结果 return CompletableFuture.completedFuture(SendResult.merge(smsResult, messageResult)); } }调用方收到一个CompletableFuture,可以决定是同步等待结果,还是继续异步编排:
CompletableFuture<SendResult> future = notificationService.sendNotification(1001L, "您的订单已发货"); future.thenAccept(result -> log.info("发送完成: {}", result));这里要强调一个经验:返回值类型为CompletableFuture的异步方法,切面里去调用joinPoint.proceed()得到的结果其实是另一个CompletableFuture,所以切面内部把它作为外层返回值返回。看起来会嵌套,但由于CompletableFuture本身支持链式展开,不会影响调用方使用。
4. 关于异步切面,我踩过的五个高频坑和对策
代码写出来只是第一步,真正坑人的往往在细节里。下面这些内容,我把在实际项目中遇到的问题和最终解决办法都整理出来。
4.1 自调用失效:同类里 this 调用,切面根本不会执行
这是 AOP 最容易踩的坑。假设我们在NotificationService里加了一个公开方法,内部直接调用this.sendNotification(...),那么这里触发的只是普通 Java 方法调用,完全不会经过代理对象,切面自然也就不会生效。
在异步切面场景里,这个坑更加隐蔽。因为业务方法本身执行很快(它只是提交线程池),如果切面不生效,方法就会同步执行,慢的所有逻辑直接压到调用线程上,接口顿时又慢了。排查时,日志里看不出来任何异步迹象。
解决办法有三种。第一种是把需要异步的方法拆分到独立的 Service Bean 里,然后用@Autowired注入并调用;第二种是在原 Bean 中注入ApplicationContext,通过context.getBean(NotificationService.class)获取代理对象;第三种是开启exposeProxy并借助AopContext.currentProxy()。
// 方式二:通过 ApplicationContext 获取代理对象 @Autowired private ApplicationContext applicationContext; public void notifyBatch(List<Long> userIds) { NotificationService proxy = applicationContext.getBean(NotificationService.class); userIds.forEach(userId -> proxy.sendNotification(userId, "批量通知内容")); }我个人的习惯是优先用第一种方案,因为拆分 Bean 本身就是一种职责清晰化,而且对ApplicationContext的依赖更少,也更容易写单测。
4.2 ThreadLocal 上下文丢失:TraceID、登录用户从异步线程里消失了
异步切面把方法丢给线程池之后,主线程和异步线程就彻底隔离了,ThreadLocal里的数据在子线程里是拿不到的。最典型的场景就是日志系统中的 TraceID:主线程打完日志有 TraceID,异步线程里没有,整条调用链直接断掉,排查问题的时候只能盲猜。
我目前的常规对策是做一个透明的Runnable装饰器,在执行异步任务之前捕获主线程的上下文,并在任务执行完后清理,避免内存泄漏:
public class ContextAwareRunnable implements Runnable { private final Runnable delegate; private final Map<String, String> contextMap; public ContextAwareRunnable(Runnable delegate) { this.delegate = delegate; this.contextMap = new HashMap<>(); // 捕获当前主线程的上下文,这里以 MDC 为例 Map<String, String> mdcContext = MDC.getCopyOfContextMap(); if (mdcContext != null) { contextMap.putAll(mdcContext); } } @Override public void run() { MDC.setContextMap(contextMap); try { delegate.run(); } finally { MDC.clear(); } } }然后在切面里统一包装:
executor.execute(new ContextAwareRunnable(() -> { try { joinPoint.proceed(); } catch (Throwable e) { log.error("异步任务执行失败", e); } }));HTTP 请求参数、当前登录用户这类信息也可以用同样的思路封装。但是要注意,这类上下文传递本质是对线程池复用的一种妥协,千万不要试图把大对象、数据库连接这种重量级资源塞进ThreadLocal传递,很容易引发连接泄漏。
4.3 事务边界:注解式事务在异步线程里是失效的
这个坑踩得非常深刻。一开始我试图在一个服务方法上同时加@Transactional和自定义异步注解,结果发现事务根本不生效。
原因很简单:Spring 的事务管理依赖TransactionInterceptor,它通过ThreadLocal绑定事务资源。当方法被丢到异步线程执行后,DataSourceTransactionManager在主线程里开启的事务上下文和异步线程完全无关,加上切面先于事务拦截器生效,事务拦截器看到的是同步调用链,自然也不会在异步线程里开启新事务。
正确的做法是把事务边界和异步边界分开。我这里提供一个实践过的模式:外层方法异步,内层独立事务。将需要事务保护的一段逻辑单独抽到一个 Bean 的方法里,用REQUIRES_NEW传播行为开启独立事务:
@Service public class NotificationDataService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveSendRecord(SendRecord record) { // 插入发送记录,异步线程内独立提交 } }这样短信发送完成后,异步线程调用dataService.saveSendRecord(record),事务就能在子线程里正常工作。如果你确实需要在异步方法里开启事务,也可以使用编程式事务模板TransactionTemplate,不过代码会明显变啰嗦,非特殊场景不建议。
4.4 线程池停机丢任务:应用重启,正在排队的任务全没了
如果线程池没有配置优雅关闭,应用在重启时,已经提交到队列里但还没执行的任务会被直接丢弃,更严重的是正在执行的任务也可能被中断。这类问题在生产环境非常难以发现,因为日志里没有任何异常,只有业务侧发现“那会儿的发送记录缺失了一部分”。
前面 3.3 节的配置里已经加了优雅关闭参数,这里再补充两个辅助手段。
一个是通过@PreDestroy显式关闭线程池,另一个是在停机前预留缓冲时间:
@PreDestroy public void destroy() { if (asyncTaskExecutor != null) { asyncTaskExecutor.shutdown(); } }同时,我还会在异步切面里对RejectedExecutionException做兜底,打一条带业务标识的日志,至少保证失败任务有迹可查。如果业务本身要求“一个都不能丢”,那 AOP 本地线程池可能就不是最佳选择了,应该考虑持久化消息队列方案。
4.5 返回值回调:主线程到底怎么拿到结果?
异步切面会将有返回值的方法包装成CompletableFuture。这里有个典型的判断点要提前想清楚:调用方是否真的需要结果。
如果不需要,那方法返回类型设计为void,切面里直接用executor.execute,这种方式最轻量。如果需要,那切面就返回CompletableFuture<T>,调用方可以选择future.get()阻塞等待,也可以thenAccept做异步回调。后者体验会好很多,不会因为等待结果又把它变回同步。
我建议是,凡是使用这套 AOP 异步方案的接口,返回值统一用CompletableFuture,调用方能够非常直观地感到“这个接口是异步的”,不至于想当然地同步等待。
5. 异步切面的适用边界,什么时候该换 MQ 或事件机制?
最后这部分也是我特别想聊的。AOP 异步方案不是万能的,它是一个“本地内存级”的异步能力,有天然的适用边界。
5.1 本地线程池的容量边界:消息堆积、持久化、分布式
线程池的队列也好,工作线程也好,都只存在于当前 JVM 进程内。一旦任务提交量超过线程池最大处理能力,CallerRunsPolicy虽然会触发背压,但调用线程也会被拖慢,最终可能把 Web 容器的线程池占满,整个应用不可用。
另外,本地线程池不提供任何持久化机制,应用宕机任务直接消失;也不具备跨实例协调能力,多个应用实例同时执行时,缺少天然的互斥和拆分机制。如果业务上要求异步任务具备“可靠投递、失败重试、分区处理”这些特性,那正确归宿一定是消息队列,AOP 异步只能作为低并发、允许丢失的部分场景里的轻量方案。
5.2 事件机制和 AOP 异步的配合
Spring 的@EventListener配合@Async也能实现解耦异步,而且语义更自然。AOP 异步适合的是“某个业务方法想让它异步执行”;事件机制则适合“某个业务动作完成之后,通知其他模块去做后续处理”。
我经历过一个项目,用户注册之后要做一系列动作:发欢迎短信、初始化默认配置、发送积分通知。这些动作和“用户注册成功”之间是典型的事件关系,用@TransactionalEventListener+@Async实现会比硬套 AOP 异步舒服得多。但在处理“批量导出”“批量推送”这类方法级任务时,AOP 异步又是最趁手的工具。两者不是替代关系,而是适用场景不同。
5.3 我的落地经验总结
在决定是否引入 AOP 异步时,我会先用三个条件过滤一下:任务是否可以接受进程重启后的丢失?任务是否允许被多个实例重复执行?任务是否只关心执行成功,不需要同步等待结果?如果三个条件都符合,那才值得用这套轻量方案;只要有一个不符合,就该重新评估 MQ 或者分布式调度框架。
这个方案上线之后,异步逻辑的改动量非常小。新加一个异步任务,只需要标注注解并选择一个合适的线程池,后续想给全部异步任务加埋点监控,也只需要在切面类里加一段逻辑就行。对我来说,这种“横向扩展”带来的维护价值,已经远远超过了刚开始封装那点成本。如果你也在 Spring Boot 项目里遇到了类似的异步需求,不妨从这套方案开始试试,把线程池参数和异常策略都控制在自己手里,比到处散落@Async要踏实得多。