1. 多租户插件的前世今生

第一次接触多租户概念是在2015年做SaaS平台时,当时为了给不同客户隔离数据,硬是在每个SQL后面手动拼接"AND tenant_id=xxx"。这种土办法不仅容易出错,还经常忘记加条件。直到发现Mybatis-Plus的多租户插件,才明白原来数据隔离可以如此优雅。

多租户(Multi-Tenancy)的本质就像一栋写字楼:整栋楼共用基础设施(数据库实例),但每个公司(租户)拥有独立的办公区域(数据空间)。在技术实现上,主要分为三种模式:

  • 独立数据库:成本最高但隔离性最好
  • 共享数据库独立Schema:折中方案
  • 共享数据库共享Schema:成本最低但需要字段隔离

Mybatis-Plus选择的是第三种方案,通过tenant_id字段实现数据隔离。这就像给每张桌子(数据表)贴上公司标签(tenant_id),查询时自动过滤非本公司物品。

2. 核心拦截器工作原理揭秘

2.1 拦截器链的装配过程

在SpringBoot项目中配置多租户插件时,我们需要在MybatisPlusInterceptor中添加TenantLineInnerInterceptor。这个顺序很重要——就像机场安检,必须先在登机口(分页拦截器)前完成身份核验(租户过滤)。

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 必须先添加租户拦截器
    interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(tenantLineHandler()));
    // 再添加分页拦截器
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
    return interceptor;
}

2.2 SQL拦截的完整流程

当执行select * from sys_user时,拦截器的工作流程堪比精密仪器:

  1. 拦截触发:MybatisPlusInterceptor拦截所有SQL请求
  2. 租户过滤:TenantLineInnerInterceptor的beforeQuery方法接管处理
  3. 语法解析:使用JSqlParser将SQL解析为语法树
  4. 条件注入:在WHERE子句中插入tenant_id = 1条件
  5. SQL重构:将修改后的语法树重新生成SQL语句

这个过程中最精妙的是JSqlParser的运用。它就像SQL翻译官,把字符串SQL转换成可操作的Java对象树,让我们能精准修改查询条件。

3. 实战中的关键配置技巧

3.1 租户处理器的定制开发

TenantLineHandler是插件的大脑,需要实现三个核心方法:

public TenantLineHandler tenantLineHandler() {
    return new TenantLineHandler() {
        // 获取当前租户ID(从ThreadLocal或SecurityContext)
        @Override
        public Expression getTenantId() {
            return new LongValue(SecurityUtils.getTenantId());
        }

        // 指定租户字段名
        @Override
        public String getTenantIdColumn() {
            return "tenant_id";
        }

        // 忽略特定表(字典表等公共数据)
        @Override
        public boolean ignoreTable(String tableName) {
            return Arrays.asList("sys_dict", "sys_config").contains(tableName);
        }
    };
}

实际项目中我踩过的坑:getTenantId()方法不能返回null,否则会导致NPE。建议像上面代码那样设置默认租户ID。

3.2 忽略表的智能判断

ignoreTable方法的实现往往需要结合业务场景。在电商系统中,商品表可能需要区分平台商品(忽略租户)和商家商品(需要租户隔离)。我常用的模式是:

@Override
public boolean ignoreTable(String tableName) {
    // 公共表直接忽略
    if(publicTables.contains(tableName)) return true;
    
    // 超级管理员跳过过滤
    if(SecurityUtils.isSuperAdmin()) return true;
    
    // 特定业务场景判断
    if("order".equals(tableName) && isCrossTenantQuery()){
        return true;
    }
    
    return false;
}

4. 深度源码解析

4.1 条件构造的玄机

在TenantLineInnerInterceptor.builderExpression方法中,可以看到条件拼接的核心逻辑:

