Mybatis-plus多租户方案实现原理
1. 实现原理
核心就是增删改数据库数据时自动拼接商户号,并根据配置文件区分忽略拼接的表.
Mybatis-plus提供了一种多租户的解决方案,实现原理是基于MybatisPlus Interceptor插件【拦截器TenantLineInnerInterceptor)】实现的,如图所示:
2. 具体实现方式
首先需要自定义上下文 用于记录当前请求的租户ID
@Component
public class ApiContext {
private static final String KEY_CURRENT_TENANT_ID = "KEY_CURRENT_TENANT_ID";
private static final Map<String, Object> M_CONTEXT = new ConcurrentHashMap<>();
public void setCurrentTenantId(Long tenantId) {
M_CONTEXT.put(KEY_CURRENT_TENANT_ID, tenantId);
}
public Long getCurrentTenantId() {
return (Long) M_CONTEXT.get(KEY_CURRENT_TENANT_ID);
}
}
然后通过拦截器配置拦截规则;需要注意重写TenantLineHandler中的三个方法
- getTenantId():通过上下文获取租户ID
- ignoreTable():不进行拼接租户的表
- getTenantIdColumn():租户字段名称,默认为tenant_id
@Configuration
@MapperScan("com.demo.mapper")
@Slf4j
public class MybatisPlusConfig {
/**
* 需要过滤的表
*/
private static final List<String> IGNORE_TENANT_TABLES = new ArrayList<>();
static {
IGNORE_TENANT_TABLES.add("user");
}
@Autowired
private ApiContext apiContext;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 从当前系统上下文中取出当前请求的服务商ID,通过解析器注入到SQL中。
Long tenantId = apiContext.getCurrentTenantId();
if (tenantId == null) {
log.info("tenantId:{}", tenantId);
return new NullValue();
}
return new LongValue(tenantId);
}
@Override
public boolean ignoreTable(String tableName) {
return IGNORE_TENANT_TABLES.contains(tableName);
}
}));
return interceptor;
}
}
3. 目前遇到的问题
问题:Column 'tenant_id' specified twice
在mybatis-plus 对于多租户的管理是基于在sql中拼接租户ID实现的.在其3.4版本之前,mybatis-plus进行多租户插入时是不会对已经存在的tenant_id进行过滤的,会重复进行设置数值,这就导致出现Column 'tenant_id' specified twice问题。
解决方式:
- 使用mybatis-plus3.4.1之前的版本,可以通过自定义一个TenantSqlParser解析器并重写processInsert方法
- 使用3.4.1之后的版本;之后的版本对tenant_id进行了过滤.在3.5.1的版本中,通过
com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler#ignoreInsert方法进行判断过滤.
多租户数据源管理调研
多租户业务场景下,往往每个租户都独立一个数据库(是否独立数据源实例根据实际需要处理),每个租户的数据在数据库层面先做了隔离,在开展详细业务编写时就可以不用考虑不同租户的数据会混淆。但是随之而来的就是数据源灵活切换的需求,需要封装一套方法,在业务编写时可以根据提供的租户代码便捷的切换到对应的数据源
目前调研到有以下两种方式可以支持
- 基于Springboot提供了AbstractRoutingDataSource抽象类进行切换
- 基于dynamic-datasource-spring-boot-starter 进行动态切换
基于AbstractRoutingDataSource抽象类进行切换
实现原理
Springboot提供了AbstractRoutingDataSource抽象类,类名意思是数据源路由,让用户可以选择根据需要切换当前数据源.
AbstractRoutingDataSource中提供了如下方法
我们动态切换数据源只需要只需要改变this.targetDataSources,并且触发afterPropertiesSet(),即可改变this.resolvedDataSources;后续改变determineCurrentLookupKey()的返回值(key),在调用getConnection()时即可获取到指定的数据.
基于dynamic-datasource-spring-boot-starter 进行动态切换
源码地址: gitee.com/baomidou/dy…
实现原理
主类: com.baomidou.dynamic.datasource.DynamicRoutingDataSource
也是通过实现javax.sql.DataSource对连接进行动态切换管理
可以看到通过主类提供的方法能够实现对数据源的切换、新增、删除等操作.
具体实现方式
组件本身提供了多数据源管理的方式,可以在yml中配置了数据源之后通过注解
@Ds("dsName")在方法或者类上进行配置,指定某个方法或者类使用指定的数据源.
ps:方法上注解 优先于 类上注解。
但是该实现方式只适用于我们提前就知道某个方法使用哪个数据源,并不支持动态添加数据源后切换,并且当租户数量很多存在很多数据源时也不好管理.
初步想法是能够通过方法拦截器,通过从上下文配置中获取到的租户ID,通过维护的租户ID与数据源的映射关系,路由找到对应的数据源的方式进行支持.
/**
* @ClassName: DynamicDataSourceInterceptor
* @Description:基于租户ID切换数据源拦截器
* dynamic-datasource-spring-boot-starter 只提供了基于@Ds注解以及方法与数据源映射的切换方式
* 这里需要实现基于租户的管理
* @Author: zhouk
* @Date: 2022/5/6 3:28 PM
*/
public class DataSourceManagerInterceptor implements MethodInterceptor {
private ApiContext apiContext;
public DataSourceManagerInterceptor(ApiContext apiContext) {
this.apiContext = apiContext;
}
/**
* 模拟租户与数据源的关系
*/
private static Map<Long, String> tenantAndDataSourceMap = new HashMap<>();
static {
tenantAndDataSourceMap.put(1L, "db1");
tenantAndDataSourceMap.put(2L, "db2");
tenantAndDataSourceMap.put(3L, "db3");
}
@Override
public Object invoke(MethodInvocation methodInvocation) throws Throwable {
// 通过从上下文中获取到的租户ID 路由到对应的数据源
String dsKey = tenantAndDataSourceMap.get(apiContext.getCurrentTenantId());
// 在ThreadLocal中设置当前操作的数据源
DynamicDataSourceContextHolder.push(dsKey);
try {
return methodInvocation.proceed();
} finally {
DynamicDataSourceContextHolder.poll();
}
}
}
拦截器配置:
@Configuration
public class DataSourceManagerInterceptorConfig {
public static final String traceExecution = "execution(* com.demo.service..*.*(..))";
@Autowired
private ApiContext apiContext;
@Bean
public DefaultPointcutAdvisor defaultPointcutAdvisor() {
DataSourceManagerInterceptor interceptor = new DataSourceManagerInterceptor(apiContext);
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression(traceExecution);
// 配置增强类advisor
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor();
advisor.setPointcut(pointcut);
advisor.setAdvice(interceptor);
return advisor;
}
}
注意: 这里如果方法所在的类或者方法上有@Ds注解是不能通过DynamicDataSourceContextHolder.push(dsKey)来指定对应数据源的.
由于多租户场景的特殊性,我们的数据源不会全部配置在yml文件中,可能yml中只需要配置平台对应数据源,租户的数据源需要通过mysql或者文件读取后动态添加.
下面是动态管理数据源的模块;下面有两种方式可以动态增加数据源
- 通过自定义
DynamicDataSourceProvider,在项目启动时加载数据源
下面是实现代码
/**
* @ClassName: DatasourceConfig
* @Description: 项目启动加载数据源配置
* @Author: zhouk
* @Date: 2022/5/7 9:46 AM
*/
@Configuration
public class DatasourceConfig {
/**
* 数据源自定义加载
*
* @param properties 数据源配置
* @return DynamicDataSourceProvider
*/
@Bean
public DynamicDataSourceProvider dynamicDataSourceProvider(DynamicDataSourceProperties properties) {
Map<String, DataSourceProperty> datasourceMap = properties.getDatasource();
DataSourceProperty masterDataSourceProperty = datasourceMap.get("db1");
// 加载主数据源,即平台数据源(yml中配置的数据源)
return new AbstractJdbcDataSourceProvider(masterDataSourceProperty.getDriverClassName(), masterDataSourceProperty.getUrl(), masterDataSourceProperty.getUsername(), masterDataSourceProperty.getPassword()) {
@Override
protected Map<String, DataSourceProperty> executeStmt(Statement statement) throws SQLException {
// 该地方只能使用statement操作数据库(数据源未加载,注入service操作数据库会出现循环依赖问题)
ResultSet resultSet = statement.executeQuery("select * from data_source ");
while (resultSet.next()){
String name = resultSet.getString("name");
String username = resultSet.getString("username");
String password = resultSet.getString("password");
String url = resultSet.getString("url");
String driver = masterDataSourceProperty.getDriverClassName();
DataSourceProperty property = new DataSourceProperty();
property.setUsername(username);
property.setPassword(password);
property.setUrl(url);
property.setDriverClassName(driver);
datasourceMap.put(name, property);
}
return datasourceMap;
}
};
}
}
该方法存在的问题:当某一个数据源加载不成功时,会导致项目无法启动
- 通过
DynamicRoutingDataSource.addDataSource()创建数据源
/**
* 通过DynamicRoutingDataSource.addDataSource()创建数据源
*
* @param dsName
*/
public void addDataSource(String dsName) {
if (StringUtils.isEmpty(dsName)) {
return;
}
QueryWrapper<DataSourcePo> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(DataSourcePo::getName, dsName);
DataSourcePo dataSourcePo = dataSourceMapper.selectOne(queryWrapper);
if (ObjectUtils.isEmpty(dataSourcePo)) {
throw new IllegalArgumentException(String.format("数据源:%s不存在,请检查", dsName));
}
DynamicRoutingDataSource ds = (DynamicRoutingDataSource) dataSource;
//判断当前数据源是否已经存在
if (!ds.getDataSources().containsKey(dsName)) {
// 加锁防止重复添加同一个数据源
synchronized (LOCK) {
if (!ds.getDataSources().containsKey(dsName)) {
DataSourceProperty dataSourceProperty = this.getDataSourceProperty(dataSourcePo);
DataSource dataSource = dataSourceCreator.createDataSource(dataSourceProperty);
ds.addDataSource(dsName, dataSource);
}
}
}
}
private DataSourceProperty getDataSourceProperty(DataSourcePo dataSourcePo) {
DataSourceProperty dataSourceProperty = new DataSourceProperty();
dataSourceProperty.setDriverClassName("com.mysql.jdbc.Driver");
dataSourceProperty.setUsername(dataSourcePo.getUsername());
dataSourceProperty.setPassword(dataSourcePo.getPassword());
dataSourceProperty.setUrl(dataSourcePo.getUrl());
dataSourceProperty.setPoolName(dataSourcePo.getName());
return dataSourceProperty;
}