Mybatis Plus 多租户实现思路分析

一、Mybatis Plus 支持的多租户

多租户指的是不同用户使用相同服务与不同数据,也就是数据隔离。

多租户的实现方式一般分为几种:数据源/数据库隔离、Schema隔离、租户字段隔离。

大多数业务量小的时候都会采用最后一种多租户方案,同质数据之间采用逻辑隔离,比如租户 ID,这样相同的业务场景不同用户可以得到不同的展现内容,达到千人千面。Mybatis Plus 支持的也就是这一种,核心类是 TenantLineInnerInterceptor。

整体的实现思路如下:

sequenceDiagram participant MyBatis participant MybatisPlus as MybatisPlusInterceptor participant TenantInterceptor as TenantLineInnerInterceptor participant JSqlParser participant DB as 数据库 MyBatis->>MybatisPlus: 执行SQL MybatisPlus->>TenantInterceptor: 调用内部拦截器 TenantInterceptor->>JSqlParser: 解析SQL JSqlParser-->>TenantInterceptor: 返回解析结果 TenantInterceptor->>TenantInterceptor: 使用TenantLineHandler获取租户信息 TenantInterceptor->>TenantInterceptor: 修改SQL添加租户条件 TenantInterceptor-->>MybatisPlus: 返回修改后的SQL MybatisPlus-->>MyBatis: 返回修改后的SQL MyBatis->>DB: 执行修改后的SQL DB->>DB: 执行SQL并过滤数据 DB-->>MyBatis: 返回查询结果

MybatisPlusInterceptor 负责拦截所有 SQL 语句,交由 TenantLineInnerInterceptor 根据语句类别做语法解析,语法解析器是开源的 JSQLParser,在抽象语法树上根据配置项注入租户字段,最后重新封装 SQL 完成多租户任务。

二、多租户实现方式

Mybatis Plus 在 Mybatis 基础上扩展的拦截器在多租户场景大放异彩,通过暴露 TenantLineHandler 接口,应用配置需要拦截的表、需要注入的租户字段,注册 MybatisPlusInterceptor 就可以快速完成多租户使用。

flowchart TD B[添加MyBatis-Plus依赖] B --> C[实现TenantLineHandler接口 - 定义租户ID获取逻辑 - 设置租户字段名 - 配置需要忽略的表] C --> D[创建MybatisPlusInterceptor] D --> E[添加TenantLineInnerInterceptor并注入TenantLineHandler实现] E --> F[注册MybatisPlusInterceptor为Spring Bean]

这里给出两种较为常用的多租户查询实现方法。

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 行业标准的关系型数据库先行落地了,不支持的语法也就在所难免了。

相关推荐
程序员张336 分钟前
Maven编译和打包插件
java·spring boot·maven
灵犀学长2 小时前
EasyExcel之SheetWriteHandler:解锁Excel写入的高阶玩法
spring boot·excel
zwjapple3 小时前
docker-compose一键部署全栈项目。springboot后端,react前端
前端·spring boot·docker
DuelCode6 小时前
Windows VMWare Centos Docker部署Springboot 应用实现文件上传返回文件http链接
java·spring boot·mysql·nginx·docker·centos·mybatis
优创学社26 小时前
基于springboot的社区生鲜团购系统
java·spring boot·后端
幽络源小助理6 小时前
SpringBoot基于Mysql的商业辅助决策系统设计与实现
java·vue.js·spring boot·后端·mysql·spring
猴哥源码6 小时前
基于Java+springboot 的车险理赔信息管理系统
java·spring boot
Code blocks7 小时前
使用Jenkins完成springboot项目快速更新
java·运维·spring boot·后端·jenkins
荔枝吻8 小时前
【沉浸式解决问题】idea开发中mapper类中突然找不到对应实体类
java·intellij-idea·mybatis
JAVA学习通8 小时前
Mybatis--动态SQL
sql·tomcat·mybatis