Skip to content

异步任务 ​

这篇解决什么 ​

写库、发短信、记访问日志不必挡在 HTTP 返回之前。读完能加 @Async、看清默认线程池,并判断租户和登录用户会不会丢。

默认端口 48080,管理端前缀 /admin-api。@EnableAsync 随 Job Starter 打开,单体进程里各业务模块都能用。

示意图:请求进业务模块;框架把 @Async 丢进线程池,请求线程先返回。

组件位置 ​

名称说明仓库路径
Job Starter@EnableAsync,给线程池套 TTLruoyi-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 依赖TransmittableThreadLocalyudao-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。

java
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 在提交那一刻拷一份到工作线程。

名称说明仓库路径
TenantContextHoldertenantId、是否忽略租户yudao-spring-boot-starter-biz-tenant/.../TenantContextHolder.java
Security 策略SecurityContext(含登录用户)yudao-spring-boot-starter-security/.../TransmittableThreadLocalSecurityContextHolderStrategy.java
策略切换SecurityContextHolder.setStrategyNameYudaoSecurityAutoConfiguration
数据权限@DataPermission 栈yudao-spring-boot-starter-biz-data-permission/.../DataPermissionContextHolder.java
环境标签灰度 / 多环境 tagyudao-spring-boot-starter-env/.../EnvContextHolder.java

新的上下文不要用 JDK ThreadLocal。和人、租户有关的值,和现有 Holder 一样用 TTL。

手搓线程池会丢掉租户和用户

BpmTaskServiceImpl 批量通过用 Executors.newFixedThreadPool + CompletableFuture.runAsync。框架装饰器管不到,所以提交前自己 setTenantId / setIgnore,并拷 Authentication;finally 里 clear。漏一步,异步 SQL 可能打到错误租户,或 getLoginUser() 为空。

java
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
异步入口接口默认方法,带 @AsyncApiAccessLogCommonApi.createApiAccessLogAsync
同步写入Feign / 本地实现,真正 insertyudao-module-infra-server/.../ApiAccessLogApiImpl.java
Service有租户则直接插;没有则 executeIgnore.../ApiAccessLogServiceImpl.java
表访问日志infra_api_access_log → ApiAccessLogDO
java
@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
按接口取 BeanJDK 代理下不要 getBean(实现类)同上 getSelf()
自己吞异常默认 UncaughtHandler 只打日志同上 try/catch
java
@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,不要塞进异步线程。

开启见 框架层。

相关篇 ​

联系我们

获取报价、演示和二开方案

微信咨询二维码

微信咨询

17156169080

添加时备注「RuoYi Office」

在线体验商业版