1、背景
1.1、需求
- 租户数据隔离实现方案
租户的数据通过schema、复用同一套业务表结构的方式实现数据隔离。 - 租户数据源信息
租户数据源信息保存在缺省租户的schema模式下的租户信息表中,由运维使用脚本维护 - 自动刷新租户数据源
使用计划任务行扫描租户信息表,在运维新增租户后,可以在较短时间内即可提供服务,完成新数据源的初始化。
1.2、环境
SpringBoot2.7.5 + MyBatis3.5.9 + JDK8 + PostgreSQL12
2、实现方案
2.1、定义动态数据源
2.1.1、TenantDataSource
租户动态数据源类,继承AbstractRoutingDataSource类,根据当前请求中的租户数据源上下文,实现动态查找当前租户数据源的方法。
同时,提供动态添加、更新、移除租户数据源的方法,提供给用户管理租户的数据源集合。
/**
* 多租户数据源
*
* @author Bruce.CH
* @since 2023年08月19日
*/
@Slf4j
public class TenantDataSource extends AbstractRoutingDataSource {
/**
* 租户数据源集合:key为租户id或唯一编码,value为具体租户的数据源
*/
private final Map<Object, Object> targetDataSources;
/**
* 多租户数据源构造器
*
* @param defaultDataSource 默认数据源
* @param targetDataSources 租户数据源集合
*/
public TenantDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources){
this.targetDataSources = targetDataSources;
super.setDefaultTargetDataSource(defaultDataSource);
super.setTargetDataSources(this.targetDataSources);
// afterPropertiesSet负责解析成可用的目标数据源
super.afterPropertiesSet();
}
@Override
protected Object determineCurrentLookupKey() {
// 通过线程上下文设置方法获取当前租户的数据源名称
return TenantDataSourceContext.get();
}
/**
* 添加数据源到租户数据源集合
*
* @param dataSourceName 数据源名称
* @param dataSource 数据源
*/
public void add(String dataSourceName, DataSource dataSource) {
if (targetDataSources.containsKey(dataSourceName)) {
log.warn("addDataSource: {} datasource exists, pleas check.", dataSourceName);
return;
}
targetDataSources.put(dataSourceName, dataSource);
super.setTargetDataSources(targetDataSources);
super.afterPropertiesSet();
}
/**
* 更新租户数据源集合中的数据源
*
* @param dataSourceName 数据源名称
* @param dataSource 数据源
*/
public void update(String dataSourceName, DataSource dataSource) {
if (!targetDataSources.containsKey(dataSourceName)) {
log.warn("updateDataSource: {} datasource not found, ignored.", dataSourceName);
return;
}
targetDataSources.replace(dataSourceName, dataSource);
super.setTargetDataSources(targetDataSources);
super.afterPropertiesSet();
}
/**
* 从租户数据源集合中移除指定的数据源
*
* @param dataSourceName 数据源名称
*/
public void remove(String dataSourceName) {
// 缺省租户数据源不允许删除
if (TenantDataSourceContext.DEFAULT_TENANT_DATA_SOURCE.equals(dataSourceName)) {
log.warn("removeDataSource: defaultTenantDataSource not allow to remove.");
return;
}
if (!targetDataSources.containsKey(dataSourceName)) {
log.warn("removeDataSource: {} datasource not found, ignored.", dataSourceName);
return;
}
targetDataSources.remove(dataSourceName);
super.setTargetDataSources(targetDataSources);
super.afterPropertiesSet();
}
}
2.1.2、TenantDataSourceContext
租户数据源上下文类,用于设置保存、获取当前请求租户的数据源信息,即数据源名称。
/**
* 当前线程中的租户数据源上下文
*
* @author Bruce.CH
* @since 2023年08月19日
*/
public class TenantDataSourceContext {
private static final ThreadLocal<String> DATA_SOURCE_NAME_HOLDER = new ThreadLocal<>();
/**
* 缺省租户数据源名称
*/
public static final String DEFAULT_TENANT_DATA_SOURCE = "defaultTenantDataSource";
/**
* 获取当前请求中的租户数据源
*
* @return 租户数据源名称
*/
public static String get() {
String dataSourceName = DATA_SOURCE_NAME_HOLDER.get();
if (!StringUtils.hasText(dataSourceName)) {
dataSourceName = DEFAULT_TENANT_DATA_SOURCE;
}
return dataSourceName;
}
/**
* 设置当前请求将使用的租户数据源
*
* @param dataSourceName 数据源名称
*/
public static void set(String dataSourceName) {
DATA_SOURCE_NAME_HOLDER.set(dataSourceName);
}
/**
* 请求结束后,移除本地线程中的租户数据源名称
*/
public static void clear() {
DATA_SOURCE_NAME_HOLDER.remove();
}
}
一般情况下,微服务中会实现一个切面或拦截器,拦截用户请求中的租户信息,根据租户信息找到正确的数据源信息,然后对TenantDataSourceContext
中的本地线程变量进行设置。请求结束后,一般还需要清理上文中的租户数据源信息。
2.2、动态切换租户数据源
2.2.1、TenantDataSourceSwitcher
租户数据源切换器类,是实现租户数据源正确切换的关键之一。
通过继承SpringMVC中的拦截器,拦截请求并解析请求头中的租户信息,然后得到接下来需要切换使用的数据源名称,并设置到租户数据源上下文的线程变量中。TenantDataSourceSwitcher从请求头中获取租户信息:
/**
* 租户数据源切换器:使用拦截器来实现切换
*
* @author Bruce.CH
* @since 2023年08月19日
*/
public class TenantDataSourceSwitcher implements HandlerInterceptor {
/**
* 租户id请求头名称
*/
private final String tenantIdHeaderName;
@Resource
private TenantDataSourceService tenantDataSourceService;
/**
* 租户数据源切换器缺省构造方法
*/
public TenantDataSourceSwitcher() {
this.tenantIdHeaderName = "tenant-id";
}
/**
* 租户数据源切换器构造方法
*
* @param tenantIdHeaderName 租户id名称
*/
public TenantDataSourceSwitcher(String tenantIdHeaderName) {
this.tenantIdHeaderName = tenantIdHeaderName;
}
@Override
public boolean preHandle(HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull Object handler) {
String tenantId = request.getHeader(tenantIdHeaderName);
String tenantDataSourceName = TenantDataSourceContext.DEFAULT_TENANT_DATA_SOURCE;
if (StringUtils.hasText(tenantId)) {
// 数据库中查询租户对应的数据源名称,可以优化为从缓存中获取,提升性能
String tenantDataSourceBeanName = tenantDataSourceService.getTenantDataSourceBeanName(tenantId.trim());
if (StringUtils.hasText(tenantDataSourceBeanName)) {
tenantDataSourceName = tenantDataSourceBeanName;
}
}
// 设置上下文变量
TenantDataSourceContext.set(tenantDataSourceName);
return true;
}
@Override
public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, Exception ex) {
// 清理上下文变量
TenantDataSourceContext.clear();
}
}
2.2.2、TenantDataSourceService
租户数据源服务接口类。主要是为了方便实际应用中,对动态数据源中的租户数据源进行自定义的初始化、添加、更新、移除以及获取租户对应数据源信息。
/**
* 租户数据源服务接口:刷新租户数据源
*
* @author Bruce.CH
* @since 2023年08月19日
*/
public interface TenantDataSourceService {
/**
* 刷新租户数据源:新增、更新、删除等,比如定时刷新
*/
default void refresh() {
}
/**
* 添加数据源到租户数据源中
*
* @param dataSourceName 数据源名称
* @param dataSource 数据源
*/
default void add(String dataSourceName, DataSource dataSource) {
}
/**
* 更新租户数据源中的数据源
*
* @param dataSourceName 数据源名称
* @param dataSource 数据源
*/
default void update(String dataSourceName, DataSource dataSource) {
}
/**
* 从租户数据源中移除指定的数据源
*
* @param dataSourceName 数据源名称
*/
default void remove(String dataSourceName) {
}
/**
* 获取指定租户的数据源对象名称(在spring容器中的名称)
*
* @param tenantId 租户id
* @return 租户的数据源对象名称
*/
default String getTenantDataSourceBeanName(String tenantId) {
return StringUtils.hasText(tenantId) ? tenantId.trim() : "";
}
}
DefaultTenantDataSourceService是TenantDataSourceService的抽象缺省实现,代码如下
/**
* 缺省租户数据源服务
*
* @author Bruce.CH
* @since 2023年08月19日
*/
@Slf4j
public abstract class DefaultTenantDataSourceService implements TenantDataSourceService {
/**
* 注入多租户数据源对象
*/
@Resource
private TenantDataSource tenantDataSource;
@Override
public void refresh() {
log.info("DefaultTenantDataSourceService do nothing, you need to implement TenantDataSourceService.");
}
@Override
public void add(String dataSourceName, DataSource dataSource) {
tenantDataSource.add(dataSourceName, dataSource);
}
@Override
public void update(String dataSourceName, DataSource dataSource) {
tenantDataSource.update(dataSourceName, dataSource);
}
@Override
public void remove(String dataSourceName) {
tenantDataSource.remove(dataSourceName);
}
@Override
public String getTenantDataSourceBeanName(String tenantId) {
// 默认使用tenantId作为数据源
return StringUtils.hasText(tenantId) ? tenantId.trim() : "";
}
}
DefaultTenantDataSourceService提供了对多租户数据源的简单封装。默认使用tenantId作为租户数据源名称。
2.3、应用示例
2.3.1、MyBatis配置类
定义动态多租户数据源、缺省数据源/主数据源、MyBatis组件等bean。
/**
* MyBatis配置类
*
* @author Bruce.CH
* @since 2023年08月13日
*/
@Configuration
public class MyBatisConfig implements WebMvcConfigurer, ApplicationContextAware {
private static final String TENANT_SQL_SESSION_FACTORY = "tenantSqlSessionFactory";
private static final String TENANT_MAPPERS_PACKAGES = "com.example.demo.mapper.tenant";
private static final String TENANT_MAPPERS_XML_LOCATIONS = "classpath*:/mapper/tenant/*.xml";
/**
* 定义一个缺省的数据源:根据配置文件中创建默认数据源
*
* @return 默认数据源
*/
@Bean(name = TenantDataSourceContext.DEFAULT_TENANT_DATA_SOURCE)
@ConfigurationProperties(prefix="spring.datasource.default-tenant")
public DataSource getDefaultTenantDataSource() {
return DataSourceBuilder.create().build();
}
/**
* 多租户数据源:一个动态数据源
*
* @return 多租户数据源对象
*/
@Bean("tenantDataSource")
public TenantDataSource tenantDataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(TenantDataSourceContext.DEFAULT_TENANT_DATA_SOURCE, getDefaultTenantDataSource());
return new TenantDataSource(getDefaultTenantDataSource(), targetDataSources);
}
/**
* 会话工厂
*
* @param tenantDataSource 多租户数据源
* @return 会话工厂对象
* @throws IOException IO异常
*/
@Bean
public SqlSessionFactoryBean tenantSqlSessionFactory(@Qualifier("tenantDataSource") DataSource tenantDataSource) throws IOException {
org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration();
configuration.setMapUnderscoreToCamelCase(true);
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(tenantDataSource);
sqlSessionFactoryBean.setConfiguration(configuration);
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
sqlSessionFactoryBean.setMapperLocations(resolver.getResources(TENANT_MAPPERS_XML_LOCATIONS));
return sqlSessionFactoryBean;
}
/**
* 事务管理器
*
* @param tenantDataSource 多租户数据源
* @return 事务管理器对象
*/
@Bean
public PlatformTransactionManager tenantTransactionManager(@Qualifier("tenantDataSource") DataSource tenantDataSource) {
return new DataSourceTransactionManager(tenantDataSource);
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
AutowireCapableBeanFactory autowireCapableBeanFactory = applicationContext.getAutowireCapableBeanFactory();
if (autowireCapableBeanFactory instanceof DefaultListableBeanFactory) {
scanTenantMappers(applicationContext.getEnvironment(),
((DefaultListableBeanFactory) autowireCapableBeanFactory));
}
}
/**
* 扫描租户相关功能的mapper接口
*
* @param env 环境
* @param registry bean定义注册表
*/
private void scanTenantMappers(Environment env, BeanDefinitionRegistry registry) {
ClassPathMapperScanner scanner = new ClassPathMapperScanner(registry);
scanner.registerFilters();
scanner.setSqlSessionFactoryBeanName(TENANT_SQL_SESSION_FACTORY);
scanner.setEnvironment(env);
scanner.doScan(TENANT_MAPPERS_PACKAGES);
}
/**
* 租户数据源切换器
*
* @return 数据源切换器
*/
@Bean
public TenantDataSourceSwitcher tenantDataSourceSwitcher() {
return new TenantDataSourceSwitcher();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 拦截租户用户请求
registry.addInterceptor(tenantDataSourceSwitcher()).addPathPatterns("/v1/tenantuser/**");
}
/**
* 配置Spring计划任务线程池:指定线程池名称为taskExecutor即可
*
* @return 线程池
*/
@Bean("taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor poolTaskExecutor = new ThreadPoolTaskExecutor();
// 线程名称前缀
poolTaskExecutor.setThreadNamePrefix("tenant-task-executor-");
// 核心线程数
poolTaskExecutor.setCorePoolSize(4);
// 最大线程数
poolTaskExecutor.setMaxPoolSize(8);
// 设置线程保活时间(秒)
poolTaskExecutor.setKeepAliveSeconds(120);
// 设置任务队列容量
poolTaskExecutor.setQueueCapacity(100);
// 设置线程任务装饰器,完成异步线程或跨线程/父子线程间的租户数据源上下文值的传递
poolTaskExecutor.setTaskDecorator(new TenantDataSourceContextDecorator());
// 拒绝策略
poolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务结束后再关闭线程池
poolTaskExecutor.setWaitForTasksToCompleteOnShutdown(true);
return poolTaskExecutor;
}
}
最后声明了一个线程池对象,设置了一个线程任务装饰器对象TenantDataSourceContextDecorator,此装饰器对象实现了跨线程传递租户数据源信息,可以保证使用了此线程池执行任务的代码包括如@Scheduled
注解的计划任务或@Async注解的异步任务的子线程或异步线程中也可以正确切换租户的数据源。
2.3.3、TenantDataSourceContextDecorator
线程任务装饰器类。此装饰器对象实现了跨线程传递租户数据源信息,可以保证使用了此线程池执行任务的代码包括如@Scheduled
注解的计划任务或@Async注解的异步任务的子线程或异步线程中也可以正确切换租户的数据源。
/**
* 租户数据源上下文Decorator,用于异步线程/跨线程间传递租户id信息
*
* @author Bruce.CH
* @since 2023年08月20日
*/
@Slf4j
public class TenantDataSourceContextDecorator implements TaskDecorator {
@Override
public Runnable decorate(@NonNull Runnable runnable) {
//获取主线程的dataSourceName
String dataSourceName = TenantDataSourceContext.get();
return () -> {
try {
// 将主线程的dataSourceName,设置到子线程中
TenantDataSourceContext.set(dataSourceName);
// 执行子线程
runnable.run();
} finally {
// 子线程结束,清空dataSourceName
TenantDataSourceContext.clear();
log.info("TenantDataSourceContextDecorator clear dataSourceName.");
}
};
}
}
2.3.3、缺省数据源yml配置
spring:
datasource:
default-tenant:
driver-class-name: org.postgresql.Driver
# SpringBoot的默认数据源HikariDataSource的连接串属性不是url,要用jdbc-url
jdbc-url: jdbc:postgresql://127.0.0.1:5432/postgres?currentSchema=public
username: postgres
password: 123456
# 更多属性配置,略
2.3.4、从数据库中初始化和刷新新增租户数据源
通过实现TenantDataSourceService接口完成从数据库中初始化和刷新新增租户数据源。主要分成两个步骤:
a、监听/感知SpringBooting启动完成事件,在应用启动后初始化数据库中的所有租户数据源,并缓存租户和数据源名称映射关系。
b、通过定时任务,扫描数据库中是否有新增的的租户,如果有,则初始化新租户的数据源,并添加到动态租户数据源集合中
/**
* 租户数据源配置服务
*
* @author Bruce.CH
* @since 2023年08月19日
*/
@Service
@Slf4j
public class TenantDsServiceImpl extends DefaultTenantDataSourceService implements TenantDsService, ApplicationListener<ApplicationStartedEvent> {
/**
* 缓存已经初始化的租户数据源名称集合
*/
private static final Map<String, String> INITIALED_TENANT_DATA_SOURCES = new HashMap<>();
/**
* 查询租户信息列表Repository对象
*/
@Resource
private TenantDsRepository tenantDsRepository;
/**
* 感知应用启动事件:应用启动后,首次初始化租户数据源
*
* @param event 应用启动事件
*/
@Override
public void onApplicationEvent(ApplicationStartedEvent event) {
log.info("Application started:{}, initial Tenant DataSources begin", event.getClass().getTypeName());
int count = initTenantDataSources();
log.info("Tenant DataSources initialed: {}", count);
log.info("Initial Tenant DataSources end...");
}
/**
* 只加载新增的租户数据源:使用spring计划任务,每分钟检查一次。即往数据库新增一个租户数据源,1分种后生效。
* 也可以在开租的接口调用此接口即时为新的租户数据源进行初始化。
*/
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Async
@Override
public void refresh() {
// 1、加载新租户的数据源
initNewTenantDataSources();
// 2、刷新有变更的租户数据源:先删除然后重新初始化: 待实现
// 3、删除过期租户或无效的数据源:待实现
}
private int initTenantDataSources() {
List<TenantDs> tenantDataSources = queryAllTenantDataSources();
if (CollectionUtils.isEmpty(tenantDataSources)) {
return 0;
}
return buildTenantDataSources(tenantDataSources);
}
private void initNewTenantDataSources() {
List<TenantDs> newTenantDataSources = getNewTenantDataSources();
if (CollectionUtils.isEmpty(newTenantDataSources)) {
return;
}
List<String> newTenantDataSourceNames =
newTenantDataSources.stream().map(TenantDs::getDsName).collect(Collectors.toList());
log.info("New TenantDataSource found, initialing:{}", JSON.toJSONString(newTenantDataSourceNames));
int count = buildTenantDataSources(newTenantDataSources);
log.info("{} New TenantDataSource initialed:{}.", count, JSON.toJSONString(newTenantDataSourceNames));
}
private List<TenantDs> getNewTenantDataSources() {
List<TenantDs> tenantDataSources = queryAllTenantDataSources();
if (CollectionUtils.isEmpty(tenantDataSources)) {
return new ArrayList<>();
}
return tenantDataSources.stream()
.filter(tenantDataSource -> !INITIALED_TENANT_DATA_SOURCES.containsKey(tenantDataSource.getTenantId()))
.collect(Collectors.toList());
}
private int buildTenantDataSources(List<TenantDs> tenantDataSources) {
// 构建新的租户数据源
for (TenantDs tenantDataSource : tenantDataSources) {
DataSourceProperties prop = new DataSourceProperties();
prop.setDriverClassName("org.postgresql.Driver");
prop.setUrl("jdbc:postgresql://127.0.0.1:5432/postgres?currentSchema=" + tenantDataSource.getDsSchema());
prop.setUsername(tenantDataSource.getDsUsername());
prop.setPassword(tenantDataSource.getDsPassword());
DataSource dataSource = TenantDataSourceBuilder.build(prop);
// 设置数据源的更多属性,比如最大/空闲连接数,连接/空闲超时时间等,略
add(tenantDataSource.getDsName(), dataSource);
}
// 缓存已初始化的租户数据源名称集合
INITIALED_TENANT_DATA_SOURCES.putAll(tenantDataSources.stream()
.collect(Collectors.toMap(TenantDs::getTenantId, TenantDs::getDsName)));
return INITIALED_TENANT_DATA_SOURCES.size();
}
/**
* 实现租户到数据源名称(租户数据源bean的名称):如果缓存中没有,则返回缺省数据源
*
* @param tenantId 租户id
* @return 租户数据源bean的名称
*/
@Override
public String getTenantDataSourceBeanName(String tenantId) {
if (INITIALED_TENANT_DATA_SOURCES.containsKey(tenantId)) {
return INITIALED_TENANT_DATA_SOURCES.get(tenantId);
}
log.warn("{} tenant datasource not found, please check: return default datasource", tenantId);
// 返回缺省数据源或抛出异常:也可以尝试一次数据库查询,即时初始化新增的租户数据源,如果存在的话
return TenantDataSourceContext.DEFAULT_TENANT_DATA_SOURCE;
}
/**
* 从主数据库或缺省数据库中查询所有租户信息列表
*
* @return 所有租户信息列表
*/
private List<TenantDs> queryAllTenantDataSources() {
List<TenantDs> tenantDsList = tenantDsRepository.findAll();
if (CollectionUtils.isEmpty(tenantDsList)) {
return new ArrayList<>();
}
return tenantDsList;
}
}
2.3.5、排除数据源以及MyBatis自动装配类
DemoApplication类,SpringBoot应用启动入口。由于使用自定初始化数据源以及MyBatis组件,故数据源和MyBatis的自动配置类可以排除掉。
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, MybatisAutoConfiguration.class})
@EnableWebMvc
@EnableScheduling
@EnableAsync
@Slf4j
public class DemoApplication {
/**
* APP启动入口
*
* @param args 参数
*/
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
log.info("DemoApplication Started Successfully.");
}
}
2.3.6、请求示例
主要就是在请求中配置tenant-id请求头或初始化TenantDataSourceSwitcher时自定义指定的请求头。省略。
3、最后
按照以上方案实施完后,对于租户的相关服务的开发,可以像普通的无租户应用一样进行即可。