一、Mybatis Plus 支持的多租户
多租户指的是不同用户使用相同服务与不同数据,也就是数据隔离。
多租户的实现方式一般分为几种:数据源/数据库隔离、Schema隔离、租户字段隔离。
大多数业务量小的时候都会采用最后一种多租户方案,同质数据之间采用逻辑隔离,比如租户 ID,这样相同的业务场景不同用户可以得到不同的展现内容,达到千人千面。Mybatis Plus 支持的也就是这一种,核心类是 TenantLineInnerInterceptor。
整体的实现思路如下:
MybatisPlusInterceptor 负责拦截所有 SQL 语句,交由 TenantLineInnerInterceptor 根据语句类别做语法解析,语法解析器是开源的 JSQLParser,在抽象语法树上根据配置项注入租户字段,最后重新封装 SQL 完成多租户任务。
二、多租户实现方式
Mybatis Plus 在 Mybatis 基础上扩展的拦截器在多租户场景大放异彩,通过暴露 TenantLineHandler 接口,应用配置需要拦截的表、需要注入的租户字段,注册 MybatisPlusInterceptor 就可以快速完成多租户使用。
这里给出两种较为常用的多租户查询实现方法。
1、占位符替代
这种方法的使用场景是可以修改上游的 SQL,根据应用需要改造成带有占位符的查询语句,应用只需要拦截并识别其中的占位符就可以完成替换。
首先是注册 Mybatis Plus 拦截器,指定自定义内部拦截器。
java
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加自定义的SQL替换拦截器
interceptor.addInnerInterceptor(new TenantSqlReplacer());
return interceptor;
}
然后实现这个内部拦截器,实现 InnerInterceptor 接口,该拦截器会替换 SQL 中的占位符为实际的租户字段值。
java
public class TenantSqlReplacer implements InnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
// 获取原始SQL
String originalSql = boundSql.getSql();
// 获取替换后的SQL
String replacedSql = replaceTenantId(originalSql);
// 如果SQL有变化,则修改boundSql中的SQL
if (!originalSql.equals(replacedSql)) {
// 使用反射修改boundSql中的sql字段
try {
java.lang.reflect.Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, replacedSql);
} catch (Exception e) {
log.error("Failed to replace tenant ID in SQL: {}", e.getMessage(), e);
}
}
}
/**
* 替换SQL中的租户ID占位符为实际的租户ID
*
* @param sql 原始SQL
* @return 替换后的SQL
*/
private String replaceTenantId(String sql) {
// 获取租户ID占位符
String replaceTenantId = tenantColumnProperties.getReplaceTenantId();
if (StringUtils.isBlank(replaceTenantId)) {
return sql;
}
// 获取当前用户的租户ID,常用的手段有放入ThreadLocal缓存
String tenantId = UserUtils.getTenantId();
// 替换SQL中的租户ID占位符
// 使用正则表达式替换,确保只替换字符串中的占位符
Pattern pattern = Pattern.compile("'" + Pattern.quote(replaceTenantId) + "'");
Matcher matcher = pattern.matcher(sql);
// 替换所有匹配项
return matcher.replaceAll("'" + tenantId + "'");
}
}
2、解析语法注入
如果上游无法改造,就要考虑从查询语句语法解析入手了,在 JOIN、嵌套子查询、简单查询等各种场景中分析出 WHERE 所在地,注入租户字段。
这里扩展实现了租户字段与表支持绑定,也就是不同表可以有不同的租户字段。
yaml
# 需要使用多租户查询的表名和对应的租户字段
tenant:
tables: >
tableA,
tableB,
tableC
# 表名与租户字段的映射关系,如果没有配置则默认使用 tenant_id
columns:
tableB: tenant_other_id
同样的,注册 Mybatis Plus 拦截器。这里所不同的是,需要读取配置的多租户字段与需要过滤的表:
- 1、获取所有需要进行租户过滤的表名
- 2、按租户字段对表进行分组,相同租户字段的表放在一组
- 3、为每组创建一个 SpecificTenantColumnHandler 实例,配置特定的租户字段和表名
- 4、将每个处理器添加到 MybatisPlusInterceptor 中
java
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 获取所有表名
String[] allTables = tenantColumnProperties.getTables();
if (allTables == null || allTables.length == 0) {
return interceptor;
}
// 按租户字段分组表
Map<String, List<String>> tablesByTenantColumn = new HashMap<>();
for (String table : allTables) {
String tenantColumn = tenantColumnProperties.getColumnForTable(table);
tablesByTenantColumn.computeIfAbsent(tenantColumn, k -> new ArrayList<>()).add(table);
}
// 为每个不同的租户字段创建一个TenantLineHandler
for (Map.Entry<String, List<String>> entry : tablesByTenantColumn.entrySet()) {
String tenantColumn = entry.getKey();
List<String> tables = entry.getValue();
// 创建特定租户字段的处理器
TenantLineHandler handler = new SpecificTenantColumnHandler(tenantColumn, tables.toArray(new String[0]));
// 添加到拦截器
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(handler));
}
return interceptor;
}
SpecificTenantColumnHandler 实现 TenantLineHandler 接口,及其中的 getTenantId()、getTenantIdColumn()、ignoreTable()等方法。
3、两者比较
占位符替代 | 解析语法注入 | |
---|---|---|
实现复杂度 | 简单,添加 InnerInterceptor 内部拦截器并实现占位符替换 | 简单,添加 TenantLineInnerInterceptor 内部拦截器,实现租户字段与表等暴露方法 |
语法兼容性 | 良好,字符替换不依赖数据库与查询方言选型 | 一般,只能处理特定语法解析 |
应用适应性 | 差,SQL 上下游强耦合,只能解析约定的查询语句 | 较好,SQL 上下游无耦合,使用常用查询语法均可支持 |
扩展性 | 一般,耦合需要上下游同步改造 | 略好,根据需要扩展数据库语法支持,工作量较大 |
三、已知问题与例外
从上面分析可以看出,这里面的核心是 JSQLParser 实现的语法解析。因为对于应用支持场景广泛时,不管是普通查询,还是多 JOIN、嵌套子查询,以及是否带有 WHERE 都无法简单通过穷举找到需要多租户过滤指定表的注入位置。
能支持多少种数据库类型以及语法方言就看 JSQLParser 了,毕竟是一个个数据库引擎缩小版实现,工作量不容小觑。这是 JSQLParser 支持列表:
RDBMS | Statements |
---|---|
Oracle MS SQL Server and Sybase Postgres MySQL and MariaDB DB2 H2 and HSQLDB and Derby SQLite | SELECT INSERT , UPDATE , UPSERT , MERGE DELETE , TRUNCATE TABLE CREATE ... , ALTER .... , DROP ... WITH ... |
Salesforce SOQL | INCLUDES , EXCLUDES |
Piped SQL (also known as FROM SQL) |
由此可见,想要扩展 StarRocks、Clickhouse 等分布式数据库,还是 MongoDB、Redis 等非关系型数据库,甚至是达梦这样的国产数据库,兼容性问题都没法做到。这是 ORM 领域的已知问题,数据库厂商众多无法逐一满足,因此 JSQLParser 也只能选取符合 SQL 2016 行业标准的关系型数据库先行落地了,不支持的语法也就在所难免了。