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

相关推荐
重整旗鼓~22 分钟前
38.springboot使用rabbitmq
spring boot·rabbitmq·java-rabbitmq
会飞的架狗师31 分钟前
【SpringBoot实战】优雅关闭服务
java·spring boot·后端
椰椰椰耶1 小时前
[网页五子棋][匹配模块]前后端交互接口(消息推送机制)、客户端开发(匹配页面、匹配功能)
java·spring boot·json·交互·html5·web
梁小呆瓜1 小时前
掌握Jackson的灵活扩展:@JsonAnyGetter与@JsonAnySetter详解
java·spring boot·json
zeijiershuai2 小时前
SpringBoot Controller接收参数方式, @RequestMapping
java·spring boot·后端
王翼鹏6 小时前
Spring boot 策略模式
java·spring boot·策略模式
欧阳有财7 小时前
[java八股文][JavaSpring面试篇]SpringBoot
java·spring boot·面试
Wilson Chen10 小时前
告别硬编码!用工厂模式优雅构建可扩展的 Spring Boot 应用 [特殊字符]
java·spring boot·spring
crud11 小时前
Spring Boot 定时任务全攻略:从入门到实战,一篇文章讲清楚!
spring boot
风象南13 小时前
SpringBoot数据转换的4种对象映射方案
java·spring boot·后端