多租户与动态表名
在 SaaS 多租户系统或需要按维度隔离数据的场景中,最棘手的是如何保证每个请求只能看到自己租户的数据。MyBatis-Plus 通过 TenantLineInnerInterceptor(多租户插件)和 DynamicTableNameInnerInterceptor(动态表名插件)从 SQL 层面自动完成数据隔离与表名切换,本文深入讲解其原理与实战。
一、多租户的数据隔离方案
多租户(Multi-Tenant)系统通常有三种数据隔离方案:
| 方案 | 隔离粒度 | 优点 | 缺点 |
|---|---|---|---|
| 独立数据库 | 每个租户一个库 | 隔离最彻底、安全 | 成本高、维护难 |
| 独立 Schema | 每个租户一个模式 | 隔离较好 | 数据库对象多、迁移麻烦 |
| 共享表 + 租户字段 | 一张表加 tenant_id | 成本低、扩展好 | 需保证每条 SQL 都带租户条件 |
第三种(共享表 + tenant_id 字段)是中小型 SaaS 最常用的方案,而它的最大风险在于:开发人员如果漏写 WHERE tenant_id = ?,就会发生数据串租(数据泄露到其他租户)。MyBatis-Plus 多租户插件正是为了解决这个"人为遗漏"而生的——它自动为所有 SQL 追加租户条件。
二、TenantLineInnerInterceptor 多租户插件
2.1 配置
@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),在 update、select、delete 等语句的 WHERE 后自动拼接 AND tenant_id = ?。
例如原始 SQL:
SELECT * FROM user WHERE age > 18插件改写为:
SELECT * FROM user WHERE age > 18 AND tenant_id = 1001它会自动处理各种 SQL 形态:
select:在WHERE追加租户条件(无 WHERE 时自动补WHERE tenant_id = ?)。update:追加租户条件,避免跨租户更新。delete:追加租户条件(配合逻辑删除时尤其重要)。- 子查询、
JOIN、INSERT等复杂语句也尽可能处理。
无需开发人员手动写租户条件——这正是插件最大的价值:从源头杜绝数据串租。
2.3 实现一个租户上下文(ThreadLocal)
租户 ID 需要贯穿整个请求,常用 ThreadLocal + HandlerInterceptor 实现:
// 租户上下文
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 多租户实践要点
- 忽略表白名单:全局表(字典、配置、地区)必须
ignoreTable,否则租户条件会误加导致查询不到。 - 租户字段要建索引:
WHERE tenant_id = ?是高频条件,tenant_id必须走索引,否则大表扫描。 - 与逻辑删除叠加:多租户插件会与逻辑删除条件叠加,生成
WHERE tenant_id = ? AND deleted = 0。 - 自增主键风险:多租户共享表时,主键可能跨租户重复,建议使用分布式 ID(雪花)而非数据库自增。
- 缓存隔离:使用 Redis 缓存时,缓存 key 必须带上租户维度(如
user:{tenantId}:{id}),否则租户间会串缓存数据。
三、DynamicTableNameInnerInterceptor 动态表名
3.1 应用场景
- 分表:按月/按租户将数据拆到多张表(如
order_202401、order_202402),根据条件动态切换表名。 - 历史表归档:查询历史数据时切换归档表。
- 灰度测试:动态切换到测试表。
3.2 配置与使用
@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 回调返回的表名替换原始表名。
// 查询时指定月份,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 行为完全兼容,升级后无需调整插件配置。