{T}

多租户与动态表名

在 SaaS 多租户系统或需要按维度隔离数据的场景中,最棘手的是如何保证每个请求只能看到自己租户的数据。MyBatis-Plus 通过 TenantLineInnerInterceptor(多租户插件)和 DynamicTableNameInnerInterceptor(动态表名插件)从 SQL 层面自动完成数据隔离与表名切换,本文深入讲解其原理与实战。

一、多租户的数据隔离方案

多租户(Multi-Tenant)系统通常有三种数据隔离方案:

方案隔离粒度优点缺点
独立数据库每个租户一个库隔离最彻底、安全成本高、维护难
独立 Schema每个租户一个模式隔离较好数据库对象多、迁移麻烦
共享表 + 租户字段一张表加 tenant_id成本低、扩展好需保证每条 SQL 都带租户条件

第三种(共享表 + tenant_id 字段)是中小型 SaaS 最常用的方案,而它的最大风险在于:开发人员如果漏写 WHERE tenant_id = ?,就会发生数据串租(数据泄露到其他租户)。MyBatis-Plus 多租户插件正是为了解决这个"人为遗漏"而生的——它自动为所有 SQL 追加租户条件。

二、TenantLineInnerInterceptor 多租户插件

2.1 配置

java
@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 多租户插件(建议放在分页插件之前)
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(
            new TenantLineHandler() {
                @Override
                public Expression getTenantId() {
                    // 从上下文(如 Token/请求头)获取当前租户 ID
                    String tenantId = TenantContextHolder.getTenantId();
                    return new LongValue(Long.parseLong(tenantId));
                }

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

                @Override
                public boolean ignoreTable(String tableName) {
                    // 忽略某些不需要租户隔离的表(如全局字典表、系统表)
                    return "sys_dict".equals(tableName);
                }
            }));
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

关键配置项

  • getTenantId():返回当前租户 ID,通常从请求上下文(ThreadLocal、JWT、请求头)中获取。
  • getTenantIdColumn():指定租户字段名(默认 tenant_id)。
  • ignoreTable(tableName):对不需要隔离的表(全局配置表、字典表等)放行。

2.2 SQL 改写原理

多租户插件的核心是对执行的 SQL 进行 AST 解析并自动追加租户条件。MyBatis-Plus 使用 JSqlParser 将 SQL 解析为语法树(Abstract Syntax Tree),在 updateselectdelete 等语句的 WHERE 后自动拼接 AND tenant_id = ?

例如原始 SQL:

sql
SELECT * FROM user WHERE age > 18

插件改写为:

sql
SELECT * FROM user WHERE age > 18 AND tenant_id = 1001

它会自动处理各种 SQL 形态

  • select:在 WHERE 追加租户条件(无 WHERE 时自动补 WHERE tenant_id = ?)。
  • update:追加租户条件,避免跨租户更新。
  • delete:追加租户条件(配合逻辑删除时尤其重要)。
  • 子查询、JOININSERT 等复杂语句也尽可能处理。

无需开发人员手动写租户条件——这正是插件最大的价值:从源头杜绝数据串租

2.3 实现一个租户上下文(ThreadLocal)

租户 ID 需要贯穿整个请求,常用 ThreadLocal + HandlerInterceptor 实现:

java
// 租户上下文
public class TenantContextHolder {
    private static final ThreadLocal<String> TENANT = new ThreadLocal<>();

    public static void setTenantId(String tenantId) { TENANT.set(tenantId); }
    public static String getTenantId() { return TENANT.get(); }
    public static void clear() { TENANT.remove(); }   // 务必清理,防止内存泄漏
}

// 请求拦截器
public class TenantInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
        String tenantId = req.getHeader("X-Tenant-Id");
        TenantContextHolder.setTenantId(tenantId);
        return true;
    }

    @Override
    public void afterCompletion(...) {
        TenantContextHolder.clear();   // 请求结束清理 ThreadLocal
    }
}

必须清理 ThreadLocal:线程复用场景下不清理会导致租户数据串租(上一个请求的租户 ID 残留到下一个请求)。

2.4 多租户实践要点

  1. 忽略表白名单:全局表(字典、配置、地区)必须 ignoreTable,否则租户条件会误加导致查询不到。
  2. 租户字段要建索引WHERE tenant_id = ? 是高频条件,tenant_id 必须走索引,否则大表扫描。
  3. 与逻辑删除叠加:多租户插件会与逻辑删除条件叠加,生成 WHERE tenant_id = ? AND deleted = 0
  4. 自增主键风险:多租户共享表时,主键可能跨租户重复,建议使用分布式 ID(雪花)而非数据库自增。
  5. 缓存隔离:使用 Redis 缓存时,缓存 key 必须带上租户维度(如 user:{tenantId}:{id}),否则租户间会串缓存数据。

三、DynamicTableNameInnerInterceptor 动态表名

3.1 应用场景

  • 分表:按月/按租户将数据拆到多张表(如 order_202401order_202402),根据条件动态切换表名。
  • 历史表归档:查询历史数据时切换归档表。
  • 灰度测试:动态切换到测试表。

3.2 配置与使用

java
@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new DynamicTableNameInnerInterceptor(
            new DynamicTableNameHandler() {
                @Override
                public String dynamicTableName(String sql, String tableName) {
                    // 根据请求参数或上下文决定实际表名
                    String month = TableContextHolder.getMonth();
                    if ("order".equals(tableName) && month != null) {
                        return "order_" + month;   // 动态切换为 order_202401
                    }
                    return tableName;
                }
            }));
        return interceptor;
    }
}

原理:与多租户插件类似,DynamicTableNameInnerInterceptor 通过 JSqlParser 解析 SQL,根据 dynamicTableName 回调返回的表名替换原始表名。

java
// 查询时指定月份,SQL 自动切换到对应分表
TableContextHolder.setMonth("202401");
List<Order> orders = orderMapper.selectList(wrapper);
// 实际执行: SELECT * FROM order_202401 ...

3.3 动态表名 vs 多租户

对比项多租户(TenantLine)动态表名(DynamicTableName)
作用给 SQL 追加租户条件 tenant_id = ?替换 SQL 中的表名
适用共享表 + 租户字段隔离分表、归档、按租户分表
数据源同一张表多张物理表
典型场景SaaS 共享库分库分表中的分表

四、小结

  • 多租户插件通过 JSqlParser 改写 SQL,自动追加 tenant_id 条件,从源头防止数据串租,是 SaaS 共享表方案的标配。
  • 必须配置忽略表白名单租户字段索引缓存 key 加租户维度,并做好 ThreadLocal 清理。
  • 动态表名插件适用于分表、归档场景,按需动态切换物理表。
  • 两者都依赖拦截器 + AST 解析,理解 SQL 改写原理有助于排查分页、租户、逻辑删除叠加时的 SQL 异常。

版本差异(旧版 3.5.5 → 3.5.x)

特性旧版(3.5.5 时期)当前 3.5.x(如 3.5.12)
多租户插件TenantLineInnerInterceptor用法不变,无破坏性变更
动态表名插件DynamicTableNameInnerInterceptor用法不变,无破坏性变更
JSqlParser 版本内置固定版本3.5.x 随版本升级持续更新,兼容新 SQL 语法
忽略表白名单ignoreTable 配置用法不变
虚拟线程无特殊处理3.5.9+ 优化虚拟线程环境兼容;ThreadLocal 清理注意点不变

多租户与动态表名插件基于拦截器 + JSqlParser AST 改写实现,从 3.5.5 到最新 3.5.x 行为完全兼容,升级后无需调整插件配置。