第十五章. common-core 核心配置:@Async 异步调用详解
第十五章. common-core 核心配置:@Async 异步调用详解
Prorise第十五章. common-core 核心配置:@Async 异步调用详解
摘要:本章我们将深入 Spring 的 @Async 异步调用体系。我们将分析 RVP 5.x 如何 摒弃 旧的 AsyncConfig,转而使用 Spring Boot 3+ 的 自动化配置(spring.task.execution)来管理 @Async 线程池。最后,我们将实战“@Async 内部调用失效”的经典陷阱及其解决方案。
在上一章(第十四章)中,我们深入剖析了 RVP 的 ThreadPoolConfig,从七大参数到 RVP 5.x 源码中 ScheduledExecutorService 的配置细节。我们最终掌握了 execute、submit 和 schedule 这三种 手动 调用线程池的方式。
手动调用固然灵活,但在日常开发中,我们更希望有一种“更优雅”的方式。
@Async 注解就是 Spring 提供的答案。它允许我们 不 再手动注入 Executor 并调用 execute(),而是通过一个 简单的注解,就能让方法“自动”地在后台线程池中异步执行。
本章,我们将 严格基于 RVP 5.x 的最新源码,重新梳理 @Async 的配置体系,并深入探究 @Async 最著名 的“内部调用失效”陷阱及其解决方案。
本章学习路径

