Spring Boot 3 集成 MyBatis-Plus 实现多租户数据隔离的完整方案|2026-11-02
多租户是 SaaS 系统的地基。这篇文章整理一套在 Spring Boot 3 + MyBatis-Plus 环境下落地多租户隔离的完整做法,包含租户识别、SQL 自动改写、跨租户查询和几个容易踩的坑。
隔离级别怎么选
三种模式,选错了后期改造成本极高:
模式 | 隔离程度 | 成本 | 适用
独立数据库 | 最高 | 最高 | 金融、医疗等强合规场景
共享库独立 Schema | 中 | 中 | 租户数量可控(几十到几百)
共享表加租户字段 | 低 | 最低 | 租户数量大、数据敏感度低
绝大多数 To B 产品落在第三种:共享表 + tenant_id 字段 + 框架层自动补条件。
核心实现
1. 租户上下文
用 ThreadLocal 保存当前请求的租户 ID,配合拦截器在请求进入时写入、结束时清理:
public final class TenantContext {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
public static void set(String tenantId) {
HOLDER.set(tenantId);
}
public static String get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}
拦截器:
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
String tenantId = req.getHeader("X-Tenant-Id");
if (tenantId == null) {
// 从登录态里兜底取
tenantId = resolveFromToken(req);
}
TenantContext.set(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
Object handler, Exception ex) {
TenantContext.clear(); // 必须清理,否则线程池复用会串租户
}
}
这里是第一个坑:afterCompletion 一定要清 ThreadLocal。Tomcat 线程池是复用的,不清会导致下一个请求拿到上一个租户的 ID,出现跨租户数据泄露。这个问题在压测时才会暴露,线上非常危险。
2. MyBatis-Plus 租户插件
MyBatis-Plus 3.5.x 内置 TenantLineInnerInterceptor:
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 多租户插件要放在分页插件之前
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
return new StringValue(TenantContext.get());
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
// 全局表不加租户条件
return IGNORE_TABLES.contains(tableName);
}
}));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
private static final Set<String> IGNORE_TABLES = Set.of(
"sys_dict", "sys_config", "sys_tenant", "flyway_schema_history"
);
}
插件会自动把:
SELECT * FROM orders WHERE status = 1
改写成:
SELECT * FROM orders WHERE status = 1 AND orders.tenant_id = '1001'
3. 新增数据自动填充租户
INSERT 也需要自动补 tenant_id,否则新数据没有归属:
@Component
public class TenantMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
fillIfAbsent(metaObject, "tenantId", TenantContext.get());
fillIfAbsent(metaObject, "createTime", LocalDateTime.now());
fillIfAbsent(metaObject, "updateTime", LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
fillIfAbsent(metaObject, "updateTime", LocalDateTime.now());
}
private void fillIfAbsent(MetaObject meta, String field, Object value) {
if (meta.hasSetter(field) && getFieldValByName(field, meta) == null) {
setFieldValByName(field, value, meta);
}
}
}
实体类上加 @TableField(fill = FieldFill.INSERT) 即可。
4. 跨租户查询怎么做
运营后台经常要"查所有租户的数据",这时候需要临时绕过租户插件。
方案一:用 @InterceptorIgnore
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT tenant_id, COUNT(*) AS cnt FROM orders GROUP BY tenant_id")
List<TenantStat> statByTenant();
方案二:手动切换上下文
public <T> T executeAsSystem(Supplier<T> action) {
String origin = TenantContext.get();
try {
TenantContext.set("0"); // 系统租户,配合 ignoreTable 逻辑
return action.get();
} finally {
TenantContext.set(origin);
}
}
方案一更清晰,推荐优先用。方案二适合服务层需要批量跨租户处理的场景,注意 finally 一定要还原。
索引与 SQL 层面的注意事项
加了 tenant_id 之后,原来所有索引都会失效一部分。原则是:所有业务索引的第一列都应该是 tenant_id。
-- 优化前
KEY idx_order_no (order_no)
-- 优化后
KEY idx_tenant_order_no (tenant_id, order_no)
原因很简单:租户插件的条件永远带 tenant_id = ?,联合索引把 tenant_id 放前面才能用上。
另外要注意 EXPLAIN 复查带 IN 子查询的语句,MyBatis-Plus 的改写可能让子查询也带上租户条件,导致结果集变小——如果业务确实需要跨租户子查询,必须显式 @InterceptorIgnore。
数据迁移时的坑
历史数据没有 tenant_id,直接加 NOT NULL 约束会失败。正确的顺序:
1. 加字段,允许 NULL,默认值不设
2. 按业务归属规则批量刷 tenant_id
3. 建联合索引
4. 最后才加 NOT NULL 约束
刷数据脚本建议分批跑,每批 5000 条,避免长事务锁表:
UPDATE orders SET tenant_id = '1001'
WHERE tenant_id IS NULL AND user_id IN (SELECT id FROM ...)
LIMIT 5000;
小结
多租户隔离的核心就三件事:请求进入时确定租户、SQL 执行前自动加条件、线程结束时清理上下文。框架能帮你做掉 80%,剩下的 20% 靠索引设计和跨租户场景的手动豁免。
最容易出事故的两点再强调一次:ThreadLocal 必须清理,所有索引必须以 tenant_id 开头。前者导致数据泄露,后者导致慢查询,都是上线后才爆的类型。
- 点赞
- 收藏
- 关注作者
评论(0)