点击关注上方蓝字,阅读更多干货~
多租户(Multi-Tenancy)是一种软件架构模式,其中单个软件实例可以同时服务于多个租户(Tenant)。租户是指独立的组织、公司或个人,他们使用共享的软件系统,并且每个租户之间的数据和配置是相互隔离的。
多租户技术或称多重租赁技术,简称多租户技术,是一种软件架构技术,允许多个用户或组织共享同一个软件应用程序、服务或基础设施,同时保持彼此之间的逻辑和数据隔离。
例如,在多租户技术之前软件产品销售并交付给客户,需要到客户现场交付或重复部署一套系统,这种模式简单,但是扩展性和维护成本较高;而在多租户技术架构实现下,将不同租户的数据存储在同一个数据库中,但通过数据隔离和多租户架构来确保数据之间的逻辑和物理隔离。每个租户都有自己的数据表或模式,以实现数据的隔离和安全性。这种方式在数据共享和管理方面更加灵活。
图2 独立数据库模型的多租户实现
隔离数据表是在共享数据库的基础上,为每个租户创建独立的数据表空间。每个租户拥有自己的数据表集合,只能访问和操作自己的数据表。这种隔离级别可进一步增强数据的安全性和隐私保护,避免租户之间的数据混淆和干扰。具有以下优点:
资源节约:共享数据库可以节约资源,避免为每个租户都创建独立的数据库实例。这样可以减少服务器的数量和维护成本,并提高资源利用率。适用于租户之间的数据访问模式相似或数据量较小的情况。
数据共享:共享数据库允许不同租户之间共享部分数据,促进协作和数据交互。对于某些业务场景,租户可能需要共享一些公共数据,例如用户信息、产品目录等。通过共享数据库,可以简化数据共享和协作的管理,提高系统的灵活性和效率。
隔离数据表:尽管共享数据库,但通过隔离数据表可以确保租户之间的数据隔离和安全性。每个租户拥有自己独立的数据表空间,只能访问和操作自己的数据表。这种隔离级别提供了更高的数据保护和安全性,防止数据泄露和干扰。
节约资源:使用共享数据库和共享数据表,可以减少数据库实例和数据表空间的数量,从而节约服务器和存储资源,提高资源利用率。
简化管理:共享数据库和共享数据表可以简化管理,减少管理员的工作量,提高系统的可维护性和可扩展性。
跨租户分析:共享数据库和共享数据表使得多个租户的数据可以在同一数据存储中查询和分析,从而方便了跨租户数据分析和报告的生成,为租户提供全局的、综合的业务洞察。
图4 共享数据库,共享数据表模型的多租户实现
图5 用户表增加租户ID视图
隔离方案 | 成本 | 支持租户用户数量 | 隔离级别 | 维护成本 | 故障情况下,数据恢复复杂度 |
独立数据库 | 高 | 少 | 好 | 高 | 容易 |
共享数据库,隔离数据表 | 中 | 较多 | 较为安全 | 中 | 较为困难 |
共享数据库,共享数据表 | 底 | 非常多 | 底 | 低 | 困难 |
独立数据库适用于金融、医疗等对数据的隔离和安全性要求非常高的领域;共享数据库,隔离数据表适用于租户之间的数据相对较为独立,但仍需要共享一些公共资源和提高数据管理效率的场景,如客户信息、销售机会等数据需要被不同用户以及各自的团队所共享和协同编辑的 CRM 系统;共享数据库,共享数据表适用于以最少的维护成本为最多的租户提供服务,并且接受牺牲隔离级别换取降低成本的业务场景。
-- 租户id 为 1 的数据库名称为db_00000001,租户id 为 2 的数据库名称为db_00000002
-- 业务代码 xml SQL,查询合同表:
select * from document;
-- 通过 Mybatis-plus 拦截器添加动态数据库名的方式,租户 id 为 1 情况下查询合同表
select * from `db_00000001`.document;
而不同实例的数据库实现多租户较为复杂,需要在执行过程中不停动态切换数据源,还要考虑多个数据源之间的事务控制,搜索资料都是推荐第三方框架:Dynamic-datasource
,一个基于SpringBoot
的快速集成多数据源的启动器。
3.5.2 共享数据库,隔离数据表技术方案
-- 合同表,租户id 为 1 的合同表为document_00000001,租户id 为 2 的合同表为document_00000002
-- 业务代码 xml SQL,查询合同表:
select * from document;
-- 通过 Mybatis-plus 拦截器添加动态数据库名的方式,租户 id 为 1 情况下查询合同表
select * from `document_00000001`.document;
3.5.3 共享数据库,共享数据表技术方案
所有租户数据共用一个数据库,共享数据表,仅仅是在每个业务表中增加tenant_id
字段用于区分不同的租户数据,用户登录系统,从Header
头传递租户信息,租户在「增删插改」操作数据时,在原sql语句基础上拼接对应的tenant_id
,这种可以通过 AOP
切面方式或者Mybatis-plus
拦截器实现,确保数据之间的隔离,效果如下:
-- 业务代码 xml SQL,查询合同表:
select * from document;
-- 通过 Mybatis-plus 拦截器拼接 租户id 的方式,租户 id 为 1 情况下查询合同表
select * from document where tenant_id = 1;
同个实例的不同数据库,以及同个数据库不同数据表的方案提供了一定的数据隔离,但是数据隔离不彻底,遇到业务表信息变更,则需要将所有的租户表都需要进行相应的变更,这就导致维护较为复杂。
同个数据库同个数据表方案成本最低,但是所有数据在一起,数据隔离性也是最低的,这就导致单个租户的数据恢复以及备份很复杂。
Mybatis-plus
官方提供多租户插件实现多租户技术。多租户插件实现InnerInterceptor
类,而InnerInterceptor
类作用实际就是拦截器,多租户插件在SQL
语句构建阶段注入tenant_id
字段,详情见下文「Mybatis-plus 多租户插件详解」。
Mybatis
实现多租户技术,需要手动实现拦截器,拦截SQL
语句在构建阶段注入tenant_id
字段,简单示例如下,实现TenantInterceptor
拦截器:public class TenantInterceptor implements Interceptor {
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
// 获取当前租户标识,可以从 ThreadLocal、Spring Security 等获取
String tenantId = getCurrentTenantId();
String sql = boundSql.getSql();
String newSql = sql + " WHERE tenant_id = '" + tenantId + "'";
BoundSql newBoundSql = new BoundSql(mappedStatement.getConfiguration(), newSql,
boundSql.getParameterMappings(), parameter);
MappedStatement newMappedStatement = copyFromMappedStatement(mappedStatement, new BoundSqlSqlSource(newBoundSql));
invocation.getArgs()[0] = newMappedStatement;
return invocation.proceed();
}
// 实现其他必要的方法...
}
在MyBatis
配置文件中注册自定义Interceptor
。在<plugins>
部分添加如下内容:
<plugins>
<plugin interceptor="com.example.TenantInterceptor"/>
</plugins>
MyBatis
将会自动调用拦截器中的逻辑,确保SQL
语句会被添加上租户标识条件。JdbcTemplate
实现多租户功能也可以通过拦截器和动态SQL
来实现,和Mybatis
类似,简单示例如下:Interceptor
,实现StatementInterceptor
接口的beforeExecution
拦截SQL
执行。public class TenantStatementInterceptor implements StatementInterceptor {
public void beforeExecution(PreparedStatement ps) {
String tenantId = getCurrentTenantId();
try {
// 获取原始 SQL 语句
String originalSql = ps.toString();
// 修改 SQL 语句,添加租户标识条件
String newSql = originalSql + " WHERE tenant_id = '" + tenantId + "'";
// 替换 PreparedStatement 中的 SQL
Field field = ps.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(ps, newSql);
} catch (Exception e) {
// 处理异常
}
}
}
注册TenantStatementInterceptor
拦截器:
<bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
<property name="dataSource" ref="dataSource" />
<property name="statementInterceptors">
<list>
<ref bean="tenantStatementInterceptor" />
</list>
</property>
</bean>
<bean id="tenantStatementInterceptor" class="com.example.TenantStatementInterceptor" />
在业务代码中,当执行查询或更新操作时,JdbcTemplate
将会自动调用拦截器中的逻辑,确保SQL
语句会被添加上租户标识条件。
Mybatis-plus
MybatisJdbcTemplate
技术实现多租户方案,发现实现方案大同小异,都是基于SQL
语句的拦截,实现在语句上拼接上租户标识,而Mybatis-plus
官方提供了TenantLineInnerInterceptor
插件,支持特殊的SQL
语句。基于共享数据库,共享数据表的实现方式,具有数据隔离性、易于维护、扩展性、安全性、灵活配置的优点。Mybatis
插件,实际上就是一个拦截器,应用代理模式,在方法级别上进行拦截。可以实现分页、SQL
打印监控、公共字段赋值等功能,基本上可以控制SQL
执行的各个阶段,如执行阶段,参数处理阶段,语法构建阶段,结果集处理阶段,具体可以根据项目业务来实现对应业务逻辑。Mybatis-plus
提供 InnerInterceptor
来实现插件功能。
TenantLineInnerInterceptor
类,实现InnerInterceptor
接口,其作用是 sql 拦截器,继承JsqlParserSupport
类,其作用是 sql 解析。
5.1.1 TenantLineInnerInterceptor 拦截器实现原理
InnerInterceptor
接口方法功能:public interface InnerInterceptor {
/**
* 判断是否执行 {@link Executor#query(MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql)}
* <p>
* 如果不执行query操作,则返回 {@link Collections#emptyList()}
*
* @param executor Executor(可能是代理对象)
* @param ms MappedStatement
* @param parameter parameter
* @param rowBounds rowBounds
* @param resultHandler resultHandler
* @param boundSql boundSql
* @return 新的 boundSql
*/
default boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
return true;
}
/**
* {@link Executor#query(MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql)} 操作前置处理
* <p>
* 改改sql啥的
*
* @param executor Executor(可能是代理对象)
* @param ms MappedStatement
* @param parameter parameter
* @param rowBounds rowBounds
* @param resultHandler resultHandler
* @param boundSql boundSql
*/
default void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
// do nothing
}
/**
* 判断是否执行 {@link Executor#update(MappedStatement, Object)}
* <p>
* 如果不执行update操作,则影响行数的值为 -1
*
* @param executor Executor(可能是代理对象)
* @param ms MappedStatement
* @param parameter parameter
*/
default boolean willDoUpdate(Executor executor, MappedStatement ms, Object parameter) throws SQLException {
return true;
}
/**
* {@link Executor#update(MappedStatement, Object)} 操作前置处理
* <p>
* 改改sql啥的
*
* @param executor Executor(可能是代理对象)
* @param ms MappedStatement
* @param parameter parameter
*/
default void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) throws SQLException {
// do nothing
}
/**
* {@link StatementHandler#prepare(Connection, Integer)} 操作前置处理
* <p>
* 改改sql啥的
*
* @param sh StatementHandler(可能是代理对象)
* @param connection Connection
* @param transactionTimeout transactionTimeout
*/
default void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
// do nothing
}
/**
* {@link StatementHandler#getBoundSql()} 操作前置处理
* <p>
* 只有 {@link BatchExecutor} 和 {@link ReuseExecutor} 才会调用到这个方法
*
* @param sh StatementHandler(可能是代理对象)
*/
default void beforeGetBoundSql(StatementHandler sh) {
// do nothing
}
default void setProperties(Properties properties) {
// do nothing
}
}
TenantLineInnerInterceptor
多租户插件实现InnerInterceptor
接口:
/**
* sql 前置操作操作,改改 sql 啥的
* Executor executor 可能是代理对象
*/
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
// 第一步
// @InterceptorIgnore(tenantLine = "true") 注解的作用
// 如果开启了这个忽略租户插件注解直接返回
if (InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) return;
// ms 执行 sql 的 map 相关信息,id 就是 xml 方法里面,比如:id com.baomidou.mybatisplus.samples.tenant.mapper.UserMapper.myCount
// bound 就是 sql 的信息,参数等
PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);
// 获取 sql 语句
mpBs.sql(parserSingle(mpBs.sql(), null));
}
public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
MappedStatement ms = mpSh.mappedStatement();
SqlCommandType sct = ms.getSqlCommandType();
if (sct == SqlCommandType.INSERT || sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {
if (InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {
return;
}
PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql();
mpBs.sql(parserMulti(mpBs.sql(), null));
}
}
Mybatis-plus
插件配置,注册多租户插件:
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
public Expression getTenantId() {
// 租户的信息
return new LongValue(1);
}
// 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件
public boolean ignoreTable(String tableName) {
return false;
}
}));
return interceptor;
}
MybatisPlusInterceptor
调用实现了Interceptor
接口并注册的拦截器:public class MybatisPlusInterceptor implements Interceptor {
private List<InnerInterceptor> interceptors = new ArrayList<>();
public Object intercept(Invocation invocation) throws Throwable {
Object target = invocation.getTarget();
Object[] args = invocation.getArgs();
if (target instanceof Executor) {
final Executor executor = (Executor) target;
Object parameter = args[1];
boolean isUpdate = args.length == 2;
MappedStatement ms = (MappedStatement) args[0];
if (!isUpdate && ms.getSqlCommandType() == SqlCommandType.SELECT) {
RowBounds rowBounds = (RowBounds) args[2];
ResultHandler resultHandler = (ResultHandler) args[3];
BoundSql boundSql;
if (args.length == 4) {
boundSql = ms.getBoundSql(parameter);
} else {
// 几乎不可能走进这里面,除非使用Executor的代理对象调用query[args[6]]
boundSql = (BoundSql) args[5];
}
for (InnerInterceptor query : interceptors) {
if (!query.willDoQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql)) {
return Collections.emptyList();
}
query.beforeQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql);
}
CacheKey cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
return executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
} else if (isUpdate) {
for (InnerInterceptor update : interceptors) {
if (!update.willDoUpdate(executor, ms, parameter)) {
return -1;
}
update.beforeUpdate(executor, ms, parameter);
}
}
} else {
// StatementHandler
final StatementHandler sh = (StatementHandler) target;
// 目前只有StatementHandler.getBoundSql方法args才为null
if (null == args) {
for (InnerInterceptor innerInterceptor : interceptors) {
innerInterceptor.beforeGetBoundSql(sh);
}
} else {
Connection connections = (Connection) args[0];
Integer transactionTimeout = (Integer) args[1];
for (InnerInterceptor innerInterceptor : interceptors) {
innerInterceptor.beforePrepare(sh, connections, transactionTimeout);
}
}
}
return invocation.proceed();
}
}
通过@AllArgsConstructor
注解,用于实例化TenantLineInnerInterceptor
,需传递租户处理器TenantLineHandler
接口参数,用于后续多租户插件应用配置。
图6 TenantLineInnerInterceptor 类的多租户配置自动注入
public interface TenantLineHandler {
/**
* 获取租户 ID 值表达式,只支持单个 ID 值
* <p>
*
* @return 租户 ID 值表达式
*/
Expression getTenantId();
/**
* 获取租户字段名
* <p>
* 默认字段名叫: tenant_id
*
* @return 租户字段名
*/
default String getTenantIdColumn() {
return "tenant_id";
}
/**
* 根据表名判断是否忽略拼接多租户条件
* <p>
* 默认都要进行解析并拼接多租户条件
*
* @param tableName 表名
* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
*/
default boolean ignoreTable(String tableName) {
return false;
}
/**
* 忽略插入租户字段逻辑
*
* @param columns 插入字段
* @param tenantIdColumn 租户 ID 字段
* @return
*/
default boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {
return columns.stream().map(Column::getColumnName).anyMatch(i -> i.equalsIgnoreCase(tenantIdColumn));
}
}
Mybatis-plus
官方是使用JsqlParserSupport
解析SQL
成语法树,特此对一些主要类分析。实现了SelectBody
接口,而SelectBody
可以理解为Select
语句对象,以select sql
语句为例。
查询用户信息,并且内联user_addrs
表:
-- plainSelect 就是整条 sql 语句
select users.id, users.name, users.tenant_id
from users
join user_addrs on users.id = user_addrs.id
where id in (1, 2, 3);
图7 PlainSelect 类属性
user.id, users.name, users.tenant_id 是SelectItem
类信息
users
是FromItem
类信息
users_addrs on users.id = user_addrs.id 是Join
类信息
id in (1,2,3)
是Expression
类信息
图8 select sql 语句解析成对象
针对使用子查询sql
语句,将子查询的语句转换SubSelect
类:
select *
from users
where id in (select user_id from user_addrs)
select user_id from user_addrs
是SubSelect
类信息
with
其实就是一个子查询抽取出来,换了一个别名,和视图的区别:with as
等同于一次性视图,只会持续到下一个查询,在之后就不能再被引用。with
语法,不适用于mysql 8.0
版本之前。简单的with as
sql
语句:
-- 经过 JSqlParser 解析之后,WithItem 是 select * from users
with a as (select * from users) select * from a;
select * from users
是WithItem
类信息
当我们使用INTERSECT、EXCEPT、MINUS、UNION
这些交并集查询时,就会转换为SetOperationList
对象。
图11 union sql 语句运行断点
select id from users union select user_id from user_addrs 是SetOperationList
类信息
select id from users和select user_id from user_addrs 是SelectBodyList
类信息
Insertsql
语句的转换Insert
类:图12 insert sql 语句解析成对象截图
user_addrs
是Table
类信息
id, user_id, name, tenant_id, create_time, update_time 是Column
类信息
(3, 3, 'Tom Home', 2, '2023-03-08 18:01:47', '2023-03-08 18:01:47')和(4, 4, 'Sandy Home', 3, '2023-03-08 18:01:47', '2023-03-08 18:01:47')是ItemsList
类信息
Mybatis-Plus
多租户插件在执行select
语句时,会调用MybatisPlusInterceptor
类的intercept
方法,作用是对sql
语句进行解析和修改。再调用多租户插件的beforeQuery
方法,至此已进入多租户插件流程,多租户插件首先对sql
进行解析并组装各种有含义的类,依次执行:
processSelectBody
方法:处理select语句,处理并拆分with as
语句,处理并拆分intersect,except,minus,union
语句
protected void processSelectBody(SelectBody selectBody) {
if (selectBody == null) {
return;
}
if (selectBody instanceof PlainSelect) {
// 处理 select 语句
processPlainSelect((PlainSelect) selectBody);
} else if (selectBody instanceof WithItem) {
// 处理 with as 语句
WithItem withItem = (WithItem) selectBody;
processSelectBody(withItem.getSubSelect().getSelectBody());
} else {
// 处理 INTERSECT、EXCEPT、MINUS、UNION 语句
SetOperationList operationList = (SetOperationList) selectBody;
List<SelectBody> selectBodyList = operationList.getSelects();
if (CollectionUtils.isNotEmpty(selectBodyList)) {
selectBodyList.forEach(this::processSelectBody);
}
}
}
processSelectItem
方法:处理select
字段,包含处理子查询,方法函数场景
processWhereSubSelect
方法:处理where
条件的子查询,包含处理子查询以及!=, or, and, exist, in
等场景 processFromItem
方法:处理from
表,包含处理临时表场景processJoins
方法:处理join
表,包含处理子查询,内连接,右链接,左链接场景
/**
* 处理 PlainSelect
*/
protected void processPlainSelect(PlainSelect plainSelect) {
// 处理 select 的子查询
List<SelectItem> selectItems = plainSelect.getSelectItems();
if (CollectionUtils.isNotEmpty(selectItems)) {
selectItems.forEach(this::processSelectItem);
}
// 处理 where 中的子查询
Expression where = plainSelect.getWhere();
processWhereSubSelect(where);
// 获取 主表
FromItem fromItem = plainSelect.getFromItem();
// 获取 table,处理 From 表子查询
List<Table> list = processFromItem(fromItem);
List<Table> mainTables = new ArrayList<>(list);
// 处理 join
List<Join> joins = plainSelect.getJoins();
if (CollectionUtils.isNotEmpty(joins)) {
mainTables = processJoins(mainTables, joins);
}
// 当有 mainTable 时,进行 where 租户ID 条件追加
if (CollectionUtils.isNotEmpty(mainTables)) {
plainSelect.setWhere(builderExpression(where, mainTables));
}
}
builderExpression
方法:多租户插件会根据获取到的租户信息,将其添加到原始SQL
语句中
/**
* 处理条件
*/
protected Expression builderExpression(Expression currentExpression, List<Table> tables) {
// 没有表需要处理直接返回
if (CollectionUtils.isEmpty(tables)) {
return currentExpression;
}
// 过滤配置的忽略表信息
List<Table> tempTables = tables.stream()
.filter(x -> !tenantLineHandler.ignoreTable(x.getName()))
.collect(Collectors.toList());
// 没有表需要处理直接返回
if (CollectionUtils.isEmpty(tempTables)) {
return currentExpression;
}
// 获取租户ID
Expression tenantId = tenantLineHandler.getTenantId();
List<EqualsTo> equalsTos = tempTables.stream()
// 增加别名,如果没有别名就用表名
.map(item -> new EqualsTo(getAliasColumn(item), tenantId))
.collect(Collectors.toList());
// 注入的表达式
Expression injectExpression = equalsTos.get(0);
// 如果有多表,则用 and 连接
if (equalsTos.size() > 1) {
for (int i = 1; i < equalsTos.size(); i++) {
injectExpression = new AndExpression(injectExpression, equalsTos.get(i));
}
}
if (currentExpression == null) {
return injectExpression;
}
// 存在 or 条件的话,则用 () 括起来如 a.tenant_id = 1 and (id = 2 or id = 1)
if (currentExpression instanceof OrExpression) {
return new AndExpression(new Parenthesis(currentExpression), injectExpression);
} else {
return new AndExpression(currentExpression, injectExpression);
}
}
Select
语句执行源码类似,Mybatis-Plus
多租户插件在执行Insert Update Delete
语句时,最后也是调用多租户插件的beforeQuery
方法,通过JsqlParserSupport
类对sql
语句进行解析并组装各种有含义的类后,根据不同类型的Insert Update Delete
sql
语句执行不同的多租户插件业务流程。Insert
sql
语句调用多租户插件的processInsert
方法
protected void processInsert(Insert insert, int index, String sql, Object obj) {
if (tenantLineHandler.ignoreTable(insert.getTable().getName())) {
// 过滤退出执行
return;
}
List<Column> columns = insert.getColumns();
if (CollectionUtils.isEmpty(columns)) {
// 针对不给列名的 insert 不处理
return;
}
String tenantIdColumn = tenantLineHandler.getTenantIdColumn();
if (tenantLineHandler.ignoreInsert(columns, tenantIdColumn)) {
// 针对已给出租户列的 insert 不处理
return;
}
columns.add(new Column(tenantIdColumn));
// fixed gitee pulls/141 duplicate update【测试了下效果无用】
List<Expression> duplicateUpdateColumns = insert.getDuplicateUpdateExpressionList();
if (CollectionUtils.isNotEmpty(duplicateUpdateColumns)) {
EqualsTo equalsTo = new EqualsTo();
equalsTo.setLeftExpression(new StringValue(tenantIdColumn));
equalsTo.setRightExpression(tenantLineHandler.getTenantId());
duplicateUpdateColumns.add(equalsTo);
}
Select select = insert.getSelect();
if (select != null) {
this.processInsertSelect(select.getSelectBody());
} else if (insert.getItemsList() != null) {
// fixed github pull/295
ItemsList itemsList = insert.getItemsList();
if (itemsList instanceof MultiExpressionList) {
// 多批量 insert into values
((MultiExpressionList) itemsList).getExpressionLists().forEach(el -> el.getExpressions().add(tenantLineHandler.getTenantId()));
} else {
((ExpressionList) itemsList).getExpressions().add(tenantLineHandler.getTenantId());
}
} else {
throw ExceptionUtils.mpe("Failed to process multiple-table update, please exclude the tableName or statementId");
}
}
Delete
sql
语句调用多租户插件的processDelete
方法
protected void processDelete(Delete delete, int index, String sql, Object obj) {
if (tenantLineHandler.ignoreTable(delete.getTable().getName())) {
// 过滤退出执行
return;
}
delete.setWhere(this.andExpression(delete.getTable(), delete.getWhere()));
}
Update
sql
语句调用多租户插件的processUpdate
方法
protected void processUpdate(Update update, int index, String sql, Object obj) {
final Table table = update.getTable();
if (tenantLineHandler.ignoreTable(table.getName())) {
// 过滤退出执行
return;
}
// 拼接 where 条件
update.setWhere(this.andExpression(table, update.getWhere()));
}
Mybatis-Plus
中,采用的是“共享数据库,共享数据表”的方式,此方式需要我们在每个数据表中增加tenant_id
字段信息,插件会在操作语句时,自动添加tenant_id
字段信息,用于逻辑区分租户。ALTER TABLE `gift_card_refunds` ADD `tenant_id` BIGINT(20)
UNSIGNED NULL DEFAULT NULL COMMENT '租户id' AFTER `updated_time`
通过配置文件中读取多租户插件的“忽略应用多租户插件表列表”和“多租户插件开关”来配置多租户插件。
"tenantplugin") (prefix =
public class TenantPluginProperties implements Serializable {
/**
* 忽略应用多租户插件表列表
*/
private List<String> ignoreTables;
/**
* 多租户插件开关
*/
private Boolean enable;
}
注册并实现TenantLineInnerInterceptor
内容,根据具体的数据库类型和租户信息,实现相应的多租户SQL
解析器。
public TenantPluginProperties tenantPluginProps() {
return new TenantPluginProperties();
}
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantPluginProperties tenantPluginProperties) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(this.tenantPluginInterceptor(tenantPluginProperties));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
/**
* 多租户插件 拦截器
* @param tenantPluginProperties
* @return
*/
private TenantLineInnerInterceptor tenantPluginInterceptor(TenantPluginProperties tenantPluginProperties) {
return new TenantLineInnerInterceptor(new TenantLineHandler() {
public Expression getTenantId() {
Long tenantId = ReqUtil.getTenantId();
return new LongValue(tenantId);
}
public boolean ignoreTable(String tableName) {
// 如果没配置 nacos 配置,或者 nacos 配置禁用多租户插件
if (ObjectUtils.isEmpty(tenantPluginProperties.getEnable()) ||
Boolean.FALSE.equals(tenantPluginProperties.getEnable())) {
return true;
}
// 过滤配置指定表忽略使用多租户插件
if (CollectionUtils.isNotEmpty(tenantPluginProperties.getIgnoreTables()) &&
tenantPluginProperties.getIgnoreTables().contains(tableName)) {
return true;
}
return false;
}
});
}
nacos
提供“忽略应用多租户插件表列表”和“多租户插件开关”配置,方便开发者针对不同服务、不同业务表灵活配置需要自动添加tenant_id
字段信息。tenantplugin:
# 忽略的表应用多租户插件
ignoreTables:
tenant_infos
# 启动多租户插件
enable: true
Mybatis-Plus
多租户插件后,在查询用户信息时,自动在where
条件拼接users.tenant_id = 1
条件,从而实现多租户。当手动Insert `tenant_id`
字段,源码在sql
后面拼接tenant_id
字段,导致异常Column `tenant_id` sepcifed twice
。
6.3.2 Join 与 Inner Join 效果不同
使用Inner Join
查询语句,在语句后面拼接了users.tenant_id = 1 and user_addrs.tenant_id = 1条件,而使用Join
的查询语句,仅仅是在语句后面拼接了users.tenant_id = 1
条件。
原因是:processJoins
代码块仅判断 join.isRight() join.isLeft() join.isInner(),而未判断使用Join
的sql
语句。
使用insert into on duplicate key update
语句,引用Mybatis-Plus
多租户插件后,未在sql
语句后面拼接 租户id。
图19 多租户插件下 insert into on duplicate key update 语句效果展示
原因是:processInsert
方法中duplicateUpdateColumn
处理后的变量最后未使用。
在mapper
接口上加上@InterceptorIgnore(tenantLine = "true")
注解,即可使得多租户屏蔽某个特定mapper
的方法。
图21 应用忽略多租户插件
原因是:源码检测mapper
接口是否加上忽略多租户插件注解,如果加入则直接跳过。
mapper
方法添加注解方式忽略使用多租户插件Mabtis-plus 多租户插件是一种可靠、灵活和易于维护的解决方案,可以帮助开发人员轻松构建多租户应用程序,提高开发效率和应用程序的可靠性。但是如果系统定位是超级管理员查看所有租户数据的平台,则不适用 Mabtis-plus 多租户插件。
本文作者
麦金,来自缦图互联网中心后端团队。
--------END--------
也许你还想看