15.1. RVP 5.x 异步架构的演进
要理解 RVP 5.x 的配置,我们必须先知道它“抛弃”了什么。
15.1.1. 【RVP 4.x 回顾】AsyncConfigurer 的手动绑定时代
在 Spring Boot 3 之前,@Async 注解默认是 不会 自动关联到我们 ThreadPoolConfig 里创建的那个线程池的。
为了让 @Async 使用我们自定义的线程池,RVP 4.x 必须:
ThreadPoolConfig.java:手动创建一个ThreadPoolTaskExecutor类型的 Bean,并命名(例如threadPoolTaskExecutor)。AsyncConfig.java:创建一个新类,实现AsyncConfigurer接口,并重写getAsyncExecutor()方法,手动 将@Async注解的默认执行器指向threadPoolTaskExecutor这个 Bean。
这个过程需要两个类,配置繁琐且分散。
15.1.2. 【RVP 5.x 演进】Spring Boot 3+ 的“自动化”时代
RVP 5.x 采用了 Spring Boot 3+ 的 自动化配置,彻底抛弃了 AsyncConfig.java。
这是一个巨大的简化。现在,Spring Boot 允许我们直接在 application.yml 中配置 @Async 的默认线程池。AsyncConfigurer 接口及其“手动绑定”的时代已经过去了。
15.2. RVP 5.x @Async 自动配置解析
我们来看 RVP 5.x 中让 @Async 跑起来的 两个 关键文件。
15.2.1. application.yml:spring.task.execution 属性详解
第一个文件是 ruoyi-admin/src/main/resources/application.yml:
1 | spring: |
spring.task.execution 是 Spring Boot 3+ 原生 的异步线程池配置命名空间。
thread-name-prefix: async-:- 作用:设置
@Async默认线程池的 线程名前缀。 - 分析:这非常重要。当我们在日志中看到
[async-1]这样的线程名时,我们就立刻知道这个任务是由@Async自动调度的,而不是由我们上一章配置的[schedule-pool-1]调度的。
- 作用:设置
mode: force:- 作用:强制 Spring Boot 总是 初始化它自己的
TaskExecutorBean。 - 分析:这确保了
@Async有一个 专属 的、与 RVPScheduledExecutorService(用于定时任务)隔离 的线程池。
- 作用:强制 Spring Boot 总是 初始化它自己的
15.2.2. @EnableAsync 配置解析
第二个文件是 ruoyi-common-core 中的配置类:
文件路径:ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/config/ApplicationConfig.java
1 |
|
@EnableAsync:这是 开启异步功能 的“总开关”。没有这个注解,Spring Boot 会完全 忽略@Async注解。proxyTargetClass = true:这是 强制 Spring AOP 使用 CGLIB 代理。- 为什么必须是 CGLIB? Spring AOP 有两种代理方式:
- JDK 动态代理:要求目标类 必须 实现一个接口。
- CGLIB 代理:通过 继承 目标类来创建代理,不需要 接口。
@Async的原理是 AOP 代理。如果我们不强制proxyTargetClass = true,并且我们的SysLogininforServiceImpl没有 实现ISysLogininforService接口,那么 Spring 将 无法 为它创建 JDK 代理,导致@Async完全失效。true确保了@Async能够应用于 RVP 中的 所有 Bean,无论它们是否实现了接口。
- 为什么必须是 CGLIB? Spring AOP 有两种代理方式:
15.2.3. @Async 的 AOP 代理原理
当我们配置了 @EnableAsync 后,Spring 的 AOP 机制就开始工作了:
- 扫描:Spring 启动时,
AsyncAnnotationBeanPostProcessor(一个后置处理器)会扫描所有 Bean,查找@Async注解。 - 创建代理:当它在
AsyncService上找到@Async方法时,它 不会 直接将AsyncService实例放入容器。 - 包装:它会创建一个
AsyncService的 CGLIB 代理对象(AsyncService$$Proxy),这个代理对象 包装 了真实的AsyncService实例。 - 注入:当
AsyncController注入@Autowired private AsyncService asyncService时,它获取到的是这个 代理对象。 - 拦截:当
Controller调用asyncService.method1()时:- 调用被 代理对象(
AsyncService$$Proxy)的AsyncExecutionInterceptor(异步执行拦截器)拦截。 - 拦截器 不会 立即执行
method1。 - 它将“调用
method1”这个动作打包成一个Callable或Runnable任务。 - 它将这个任务提交给
spring.task.execution(async-线程池)去执行。 - 拦截器 立即 向
Controller返回null(如果方法是void)或一个Future(如果方法有返回值)。
- 调用被 代理对象(
- 结果:
Controller(主线程) 认为方法已“执行”完毕并立即返回,而method1的真正逻辑此时才在async-(子线程)中开始执行。
15.3. 【RVP 真实场景】@Async 在 RVP 中的应用
@Async 在 RVP 框架中有大量应用,最典型的就是“异步写日志”。
15.3.1. 源码追踪:SysLogininforServiceImpl.recordLogininfor()
我们可以在 system 模块中找到这个真实案例。
文件路径:ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysLogininforServiceImpl.java
1 |
|
15.3.2. 痛点分析:为何“记录登录日志”必须异步?
这个场景完美诠释了 @Async 的价值:
- 主流程:
SysLoginService.login()是用户登录的核心流程,它必须 尽快 返回 Token,让用户登录。 - 非核心流程:
recordLogininfor()只是一个“审计”功能(记录日志),它涉及一次数据库INSERT操作。 - 痛点:
INSERT操作会涉及数据库连接、网络 IO、磁盘写入,可能耗时 20ms 到 100ms 不等。如果让login()主流程 同步 等待这 100ms,用户的登录体验会明显变差。 @Async解决方案:login()方法调用recordLogininfor()时,AOP 代理会 立即 返回,login()方法可以继续执行并返回 Token。而baseMapper.insert()的数据库操作,会“稍后”在async-线程池中执行,完全不阻塞 用户的登录流程。
15.4. 【二开核心】@Async 内部调用失效(The “AOP Trap”)
这个陷阱是 Spring AOP 中最常遇到的问题,@Transactional 事务注解也会有同样的问题。
15.4.1. 测试准备:创建 AsyncController 和 AsyncService
为了完整复现这个陷阱,我们不能只使用 main 方法,必须在 Spring 容器中进行。我们将在 ruoyi-demo 模块中创建 AsyncController 和 AsyncService。
文件路径:ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/service/AsyncService.java
1 | package org.dromara.demo.service; |
文件路径:ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/controller/AsyncController.java
1 | package org.dromara.demo.controller; |
15.4.2. 【正确演示】Controller -> Service.@Async (AOP 代理生效)
首先,我们演示 正确 的调用方式:Controller (外部类) 调用 Service (内部类) 的 @Async 方法。
在 AsyncController.java 中添加 test1 接口:
1 | // 位于 AsyncController.java |
验证:
- 启动
DromaraApplication。 - 调用
GET http://localhost:8080/async/test1。
控制台日志输出:
1 | ... [main] INFO ... test1 (Controller) 开始执行... 线程: main |
分析:完美!test1 (主线程 main) 立即 返回了。methodB 被 AOP 拦截器“抛”给了 async-1(子线程)执行,实现了异步。
15.4.3. 【错误演示】Service.methodA() -> this.methodB() (AOP 代理失效)
现在,我们来复现这个“陷阱”。
场景:我们在 AsyncService 内部,从一个 没有 @Async 注解的 methodA,去调用 有 @Async 注解的 methodB。
步骤 1:修改 AsyncService.java
1 | // 位于 AsyncService.java |
步骤 2:修改 AsyncController.java
1 | // 位于 AsyncController.java |
验证:
- 重启
DromaraApplication。 - 调用
GET http://localhost:8080/demo/async/test2。
结果:浏览器会“转圈” 3 秒钟才返回!
控制台日志输出:
1 | ... [main] INFO ... test2 (Controller) 开始执行... 线程: main |
分析:@Async 完全失效了! methodB 并不是在 async-1 子线程执行的,而是在 main 主线程 同步 执行的。
15.4.4. 痛点分析:this 引用的是“原始对象”,不是“代理对象”
为什么会这样?我们必须回顾 15.2.3 节的 AOP 代理原理:
- Spring 启动时,为
AsyncService创建了一个 代理对象AsyncService$$Proxy,并把它注入给了AsyncController。 Controller调用asyncService.methodA()时,它调用的是 代理对象。- 代理对象 发现
methodA没有@Async注解,于是 AOP 拦截器 放行,调用 原始对象 的methodA()。 - 【关键】:程序进入
AsyncService类的 原始对象 内部。当执行到this.methodB()时:this关键字,永远指向“当前这个类的原始实例”(AsyncService)。- 它调用的 不是
AsyncService$$Proxy.methodB()。 - 它调用的是 原始
AsyncService.methodB()。
结论:this 调用 绕过 了 AOP 代理,@Async 拦截器根本没有机会触发,导致 methodB 沦为了一个普通的同步方法。
15.5. 解决 @Async 内部调用失效
我们已经知道了失败的根源:this 引用的是“原始对象”。那么解决方案就呼之欲出了:我们必须在 methodA 内部,想办法拿到“代理对象”,然后用 代理对象去调用 methodB。
15.5.1. 方案一 (RVP 推荐): SpringUtils.getAopProxy(this)
我们在 第四章 学习的 SpringUtils,它的核心功能之一 getAopProxy() 就是为了解决这个 AOP“内部调用失效”的经典难题而生的。
步骤 1:修改 AsyncService.java
我们添加一个 methodC 来演示正确用法。
1 | // 位于 AsyncService.java |
步骤 2:修改 AsyncController.java
1 | // 位于 AsyncController.java |
验证:
- 重启
DromaraApplication。 - 调用
GET http://localhost:8080/async/test3。
控制台日志输出:
1 | ... [main] INFO ... test3 (Controller) 开始执行... 线程: main |
结论:methodC(main 线程)立即返回了!methodB 成功地在 async-1 子线程中执行。AOP 代理生效!
15.5.2. 方案二 (Spring 常用): 注入自己
另一种在 Spring 中常见的(但不那么优雅的)解决方案是“自己注入自己”。
1 | // 位于 AsyncService.java |
分析:这种方式同样可行,因为 Spring 注入的 selfProxy 是 AsyncService$$Proxy 代理对象,调用 selfProxy.methodB() 同样可以触发 AOP 拦截。
RVP 框架更推荐使用 SpringUtils.getAopProxy(this),因为它不需要为 Service 额外增加一个 @Autowired 字段
15.6. @Async 进阶:指定线程池与异常处理
到目前为止,我们使用的 @Async(不带参数)都会被 Spring Boot 自动调度到它所配置的 async- 线程池中。
15.6.1. 场景:async- (默认池) vs scheduledExecutorService (RVP 定时池)
在我们的 RVP 框架中,现在至少存在 两个 线程池:
async-线程池:由application.yml(spring.task.execution)自动配置,专门用于@Async。scheduledExecutorService:由ThreadPoolConfig.java手动配置,Bean 名称为"scheduledExecutorService",用于 RVP 的定时和延迟任务。
如果我们的业务场景中,有一个 @Async 任务 不 希望和 其他 @Async 任务(例如 recordLogininfor)竞争 async- 线程池的资源,而是希望使用 RVP 提供的 scheduledExecutorService 线程池(schedule-pool- 前缀),该怎么办?
15.6.2. 实战:@Async("bean") (指定线程池)
@Async 注解允许我们传入一个 字符串 参数,这个字符串就是 目标线程池的 Bean 名称。
步骤 1:修改 AsyncService.java
我们添加一个 methodE,并显式指定它使用 "scheduledExecutorService"。
1 | // 位于 AsyncService.java |
步骤 2:修改 AsyncController.java
我们添加一个 test4 接口来调用 methodB (默认池) 和 methodE (指定池)。
1 | // 位于 AsyncController.java |
验证:
- 重启
DromaraApplication。 - 调用
GET http://localhost:8080/demo/async/test4。
控制台日志输出:
1 | ... [main] INFO ... test4 (Controller) 开始执行... 线程: main |
分析:@Async("beanName") 的方式成功地实现了 线程池隔离。methodB 和 methodE 在 两个完全不同 的线程池中并发执行。这在“二开”中对于隔离高、低优先级的异步任务非常有用。
15.6.3. 异步异常处理
最后一个关键问题:如果 @Async 标记的方法(void 返回值)内部抛出了异常,主线程会知道吗?
不会。
@Async 的 AOP 拦截器会 捕获 子线程的异常,主线程(Controller)会 毫不知情 并正常返回 200 OK。
实战:演示“静默”异常
步骤 1:修改 AsyncService.java
我们添加一个 methodF,它会抛出 RuntimeException。
1 | // 位于 AsyncService.java |
步骤 2:修改 AsyncController.java
1 | // 位于 AsyncController.java |
验证:
- 重启
DromaraApplication。 - 调用
GET http://localhost:8080/demo/async/test5。
结果:
- 浏览器:立即 收到
200 OK和“操作成功”的响应。 - 控制台日志:
1
2
3
4
5
6
7
8
9... [main] INFO ... test5 (Controller) 开始执行...
... [main] INFO ... test5 (Controller) 执行完毕.
// (过了 1 毫秒后)
... [async-2] INFO ... methodF 开始执行... 线程: async-2
// (Spring Boot 的默认处理器捕获并打印了错误)
... [async-2] ERROR ... Unexpected exception occurred invoking async method 'methodF'
java.lang.RuntimeException: 这是一个故意的异步异常
at org.dromara.demo.service.AsyncService.methodF(AsyncService.java:...)
...
分析:主线程 毫不知情,API 正常返回了 200。异常只在后台 日志 中打印(RVP 4.x 的 AsyncConfig 会捕获此异常并抛出 ServiceException,但 RVP 5.x 依赖 Spring Boot 3+ 的默认行为,即只记录日志)。
【二开守则】如何正确处理异步异常?
如果 methodF 的成功与否 非常重要,你 不应该 将其声明为 void。你应该将其声明为 Future<?>:
1 |
|
调用方(例如 Controller 或 Service)在调用 methodG() 后,可以通过 Future.get() 来捕获 ExecutionException,从而 感知 到异步任务的失败。
15.7. 本章总结
在本章中,我们深入了 RVP 5.x 框架下的 @Async 异步调用体系。
- 架构演进:我们明确了 RVP 5.x 摒弃 了 4.x 时代的
AsyncConfig(手动绑定),转而依赖 Spring Boot 3+ 原生 的spring.task.execution(application.yml)来 自动配置@Async的默认线程池(async-前缀)。 - RVP 配置:RVP 的
ApplicationConfig负责@EnableAsync(开启 AOP 代理),ThreadPoolConfig负责ScheduledExecutorService(定时任务池)。 - AOP 原理:
@Async是通过 AOP 代理 和 拦截器 实现的。拦截器将方法调用“抛”入线程池,主线程立即返回。 - 真实场景:RVP 在
SysLogininforServiceImpl中使用@Async异步写入登录日志,避免了INSERT操作阻塞用户登录主流程。 - 核心陷阱:我们实战了“内部调用失效”——
this.methodB()会 绕过 AOP 代理,导致@Async失效。 - 核心解决方案:在 RVP 中,我们应使用
SpringUtils.getAopProxy(this).methodB()来获取 代理对象,从而正确触发异步。 - 进阶用法:
@Async("scheduledExecutorService"):通过指定 Bean 名称,将任务调度到 RVP 的 定时任务池(schedule-pool-)。@Async void方法的异常会被 静默 处理(只打印日志)。必须返回Future并调用get(),才能在主线程中捕获ExecutionException。