protected Expression builderExpression(...) {
    // 基础条件:tenant_id = 1
    EqualsTo equalsTo = new EqualsTo();
    equalsTo.setLeftExpression(new Column(tenantLineHandler.getTenantIdColumn()));
    equalsTo.setRightExpression(tenantLineHandler.getTenantId());
    
    // 已有WHERE条件时用AND连接
    if(existingWhere != null) {
        return new AndExpression(existingWhere, equalsTo);
    }
    
    return equalsTo;
}

这个设计体现了Mybatis-Plus的巧妙之处:不是简单拼接字符串,而是在语法树层面进行操作,避免了SQL注入风险。

4.2 多表查询的特殊处理

在处理JOIN查询时,插件会递归处理所有表引用。以select * from a join b on a.id=b.aid为例:

  1. 检查表a是否需要租户过滤
  2. 检查表b是否需要租户过滤
  3. 对需要过滤的表分别添加条件
  4. 最终生成select * from a join b on a.id=b.aid WHERE a.tenant_id=1 AND b.tenant_id=1

这里有个性能优化点:如果多表查询的所有表都属于同一租户,可以考虑在应用层先校验租户一致性,减少数据库过滤开销。

5. 生产环境避坑指南

5.1 与分页插件的相爱相杀

在同时使用多租户和分页插件时,我遇到过count查询漏加租户条件的问题。解决方案是确保拦截器添加顺序正确,并且自定义count查询:

<select id="selectPageCount" resultType="long">
    SELECT COUNT(*) FROM table 
    WHERE tenant_id = #{tenantId} AND other_conditions
</select>

5.2 事务传播的特殊情况

在@Transactional方法中切换租户上下文时,新租户ID可能不会立即生效。这是因为拦截器在事务开始时就已经确定SQL模板。解决方法是在事务方法内显式清除Mybatis缓存:

@Transactional
public void crossTenantOperation() {
    // 操作租户A数据
    mapperA.doSomething();
    
    // 清除缓存使新租户ID生效
    SqlSessionHelper.clearCache(sqlSessionFactory);
    
    // 操作租户B数据
    mapperB.doSomething();
}

5.3 性能监控建议

在多租户系统中,建议对SQL执行进行监控,特别关注:

  • 漏加租户条件的SQL(安全风险)
  • 全表扫描的查询(性能风险)
  • 跨租户的大结果集查询(内存风险)

可以在TenantLineHandler中添加统计逻辑:

@Override
public Expression getTenantId() {
    String tenantId = SecurityUtils.getTenantId();
    Metrics.counter("tenant.query", "tenantId", tenantId).increment();
    return new StringValue(tenantId);
}

6. 扩展应用场景

6.1 多租户+数据权限组合

结合数据权限插件可以实现更细粒度的控制。比如部门经理只能查看本部门数据,而租户管理员可以查看整个租户数据。配置示例:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 数据权限拦截器
    interceptor.addInnerInterceptor(new DataPermissionInterceptor(
        dataPermissionHandler()));
    // 多租户拦截器
    interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(
        tenantLineHandler()));
    return interceptor;
}

6.2 动态租户字段

有些业务需要同时按organization_id和tenant_id过滤。可以通过继承TenantLineHandler实现:

@Override
public String getTenantIdColumn() {
    if(isOrganizationQuery()){
        return "organization_id";
    }
    return "tenant_id";
}

7. 源码调试技巧

要深入理解插件工作原理,推荐按这个顺序调试:

  1. 在MybatisPlusInterceptor.intercept方法打断点
  2. 观察interceptors集合中拦截器的顺序
  3. 进入TenantLineInnerInterceptor.beforeQuery
  4. 跟踪JSqlParser解析过程
  5. 观察最终生成的SQL

调试时会发现,插件对Batch操作、存储过程等特殊场景都有处理逻辑。比如批量插入时,会自动为每条记录设置tenant_id值。

在RuoYi-Vue-Plus框架中集成时,特别注意要排除系统内置表的租户过滤。框架默认的ignoreTable实现通常已经包含这些处理,但二次开发时可能需要调整。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