异步任务
这篇解决什么
写库、发短信、记访问日志不必挡在 HTTP 返回之前。读完能加 @Async、看清默认线程池,并判断租户和登录用户会不会丢。
默认端口 48080,管理端前缀 /admin-api。@EnableAsync 随 Job Starter 打开,单体进程里各业务模块都能用。
示意图:请求进业务模块;框架把 @Async 丢进线程池,请求线程先返回。
组件位置
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| Job Starter | @EnableAsync,给线程池套 TTL | ruoyi-office/yudao-framework/yudao-spring-boot-starter-job/ |
| 自动配置 | YudaoAsyncAutoConfiguration | .../quartz/config/YudaoAsyncAutoConfiguration.java |
| 注册入口 | 与 XXL-Job 同一份 imports | .../META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports |
| 默认池 | Spring Boot applicationTaskExecutor | 未在 application.yaml 改 spring.task.execution |
| TTL 依赖 | TransmittableThreadLocal | yudao-framework/yudao-common/pom.xml |
| 访问日志 API | @Async 默认方法 | yudao-framework/yudao-common/.../ApiAccessLogCommonApi.java |
| 操作日志 API | 同上 | .../OperateLogCommonApi.java |
system、infra 以及多数业务 *-server 已引入 yudao-spring-boot-starter-job。新模块要异步,先加这个依赖,不要自己再写一份 @EnableAsync。
线程池
YudaoAsyncAutoConfiguration 打开 @EnableAsync。所有 ThreadPoolTaskExecutor、SimpleAsyncTaskExecutor 在初始化前被套上 TtlRunnable。
if (bean instanceof ThreadPoolTaskExecutor) {
ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) bean;
executor.setTaskDecorator(TtlRunnable::get);
return executor;
}
if (bean instanceof SimpleAsyncTaskExecutor) {
SimpleAsyncTaskExecutor executor = (SimpleAsyncTaskExecutor) bean;
executor.setTaskDecorator(TtlRunnable::get);
return executor;
}没配 spring.task.execution 时,走 Spring Boot 默认:核心 8、队列和最大线程不封顶、线程名 task-。压测或生产再按机器改 core-size / queue-capacity / max-size。
专用池只要是 ThreadPoolTaskExecutor Bean,同样会被装饰。租户迁移池就是这种:
| 名称 | 说明 | 仓库路径 |
|---|---|---|
tenantTransferExecutor | 导出 / 导入整单,固定 2 线程 | yudao-module-system-server/.../TenantTransferExecutorConfiguration.java |
tenantTransferWorkExecutor | 任务内工作线程,默认 1 | 同上 |
@Async 不写 value 时用默认池。要隔离流量,声明自己的 ThreadPoolTaskExecutor,再 @Async("beanName")。
只装饰这两类 Executor
ThreadPoolExecutor、Executors.newFixedThreadPool、CompletableFuture.runAsync() 默认池都不会被这个 PostProcessor 碰到。要传上下文,要么换成 ThreadPoolTaskExecutor Bean,要么提交前包 TtlRunnable.get(runnable)。
上下文怎么传
请求线程里的租户、登录用户、数据权限存在 ThreadLocal。线程池复用工作线程时,普通 ThreadLocal 传不过去,工作线程上是空的或上一单的脏值。
Holder 本身用 TransmittableThreadLocal;线程池再用 TtlRunnable 在提交那一刻拷一份到工作线程。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
TenantContextHolder | tenantId、是否忽略租户 | yudao-spring-boot-starter-biz-tenant/.../TenantContextHolder.java |
| Security 策略 | SecurityContext(含登录用户) | yudao-spring-boot-starter-security/.../TransmittableThreadLocalSecurityContextHolderStrategy.java |
| 策略切换 | SecurityContextHolder.setStrategyName | YudaoSecurityAutoConfiguration |
| 数据权限 | @DataPermission 栈 | yudao-spring-boot-starter-biz-data-permission/.../DataPermissionContextHolder.java |
| 环境标签 | 灰度 / 多环境 tag | yudao-spring-boot-starter-env/.../EnvContextHolder.java |
新的上下文不要用 JDK ThreadLocal。和人、租户有关的值,和现有 Holder 一样用 TTL。
手搓线程池会丢掉租户和用户
BpmTaskServiceImpl 批量通过用 Executors.newFixedThreadPool + CompletableFuture.runAsync。框架装饰器管不到,所以提交前自己 setTenantId / setIgnore,并拷 Authentication;finally 里 clear。漏一步,异步 SQL 可能打到错误租户,或 getLoginUser() 为空。
Long tenantId = TenantContextHolder.getTenantId();
boolean ignoreTenant = TenantContextHolder.isIgnore();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
futures.add(CompletableFuture.runAsync(() -> {
TenantContextHolder.setTenantId(tenantId);
TenantContextHolder.setIgnore(ignoreTenant);
SecurityContext workerContext = SecurityContextHolder.createEmptyContext();
workerContext.setAuthentication(authentication);
SecurityContextHolder.setContext(workerContext);
try {
approveTaskGroup(userId, reqVO.getReason(), group, successCount, failures);
} finally {
TenantContextHolder.clear();
SecurityContextHolder.clearContext();
}
}, executor));能走 @Async 或 ThreadPoolTaskExecutor 时,不要再手拷。只有旁路池才抄这段。
访问日志
管理端任意接口走完,ApiAccessLogFilter 在请求线程拼 DTO,再异步落库。接口先返回,插入不挡响应。
示意图:Filter 同步组包,插入在 task-* 线程。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| Filter | 请求结束后组包并异步提交 | yudao-spring-boot-starter-web/.../ApiAccessLogFilter.java |
| 异步入口 | 接口默认方法,带 @Async | ApiAccessLogCommonApi.createApiAccessLogAsync |
| 同步写入 | Feign / 本地实现,真正 insert | yudao-module-infra-server/.../ApiAccessLogApiImpl.java |
| Service | 有租户则直接插;没有则 executeIgnore | .../ApiAccessLogServiceImpl.java |
| 表 | 访问日志 | infra_api_access_log → ApiAccessLogDO |
@Async
default void createApiAccessLogAsync(ApiAccessLogCreateReqDTO createDTO) {
createApiAccessLog(createDTO).checkError();
}createApiAccessLog 仍是同步。异步只包在 *Async 上。注入的是 Spring 代理后的 ApiAccessLogCommonApi,注解才会生效。
操作日志同一套路:LogRecordServiceImpl.record 在当前线程填用户、模块、URL,再 operateLogApi.createOperateLogAsync,写入 system_operate_log。
Service 落库时仍读 TenantContextHolder。TTL 正常时,异步线程带着请求上的 tenantId。Holder 为空时访问日志会 TenantUtils.executeIgnore 再插,避免整条丢掉。
本地打断点看线程名:请求在 http-nio-*,插入在 task-*。
自己加 @Async
适合「调用方不必等结果」:记日志、刷新缓存、发通知、写旁路流水。调用方要返回值或要同一事务时,不要异步。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 公开方法 | public,经 Spring 代理 | IoT createDeviceLogAsync |
| 取自身代理 | 同类内部调用必须 getSelf() | IotDeviceMessageServiceImpl |
| 按接口取 Bean | JDK 代理下不要 getBean(实现类) | 同上 getSelf() |
| 自己吞异常 | 默认 UncaughtHandler 只打日志 | 同上 try/catch |
@Async
public void createDeviceLogAsync(IotDeviceMessage message) {
// 转 DO、insert ...
try {
deviceMessageMapper.insert(messageDO);
} catch (Exception ex) {
log.error("[createDeviceLogAsync][消息日志写入失败 deviceId({}) ...]",
messageDO.getDeviceId(), ex);
}
}
private IotDeviceMessageService getSelf() {
return SpringUtil.getBean(IotDeviceMessageService.class);
}
// 发送成功后:
getSelf().createDeviceLogAsync(message);this.createDeviceLogAsync(...) 不经过代理,会在当前线程同步跑。@Async 加在接口默认方法上时,调用方必须注入该接口,不能 new 实现类。
Spring Event 监听器也可以加 @Async,让消费离开发送线程。短信、邮件、部分本地 ApplicationListener 已这样写。监听若要读发送方刚提交的行,加 @TransactionalEventListener(AFTER_COMMIT),避免未提交就查。
异步里的异常不会回到接口
@Async 方法抛出的异常默认进 AsyncUncaughtExceptionHandler,HTTP 已经 200。旁路写入自己 try/catch 打日志。要让调用方感知失败,改回同步,或把结果写进任务表再查。
不要在 @Async 方法里用请求线程的 HttpServletRequest。用户、IP、traceId 在提交前拷进 DTO,访问日志和操作日志都是这样做的。
需要互斥的异步回源,先看分布式锁,不要只靠线程池排队。
配置与操作
返回后再落库的逻辑加 @Async。用户、IP、traceId 提交前拷进 DTO。按点扫描用 XXL-Job,不要塞进异步线程。
开启见 框架层。
