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

相关推荐
Mikko73 小时前
Redis 连接数满了服务端有错误信息吗?连接数不增加却命令超时?6.2 实测连接池、超时与大 key 怎么判定
java·spring boot·redis·后端
真上帝的左手6 小时前
30. 技术专题-‌应用框架组件-关系型数据库-MySQL-Mybatis
数据库·分布式·mysql·中间件·mybatis
计算机毕设定制辅导-无忧学长8 小时前
《基于微信小程序健康习惯激励系统设计与实现》
java·spring boot·mysql·微信小程序·uniapp·健康习惯激励系统
vipxieliang9 小时前
ValidX 正则表达式预编译优化:Pattern 复用策略
java·spring boot·后端
雪芽蓝域zzs9 小时前
第 2 章:整合 MySQL + MyBatis,搭建数据库基础、实体类、Mapper、测试查询
数据库·mysql·mybatis
余槐i10 小时前
Spring Boot 3.2 偶发500无日志:线程池复用导致请求上下文丢失
spring boot·redis·后端·mysql·性能优化
杨凯凡10 小时前
Spring Boot 的 @Transactional 为什么会失效?从代理、自调用到异常回滚的排查实战
java·spring boot·spring·事务·后端开发
云创智城-yuncitys1 天前
YunParking 4.0:JDK 17 与 Spring Boot 3.2 的停车云升级
java·开发语言·spring boot
wljt1 天前
公司二-面试题
java·开发语言·mybatis
paopaokaka_luck1 天前
灾害应急物资管理系统(AI应急助手、Echarts可视化数据看板、捐赠入库联动、 物资全流程管理、库存变动追溯、灾情资讯与互动反馈、物资发放公示)
java·前端·javascript·spring boot·mysql·tomcat·echarts