一、核心概念
1. 多租户 SQL 拼接的本质
多租户 SQL 拼接是指在执行数据库查询或更新操作时,MyBatis-Plus 自动在原始 SQL 中追加 tenant_id = ?
条件,以实现数据隔离。这一过程对开发者透明,无需手动编写 WHERE tenant_id = ?
。
目标:确保每个租户只能访问属于自己的数据,防止跨租户数据泄露。
2. 实现机制:InnerInterceptor
拦截器链
MyBatis-Plus 从 3.4.0 版本起采用 InnerInterceptor
架构,通过拦截 MyBatis 的 SQL 执行流程,在 SQL 解析阶段动态修改 Statement
对象。
- 关键类:
TenantLineInnerInterceptor
- 作用时机:在 SQL 构建为
MappedStatement
前进行干预 - 拼接方式:使用 MyBatis 的
Expression
API 在WHERE
子句中注入条件
3. SQL 拼接触发场景
SQL 类型 | 是否自动拼接 | 说明 |
---|---|---|
SELECT |
✅ | 自动添加 AND tenant_id = ? |
UPDATE |
✅ | 添加 WHERE tenant_id = ? 作为安全条件 |
DELETE |
✅ | 同上,防止误删其他租户数据 |
INSERT |
❌ | 不拼接,但通过 MetaObjectHandler 自动填充字段 |
⚠️ 注意:
INSERT
不需要拼接WHERE
,而是通过自动填充将tenant_id
写入记录。
二、SQL 拼接原理深度解析
1. 执行流程图解
[用户发起查询]
↓
[MyBatis 执行 SQL]
↓
[MyBatis-Plus 拦截器链触发]
↓
[TenantLineInnerInterceptor 拦截]
↓
[判断表是否需要租户隔离(ignoreTable)]
↓
[获取当前线程的 tenantId(getTenantId)]
↓
[解析 SQL AST(抽象语法树)]
↓
[在 WHERE 条件中追加 AND tenant_id = ?]
↓
[继续执行修改后的 SQL]
↓
[返回结果]
2. SQL 改写原理(AST 层面)
MyBatis-Plus 使用 JSQLParser
解析 SQL,构建抽象语法树(AST),然后在 WHERE
节点中插入新的条件表达式。
示例:原始 SQL
SELECT * FROM user WHERE status = 1
经过 TenantLineInnerInterceptor
处理后
SELECT * FROM user WHERE status = 1 AND tenant_id = 1001
内部 AST 操作逻辑:
- 如果原 SQL 有
WHERE
:追加AND tenant_id = ?
- 如果原 SQL 无
WHERE
:新增WHERE tenant_id = ?
- 支持复杂查询如
JOIN
,UNION
,SUBQUERY
的正确嵌套处理
三、操作步骤(详细到每一行代码)
步骤 1:引入依赖(确保版本 ≥ 3.4.0)
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.6</version>
</dependency>
✅ 推荐使用最新稳定版,避免旧版
SqlParserFilter
的废弃问题。
步骤 2:实体类添加租户字段
@Data
@TableName("user")
public class User {
private Long id;
private String name;
private Integer status;
// 标记为插入时自动填充
@TableField(fill = FieldFill.INSERT)
private Long tenantId;
}
🔍
@TableField(fill = FieldFill.INSERT)
表示插入时由MetaObjectHandler
填充。
步骤 3:实现自动填充处理器
@Component
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
Long tenantId = TenantContext.getTenantId();
if (tenantId != null && getFieldValByName("tenantId", metaObject) == null) {
this.strictInsertFill(metaObject, "tenantId", Long.class, tenantId);
}
}
@Override
public void updateFill(MetaObject metaObject) {
// 更新时不修改 tenantId
}
}
✅
strictInsertFill
确保字段为null
时才填充,避免覆盖已有值。
步骤 4:创建租户上下文(ThreadLocal 管理)
public class TenantContext {
private static final ThreadLocal<Long> CONTEXT = new ThreadLocal<>();
public static void setTenantId(Long tenantId) {
CONTEXT.set(tenantId);
}
public static Long getTenantId() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove(); // 必须 remove 防止内存泄漏
}
}
步骤 5:配置 TenantLineInnerInterceptor
(核心)
@Configuration
@MapperScan("com.example.mapper")
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(
new TenantLineHandler() {
@Override
public String getTenantId() {
Long id = TenantContext.getTenantId();
if (id == null) {
throw new TenantNotSetException("当前请求未设置租户ID");
}
return id.toString(); // 返回字符串形式
}
@Override
public String getTenantIdColumn() {
return "tenant_id"; // 数据库字段名
}
@Override
public boolean ignoreTable(String tableName) {
// 系统表不启用多租户
return Set.of("sys_user", "sys_role", "sys_dept").contains(tableName.toLowerCase());
}
// 可选:自定义租户字段别名
@Override
public String getTenantAlias() {
return null; // 默认使用表别名或无别名
}
}
);
interceptor.addInnerInterceptor(tenantInterceptor);
return interceptor;
}
}
✅
ignoreTable
是性能关键,避免对系统表进行无谓判断。
步骤 6:在请求中设置租户 ID(如通过拦截器)
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String header = request.getHeader("X-Tenant-Id");
if (StringUtils.isEmpty(header)) {
throw new IllegalArgumentException("Missing X-Tenant-Id header");
}
try {
Long tenantId = Long.valueOf(header);
TenantContext.setTenantId(tenantId);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid tenant ID format");
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
TenantContext.clear(); // 清理 ThreadLocal
}
}
注册拦截器:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TenantInterceptor tenantInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor)
.addPathPatterns("/api/**");
}
}
四、常见错误与排查
错误现象 | 原因分析 | 解决方案 |
---|---|---|
SQL 中未出现 tenant_id 条件 |
ignoreTable 返回 true 或表名不匹配 |
检查表名大小写、是否在忽略列表中 |
报错 tenant_id 字段不存在 |
数据库表缺少该字段 | 添加字段并建立索引 |
多线程下租户错乱 | ThreadLocal 未清理 |
确保 afterCompletion 调用 clear() |
插入时报 tenant_id 为空 |
MetaObjectHandler 未生效 |
检查 @Component 、@TableField(fill=...) |
联表查询出错 | 某些表没有 tenant_id 字段 |
所有关联表都需有该字段或配置 ignoreTable |
五、注意事项
- 字段一致性:所有参与多租户的表必须包含
tenant_id
字段。 - 索引必须加:
tenant_id
应作为联合索引的前导列,否则全表扫描。 - 避免动态表名:如
FROM ${tableName}
会导致 AST 解析失败。 - 不支持
UNION
外层 WHERE:复杂 SQL 需测试验证。 - 缓存穿透风险:若使用 MyBatis 一级/二级缓存,需注意缓存键包含
tenant_id
。
六、使用技巧
1. 调试 SQL 拼接结果
开启 MyBatis 日志,查看最终执行的 SQL:
# application.yml
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
输出示例:
==> Preparing: SELECT * FROM user WHERE status = ? AND tenant_id = ?
==> Parameters: 1(Integer), 1001(Long)
2. 单元测试验证拼接逻辑
@Test
public void testSelectWithTenant() {
TenantContext.setTenantId(1001L);
List<User> users = userMapper.selectList(Wrappers.<User>lambdaQuery().eq(User::getStatus, 1));
// 断言:SQL 应包含 AND tenant_id = 1001
}
3. 临时关闭多租户(如后台管理)
// 在需要绕过的代码块中
TenantContext.setTenantId(null); // 抛出异常或配置 allowEmpty = true
或通过 ignoreTable
配置系统表。
七、最佳实践与性能优化
✅ 最佳实践
实践 | 说明 |
---|---|
统一字段命名 | 所有表使用 tenant_id BIGINT |
联合索引设计 | (tenant_id, status, create_time) 提升查询效率 |
逻辑删除共用 | tenant_id + deleted = 0 双重过滤 |
API 显式传递 | Header 传递 X-Tenant-Id ,便于网关识别 |
租户上下文封装 | 提供 @LoginTenant 注解自动注入 |
⚡ 性能优化建议
索引优化
-- 错误:单独索引 CREATE INDEX idx_tenant ON user(tenant_id); -- 正确:联合索引(根据查询场景) CREATE INDEX idx_tenant_status_time ON user(tenant_id, status, create_time DESC);
减少 AST 解析开销
- 避免过于复杂的嵌套查询
- 使用
ignoreTable
排除不需要拦截的表
连接池配置
- 增大连接池大小,因多租户可能增加并发连接数
分库分表准备
- 若未来需分库,建议初期设计支持
tenant_id
作为分片键
- 若未来需分库,建议初期设计支持
八、高级场景:自定义 SQL 拼接逻辑
若需更复杂控制(如不同租户不同策略),可继承 TenantLineInnerInterceptor
:
public class CustomTenantInterceptor extends TenantLineInnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter,
RowBounds rowBounds, ResultHandler resultHandler,
BoundSql boundSql) {
// 可在此处修改 SQL 或参数
super.beforeQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql);
}
}
总结
MyBatis-Plus 多租户 SQL 拼接的核心是:
TenantLineInnerInterceptor
+JSQLParser
AST 修改 +ThreadLocal
上下文 +MetaObjectHandler
自动填充
只要掌握以下四点,即可熟练应用:
- ✅ 正确配置
TenantLineHandler
- ✅ 实现
MetaObjectHandler
填充插入字段 - ✅ 使用拦截器设置
TenantContext
- ✅ 为
tenant_id
建立高效索引
这套机制几乎零侵入,是实现 SaaS 系统多租户隔离的首选方案。