数据隔离方案
DATASOURCE 模式
一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。
- 优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。
- 缺点:增大了数据库的安装数量,随之带来维护成本和购置成本的增加。
SCHEMA 模式
多个或所有租户共享数据库,但一个租户一个表。
- 优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。
- 缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。
COLUMN 模式
共享数据库,共享数据架构。租户共享同一个数据库、同一个表,但在表中通过 tenant_id
字段区分租户的数据。这是共享程度最高、隔离级别最低的模式
- 优点:维护和购置成本最低,允许每个数据库支持的租户数量最多。
- 缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。
- 一般情况下,可以考虑采用 COLUMN 模式,开发、运维简单,以最少的服务器为最多的租户提供服务。
- 租户规模比较大,或者一些租户对安全性要求较高,可以考虑采用 DATASOURCE 模式,当然它也相对复杂的多。
- 不推荐采用 SCHEMA 模式,因为它的优点并不明显,而且它的缺点也很明显,同时对复杂 SQL 支持一般。
多租户的技术组件
租户上下文
TenantContextHolder (opens new window) 是租户上下文,通过 ThreadLocal 实现租户编号的共享与传递。
通过调用 TenantContextHolder 的 #getTenantId()
静态方法,获得当前的租户编号。绝绝绝大多数情况下,并不需要。
package cn.iocoder.yudao.framework.tenant.core.context;
import cn.iocoder.yudao.framework.common.enums.DocumentEnum;
import com.alibaba.ttl.TransmittableThreadLocal;
/**
* 多租户上下文 Holder
*
* @author 芋道源码
*/
public class TenantContextHolder {
/**
* 当前租户编号
*/
private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();
/**
* 是否忽略租户
*/
private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();
/**
* 获得租户编号。
*
* @return 租户编号
*/
public static Long getTenantId() {
return TENANT_ID.get();
}
/**
* 获得租户编号。如果不存在,则抛出 NullPointerException 异常
*
* @return 租户编号
*/
public static Long getRequiredTenantId() {
Long tenantId = getTenantId();
if (tenantId == null) {
throw new NullPointerException("TenantContextHolder 不存在租户编号!可参考文档:"
+ DocumentEnum.TENANT.getUrl());
}
return tenantId;
}
public static void setTenantId(Long tenantId) {
TENANT_ID.set(tenantId);
}
public static void setIgnore(Boolean ignore) {
IGNORE.set(ignore);
}
/**
* 当前是否忽略租户
*
* @return 是否忽略
*/
public static boolean isIgnore() {
return Boolean.TRUE.equals(IGNORE.get());
}
public static void clear() {
TENANT_ID.remove();
IGNORE.remove();
}
}
Web 层【重要】
package cn.iocoder.yudao.framework.tenant.core.web;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 多租户 Context Web 过滤器
* 将请求 Header 中的 tenant-id 解析出来,添加到 {@link TenantContextHolder} 中,这样后续的 DB 等操作,可以获得到租户编号。
*
* @author 芋道源码
*/
public class TenantContextWebFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 设置
Long tenantId = WebFrameworkUtils.getTenantId(request);
if (tenantId != null) {
TenantContextHolder.setTenantId(tenantId);
}
try {
chain.doFilter(request, response);
} finally {
// 清理
TenantContextHolder.clear();
}
}
}
默认情况下,前端的每个请求 Header 必须带上 tenant-id
,值为租户编号,即 system_tenant
表的主键编号。
如果不带该请求头,会报“租户的请求未传递,请进行排查”错误提示。
通过 yudao.tenant.ignore-urls
配置项,可以设置哪些 URL 无需带该请求头。例如说:
Security 层
主要是校验登录的用户,校验是否有权限访问该租户,避免越权问题。
package cn.iocoder.yudao.framework.tenant.core.security;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.config.TenantProperties;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.service.TenantFrameworkService;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import cn.iocoder.yudao.framework.web.core.filter.ApiRequestFilter;
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.AntPathMatcher;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
/**
* 多租户 Security Web 过滤器
* 1. 如果是登陆的用户,校验是否有权限访问该租户,避免越权问题。
* 2. 如果请求未带租户的编号,检查是否是忽略的 URL,否则也不允许访问。
* 3. 校验租户是合法,例如说被禁用、到期
*
* @author 芋道源码
*/
@Slf4j
public class TenantSecurityWebFilter extends ApiRequestFilter {
private final TenantProperties tenantProperties;
private final AntPathMatcher pathMatcher;
private final GlobalExceptionHandler globalExceptionHandler;
private final TenantFrameworkService tenantFrameworkService;
public TenantSecurityWebFilter(TenantProperties tenantProperties,
WebProperties webProperties,
GlobalExceptionHandler globalExceptionHandler,
TenantFrameworkService tenantFrameworkService) {
super(webProperties);
this.tenantProperties = tenantProperties;
this.pathMatcher = new AntPathMatcher();
this.globalExceptionHandler = globalExceptionHandler;
this.tenantFrameworkService = tenantFrameworkService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Long tenantId = TenantContextHolder.getTenantId();
// 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。
LoginUser user = SecurityFrameworkUtils.getLoginUser();
if (user != null) {
// 如果获取不到租户编号,则尝试使用登陆用户的租户编号
if (tenantId == null) {
tenantId = user.getTenantId();
TenantContextHolder.setTenantId(tenantId);
// 如果传递了租户编号,则进行比对租户编号,避免越权问题
} else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {
log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]",
user.getTenantId(), user.getId(), user.getUserType(),
TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod());
ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(),
"您无权访问该租户的数据"));
return;
}
}
// 如果非允许忽略租户的 URL,则校验租户是否合法
if (!isIgnoreUrl(request)) {
// 2. 如果请求未带租户的编号,不允许访问。
if (tenantId == null) {
log.error("[doFilterInternal][URL({}/{}) 未传递租户编号]", request.getRequestURI(), request.getMethod());
ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(),
"请求的租户标识未传递,请进行排查"));
return;
}
// 3. 校验租户是合法,例如说被禁用、到期
try {
tenantFrameworkService.validTenant(tenantId);
} catch (Throwable ex) {
CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
ServletUtils.writeJSON(response, result);
return;
}
} else { // 如果是允许忽略租户的 URL,若未传递租户编号,则默认忽略租户编号,避免报错
if (tenantId == null) {
TenantContextHolder.setIgnore(true);
}
}
// 继续过滤
chain.doFilter(request, response);
}
private boolean isIgnoreUrl(HttpServletRequest request) {
// 快速匹配,保证性能
if (CollUtil.contains(tenantProperties.getIgnoreUrls(), request.getRequestURI())) {
return true;
}
// 逐个 Ant 路径匹配
for (String url : tenantProperties.getIgnoreUrls()) {
if (pathMatcher.match(url, request.getRequestURI())) {
return true;
}
}
return false;
}
}
DB 层【重要】
package cn.iocoder.yudao.framework.tenant.core.db;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 拓展多租户的 BaseDO 基类
*
* @author 芋道源码
*/
@Data
@EqualsAndHashCode(callSuper = true)
public abstract class TenantBaseDO extends BaseDO {
/**
* 多租户编号
*/
private Long tenantId;
}
package cn.iocoder.yudao.framework.tenant.core.db;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.tenant.config.TenantProperties;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import java.util.HashSet;
import java.util.Set;
/**
* 基于 MyBatis Plus 多租户的功能,实现 DB 层面的多租户的功能
*
* @author 芋道源码
*/
public class TenantDatabaseInterceptor implements TenantLineHandler {
private final Set<String> ignoreTables = new HashSet<>();
public TenantDatabaseInterceptor(TenantProperties properties) {
// 不同 DB 下,大小写的习惯不同,所以需要都添加进去
properties.getIgnoreTables().forEach(table -> {
ignoreTables.add(table.toLowerCase());
ignoreTables.add(table.toUpperCase());
});
// 在 OracleKeyGenerator 中,生成主键时,会查询这个表,查询这个表后,会自动拼接 TENANT_ID 导致报错
ignoreTables.add("DUAL");
}
@Override
public Expression getTenantId() {
return new LongValue(TenantContextHolder.getRequiredTenantId());
}
@Override
public boolean ignoreTable(String tableName) {
return TenantContextHolder.isIgnore() // 情况一,全局忽略多租户
|| CollUtil.contains(ignoreTables, tableName); // 情况二,忽略多租户的表
}
}
COLUMN 模式,基于 MyBatis Plus 自带的多租户 (opens new window)功能实现。
核心:每次对数据库操作时,它会自动拼接 WHERE tenant_id = ?
条件来进行租户的过滤,并且基本支持所有的 SQL 场景。
如下是具体方式:
① 需要开启多租户的表,必须添加 tenant_id
字段。例如说 system_users
、system_role
等表。
CREATE TABLE `system_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`name` varchar(30) CHARACTER NOT NULL COMMENT '角色名称',
`tenant_id` bigint NOT NULL DEFAULT '0' COMMENT '租户编号',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='角色信息表';
并且该表对应的 DO 需要使用到 tenantId
属性时,建议继承 TenantBaseDO (opens new window) 类。
② 无需开启多租户的表,需要添加表名到 yudao.tenant.ignore-tables
配置项目。例如说:
如果不配置的话,MyBatis Plus 会自动拼接 WHERE tenant_id = ?
条件,导致报 tenant_id
字段不存在的错误。
Redis 层【重要】
package cn.iocoder.yudao.framework.tenant.core.redis;
import cn.iocoder.yudao.framework.redis.core.TimeoutRedisCacheManager;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
/**
* 多租户的 {@link RedisCacheManager} 实现类
*
* 操作指定 name 的 {@link Cache} 时,自动拼接租户后缀,格式为 name + ":" + tenantId + 后缀
*
* @author airhead
*/
@Slf4j
public class TenantRedisCacheManager extends TimeoutRedisCacheManager {
public TenantRedisCacheManager(RedisCacheWriter cacheWriter,
RedisCacheConfiguration defaultCacheConfiguration) {
super(cacheWriter, defaultCacheConfiguration);
}
@Override
public Cache getCache(String name) {
// 如果开启多租户,则 name 拼接租户后缀
if (!TenantContextHolder.isIgnore()
&& TenantContextHolder.getTenantId() != null) {
name = name + ":" + TenantContextHolder.getTenantId();
}
// 继续基于父方法
return super.getCache(name);
}
}
由于 Redis 不同于 DB 有 tenant_id
字段,无法通过类似 WHERE tenant_id
= ? 的方式过滤,所以需要通过在 Redis Key 上增加 :t{tenantId}
后缀的方式,进行租户之间的隔离。
例如说,假设 Redis Key 是 user:%d
,示例是 user:1024
;对应到多租户 1 的 Redis Key 是 user:t1:1024
。
为什么 Redis Key 要多租户隔离呢?
- ① 在使用 DATASOURCE 模式时,不同库的相同表的 id 可能相同,例如说 A 库的用户,和 B 库的用户都是 1024,直接缓存会存在 Redis Key 的冲突。
- ② 在所有模式下,跨租户可能存在相同的需要唯一的数据,例如说用户的手机号,直接缓存会存在 Redis Key 的冲突。
使用方式一:基于 Spring Cache + Redis【推荐】
只需要一步,在方法上添加 Spring Cache 注解,例如说 @Cachable
、@CachePut
、@CacheEvict
。
具体的实现原理,可见 TenantRedisCacheManager (opens new window) 的源码。
注意!!!默认配置下,Spring Cache 都开启 Redis Key 的多租户隔离。如果不需要,可以将 Key 添加到 yudao.tenant.ignore-cache
配置项中。如下图所示:
使用方式二:基于 RedisTemplate + TenantRedisKeyDefine
暂时没有合适的封装,需要在自己 format Redis Key 的时候,手动将 :t{tenantId}
后缀拼接上。
这也是为什么,我推荐你使用 Spring Cache + Redis 的原因!
AOP【重要】
package cn.iocoder.yudao.framework.tenant.core.aop;
import java.lang.annotation.*;
/**
* 忽略租户,标记指定方法不进行租户的自动过滤
*
* 注意,只有 DB 的场景会过滤,其它场景暂时不过滤:
* 1、Redis 场景:因为是基于 Key 实现多租户的能力,所以忽略没有意义,不像 DB 是一个 column 实现的
* 2、MQ 场景:有点难以抉择,目前可以通过 Consumer 手动在消费的方法上,添加 @TenantIgnore 进行忽略
*
* @author 芋道源码
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface TenantIgnore {
}
package cn.iocoder.yudao.framework.tenant.core.aop;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
/**
* 忽略多租户的 Aspect,基于 {@link TenantIgnore} 注解实现,用于一些全局的逻辑。
* 例如说,一个定时任务,读取所有数据,进行处理。
* 又例如说,读取所有数据,进行缓存。
*
* 整体逻辑的实现,和 {@link TenantUtils#executeIgnore(Runnable)} 需要保持一致
*
* @author 芋道源码
*/
@Aspect
@Slf4j
public class TenantIgnoreAspect {
@Around("@annotation(tenantIgnore)")
public Object around(ProceedingJoinPoint joinPoint, TenantIgnore tenantIgnore) throws Throwable {
Boolean oldIgnore = TenantContextHolder.isIgnore();
try {
TenantContextHolder.setIgnore(true);
// 执行逻辑
return joinPoint.proceed();
} finally {
TenantContextHolder.setIgnore(oldIgnore);
}
}
}
① 声明 @TenantIgnore
(opens new window)注解在方法上,标记指定方法不进行租户的自动过滤,避免自动拼接 WHERE tenant_id = ?
条件等等。
例如说:RoleServiceImpl (opens new window) 的 #initLocalCache()
(opens new window)方法,加载所有租户的角色到内存进行缓存,如果不声明 @TenantIgnore
注解,会导致租户的自动过滤,只加载了某个租户的角色。
// RoleServiceImpl.java
public class RoleServiceImpl implements RoleService {
@Resource
@Lazy // 注入自己,所以延迟加载
private RoleService self;
@Override
@PostConstruct
@TenantIgnore // 忽略自动多租户,全局初始化缓存
public void initLocalCache() {
// ... 从数据库中,加载角色
}
@Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD)
public void schedulePeriodicRefresh() {
self.initLocalCache(); // <x> 通过 self 引用到 Spring 代理对象
}
}
有一点要格外注意,由于 @TenantIgnore
注解是基于 Spring AOP 实现,如果是 方法内部的调用 ,避免使用 this
导致不生效,可以采用上述示例的 <x>
处的 self
方式。
② 使用 TenantUtils (opens new window) 的 #execute(Long tenantId, Runnable runnable)
方法,模拟指定租户( tenantId
),执行某段业务逻辑( runnable
)。
例如说:在 TenantServiceImpl (opens new window) 的 #createTenant(...)
方法,在创建完租户时,需要模拟该租户,进行用户和角色的创建。如下图所示:
Job【重要】
package cn.iocoder.yudao.framework.tenant.core.job;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 多租户 Job 注解
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantJob {
}
package cn.iocoder.yudao.framework.tenant.core.job;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.exceptions.ExceptionUtil;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.service.TenantFrameworkService;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 多租户 JobHandler AOP
* 任务执行时,会按照租户逐个执行 Job 的逻辑
*
* 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个租户执行失败重试时,之前执行成功的租户也会再次执行。
*
* @author 芋道源码
*/
@Aspect
@RequiredArgsConstructor
@Slf4j
public class TenantJobAspect {
private final TenantFrameworkService tenantFrameworkService;
@Around("@annotation(tenantJob)")
public String around(ProceedingJoinPoint joinPoint, TenantJob tenantJob) {
// 获得租户列表
List<Long> tenantIds = tenantFrameworkService.getTenantIds();
if (CollUtil.isEmpty(tenantIds)) {
return null;
}
// 逐个租户,执行 Job
Map<Long, String> results = new ConcurrentHashMap<>();
tenantIds.parallelStream().forEach(tenantId -> {
// TODO 芋艿:先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况
TenantUtils.execute(tenantId, () -> {
try {
joinPoint.proceed();
} catch (Throwable e) {
results.put(tenantId, ExceptionUtil.getRootCauseMessage(e));
}
});
});
return JsonUtils.toJsonString(results);
}
}
声明 @TenantJob
注解在 Job 方法上,实现并行遍历每个租户,执行定时任务的逻辑。
MQ
package cn.iocoder.yudao.framework.tenant.core.mq;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.mq.core.interceptor.RedisMessageInterceptor;
import cn.iocoder.yudao.framework.mq.core.message.AbstractRedisMessage;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID;
/**
* 多租户 {@link AbstractRedisMessage} 拦截器
*
* 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中
* 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中
*
* @author 芋道源码
*/
public class TenantRedisMessageInterceptor implements RedisMessageInterceptor {
@Override
public void sendMessageBefore(AbstractRedisMessage message) {
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
message.addHeader(HEADER_TENANT_ID, tenantId.toString());
}
}
@Override
public void consumeMessageBefore(AbstractRedisMessage message) {
String tenantIdStr = message.getHeader(HEADER_TENANT_ID);
if (StrUtil.isNotEmpty(tenantIdStr)) {
TenantContextHolder.setTenantId(Long.valueOf(tenantIdStr));
}
}
@Override
public void consumeMessageAfter(AbstractRedisMessage message) {
// 注意,Consumer 是一个逻辑的入口,所以不考虑原本上下文就存在租户编号的情况
TenantContextHolder.clear();
}
}
通过租户对 MQ 层面的封装,实现租户上下文,可以继续传递到 MQ 消费的逻辑中,避免丢失的问题。实现原理是:
- 发送消息时,MQ 会将租户上下文的租户编号,记录到 Message 消息头
tenant-id
上。 - 消费消息时,MQ 会将 Message 消息头
tenant-id
,设置到租户上下文的租户编号。
Async
package cn.iocoder.yudao.framework.quartz.config;
import com.alibaba.ttl.TtlRunnable;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* 异步任务 Configuration
*/
@AutoConfiguration
@EnableAsync
public class YudaoAsyncAutoConfiguration {
@Bean
public BeanPostProcessor threadPoolTaskExecutorBeanPostProcessor() {
return new BeanPostProcessor() {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if (!(bean instanceof ThreadPoolTaskExecutor)) {
return bean;
}
// 修改提交的任务,接入 TransmittableThreadLocal
ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) bean;
executor.setTaskDecorator(TtlRunnable::get);
return executor;
}
};
}
}
通过使用阿里开源的 TransmittableThreadLocal 组件,实现 Spring Async 执行异步逻辑时,租户上下文可以继续传递,避免丢失的问题。