Spring Boot 3 集成 MyBatis-Plus 实现多租户数据隔离的完整方案|2026-11-02

举报
yd_271851850 发表于 2026/11/02 14:25:00 2026/11/02
【摘要】 多租户是 SaaS 系统的地基。这篇文章整理一套在 Spring Boot 3 + MyBatisPlus 环境下落地多租户隔离的完整做法,包含租户识别、SQL 自动改写、跨租户查询和几个容易踩的坑。 隔离级别怎么选 三种模式,选错了后期改造成本极高: 模式 隔离程度 成本 适用 独立数据库 最高

多租户是 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 开头。前者导致数据泄露,后者导致慢查询,都是上线后才爆的类型。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。