MyBatis基础入门《十四》多租户架构实战:基于 MyBatis 实现 SaaS 系统的动态数据隔离

前情回顾

在 《MyBatis基础入门《十三》Lombok + MapStruct 极简开发》 中,我们构建了高可维护、类型安全的现代化 DAO 层。

但当你的系统需要服务 成百上千家企业客户(租户) 时,新的挑战浮现:

  • 所有租户共用一套应用,但数据必须严格隔离
  • 不同租户可能使用不同版本的数据库结构
  • 运维需支持按租户统计资源消耗、备份恢复
  • 开发不能为每个租户写一套 SQL!

如何在不修改业务代码的前提下,让 MyBatis 自动识别当前租户并路由到正确数据?

答案 :通过 MyBatis 插件(Interceptor)+ 租户上下文 + 动态 SQL 重写 实现透明化多租户支持!

本文将带你从理论到落地,掌握 SaaS 架构下的数据隔离核心能力。


一、什么是多租户(Multi-Tenancy)?

多租户是一种软件架构模式,单个实例服务多个客户(租户),每个租户的数据逻辑或物理隔离。

1.1 三种主流多租户方案对比

方案 描述 优点 缺点 适用场景
独立数据库(Database per Tenant) 每个租户拥有独立数据库实例 隔离性最强,备份/扩容灵活 成本高,运维复杂 金融、政府等强合规场景
共享数据库,独立 Schema 同一 DB,每个租户一个 Schema 隔离较好,资源利用率高 需管理大量 Schema 中大型 SaaS,如 ERP、CRM
共享数据库,共享表(字段隔离) 所有租户共用表,通过 tenant_id 区分 成本最低,开发最简单 隔离弱,易数据泄露 初创公司、轻量级 SaaS

本文重点

  • 方案二(Schema 隔离) :通过 动态替换 Schema 名 实现;
  • 方案三(字段隔离) :通过 自动注入 WHERE tenant_id = ? 实现;
  • 统一抽象 :无论哪种方案,业务代码 无需感知租户逻辑

二、核心设计原则

  1. 透明性:Service/Controller 层完全 unaware 租户存在;
  2. 安全性:杜绝跨租户数据访问(即使 SQL 写错);
  3. 性能:拦截器开销可控,避免全表扫描;
  4. 可扩展:支持未来切换隔离策略(如从字段隔离升级到 Schema 隔离)。

三、基础准备:租户上下文(Tenant Context)

所有租户信息必须在请求链路中传递。我们使用 ThreadLocal 存储当前租户 ID。

复制代码
// context/TenantContext.java
package com.charles.multitenant.context;

public class TenantContext {

    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static String getCurrentTenantId() {
        return CURRENT_TENANT.get();
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}

🔔 注意:在 Web 应用中,需在 Filter 或 Interceptor 中解析租户标识(如子域名 tenant1.app.com、Header X-Tenant-ID),并设置到上下文。

3.1 租户解析拦截器(Spring Boot)

复制代码
// interceptor/TenantInterceptor.java
@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, 
                             Object handler) {
        // 从 Header 获取租户 ID(也可从 JWT、子域名等解析)
        String tenantId = request.getHeader("X-Tenant-ID");
        
        if (tenantId == null || tenantId.isBlank()) {
            throw new IllegalArgumentException("Missing X-Tenant-ID header");
        }
        
        TenantContext.setTenantId(tenantId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler, Exception ex) {
        TenantContext.clear(); // 防止 ThreadLocal 泄漏
    }
}

// WebConfig.java
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new TenantInterceptor());
    }
}

✅ 每个请求自动绑定租户,后续 MyBatis 拦截器可直接读取。


四、方案一:字段隔离(共享表 + tenant_id)

这是最常用、成本最低的方案。所有表增加 tenant_id VARCHAR(64) NOT NULL 字段。

4.1 表结构示例

复制代码
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    tenant_id VARCHAR(64) NOT NULL,  -- 租户标识
    order_no VARCHAR(50),
    amount DECIMAL(10,2),
    create_time DATETIME,
    INDEX idx_tenant (tenant_id)
);

4.2 MyBatis 拦截器:自动注入 tenant_id 条件

复制代码
// interceptor/TenantFieldInterceptor.java
@Intercepts({
    @Signature(type = Executor.class, method = "query", 
               args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
    @Signature(type = Executor.class, method = "update",
               args = {MappedStatement.class, Object.class})
})
@Component
public class TenantFieldInterceptor implements Interceptor {

    private static final List<String> SKIP_TABLES = Arrays.asList("sys_tenant", "sys_user");

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        String tenantId = TenantContext.getCurrentTenantId();
        if (tenantId == null) {
            return invocation.proceed(); // 无租户上下文,跳过
        }

        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameter = args[1];

        // 1. 处理 SELECT:自动添加 WHERE tenant_id = ?
        if (ms.getSqlCommandType() == SqlCommandType.SELECT) {
            BoundSql boundSql = ms.getBoundSql(parameter);
            String originalSql = boundSql.getSql().trim();

            // 跳过系统表
            if (shouldSkip(originalSql)) return invocation.proceed();

            // 构造新 SQL:原 SQL + AND tenant_id = ?
            String newSql = appendTenantCondition(originalSql, "tenant_id", tenantId);

            // 创建新的 BoundSql 和 MappedStatement
            BoundSql newBoundSql = new BoundSql(
                ms.getConfiguration(), newSql, 
                boundSql.getParameterMappings(), parameter
            );

            // 复用原 ResultMap 等配置
            MappedStatement newMs = copyFromMappedStatement(ms, new BoundSqlSqlSource(newBoundSql));
            args[0] = newMs;
        }

        // 2. 处理 INSERT:自动设置 tenant_id 字段
        else if (ms.getSqlCommandType() == SqlCommandType.INSERT && parameter != null) {
            if (parameter instanceof BaseEntity) {
                ((BaseEntity) parameter).setTenantId(tenantId);
            }
            // 若使用 Map 传参,需额外处理(见后文)
        }

        return invocation.proceed();
    }

    private String appendTenantCondition(String sql, String tenantColumn, String tenantId) {
        // 简单实现:假设 SQL 以 SELECT 开头,末尾无分号
        // 更健壮做法:使用 SQL 解析器(如 JSqlParser)
        if (sql.toLowerCase().contains(" where ")) {
            return sql + " AND " + tenantColumn + " = '" + tenantId + "'";
        } else {
            int fromIndex = sql.toLowerCase().indexOf(" from ");
            if (fromIndex == -1) return sql;
            return sql.substring(0, fromIndex) + 
                   " FROM " + sql.substring(fromIndex + 6) +
                   " WHERE " + tenantColumn + " = '" + tenantId + "'";
        }
    }

    private boolean shouldSkip(String sql) {
        for (String table : SKIP_TABLES) {
            if (sql.toLowerCase().contains(table.toLowerCase())) {
                return true;
            }
        }
        return false;
    }

    // 工具方法:复制 MappedStatement(略,见附录)
    private MappedStatement copyFromMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
        MappedStatement.Builder builder = new MappedStatement.Builder(
            ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType()
        );
        builder.resource(ms.getResource());
        builder.fetchSize(ms.getFetchSize());
        builder.statementType(ms.getStatementType());
        builder.keyGenerator(ms.getKeyGenerator());
        if (ms.getKeyProperties() != null && ms.getKeyProperties().length != 0) {
            builder.keyProperty(String.join(",", ms.getKeyProperties()));
        }
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        builder.resultMaps(ms.getResultMaps());
        builder.resultSetType(ms.getResultSetType());
        builder.cache(ms.getCache());
        builder.flushCacheRequired(ms.isFlushCacheRequired());
        builder.useCache(ms.isUseCache());
        return builder.build();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }
}

⚠️ 重要缺陷 :上述 appendTenantCondition 使用字符串拼接,存在 SQL 注入风险且不支持复杂查询

4.3 健壮方案:使用 JSqlParser 解析 SQL

引入依赖:

复制代码
<dependency>
    <groupId>com.github.jsqlparser</groupId>
    <artifactId>jsqlparser</artifactId>
    <version>4.7</version>
</dependency>

改进 appendTenantCondition

复制代码
private String appendTenantCondition(String sql, String tenantColumn, String tenantId) {
    try {
        Statement stmt = CCJSqlParserUtil.parse(sql);
        if (stmt instanceof Select) {
            Select select = (Select) stmt;
            SelectBody body = select.getSelectBody();
            if (body instanceof PlainSelect) {
                PlainSelect plainSelect = (PlainSelect) body;
                Expression where = plainSelect.getWhere();
                EqualsTo tenantExpr = new EqualsTo();
                tenantExpr.setLeftExpression(new Column(tenantColumn));
                tenantExpr.setRightExpression(new StringValue(tenantId));

                if (where == null) {
                    plainSelect.setWhere(tenantExpr);
                } else {
                    plainSelect.setWhere(new AndExpression(where, tenantExpr));
                }
            }
            return stmt.toString();
        }
        return sql;
    } catch (JSQLParserException e) {
        throw new RuntimeException("Failed to parse SQL: " + sql, e);
    }
}

✅ 安全、准确、支持任意复杂 SELECT!


4.4 处理 INSERT 参数为 Map 的情况

若 Mapper 使用 @Param 或 XML 传 Map:

复制代码
// Mapper
void insertOrder(@Param("orderNo") String orderNo, @Param("amount") BigDecimal amount);

需在拦截器中动态注入 tenant_id

复制代码
// 在 intercept 方法中补充
if (ms.getSqlCommandType() == SqlCommandType.INSERT && parameter instanceof Map) {
    @SuppressWarnings("unchecked")
    Map<String, Object> paramMap = (Map<String, Object>) parameter;
    paramMap.put("tenantId", tenantId); // XML 中需有 #{tenantId}
}

对应 XML:

复制代码
<insert id="insertOrder">
    INSERT INTO orders (tenant_id, order_no, amount)
    VALUES (#{tenantId}, #{orderNo}, #{amount})
</insert>

💡 更优雅方式:自定义注解 @TenantField 标记实体类,自动注入。


五、方案二:Schema 隔离(动态替换表名前缀)

每个租户拥有独立 Schema,如 tenant_abc.orderstenant_xyz.orders

5.1 数据库准备

复制代码
-- 租户 abc
CREATE SCHEMA tenant_abc;
CREATE TABLE tenant_abc.orders (...);

-- 租户 xyz
CREATE SCHEMA tenant_xyz;
CREATE TABLE tenant_xyz.orders (...);

🔔 应用启动时需确保所有租户 Schema 已存在(可通过 Flyway/Liquibase 初始化)。


5.2 MyBatis 拦截器:动态替换 Schema

复制代码
// interceptor/TenantSchemaInterceptor.java
@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare",
               args = {Connection.class, Integer.class})
})
@Component
public class TenantSchemaInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        String tenantId = TenantContext.getCurrentTenantId();
        if (tenantId == null) {
            return invocation.proceed();
        }

        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = statementHandler.getBoundSql();
        String originalSql = boundSql.getSql();

        // 替换所有表名为 tenant_{id}.table_name
        String newSql = replaceSchema(originalSql, "tenant_" + tenantId);

        // 反射修改 BoundSql 的 sql 字段(因无 setter)
        Field sqlField = BoundSql.class.getDeclaredField("sql");
        sqlField.setAccessible(true);
        sqlField.set(boundSql, newSql);

        return invocation.proceed();
    }

    private String replaceSchema(String sql, String schema) {
        // 简单正则:匹配 "FROM table" 或 "JOIN table"
        // 更健壮:使用 JSqlParser
        return sql.replaceAll("(?i)(from|join)\\s+([a-zA-Z_][a-zA-Z0-9_]*)", 
                             "$1 " + schema + ".$2");
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }
}

⚠️ 正则方案脆弱!推荐使用 JSqlParser 重写表名

复制代码
private String replaceSchema(String sql, String schema) {
    try {
        Statement stmt = CCJSqlParserUtil.parse(sql);
        TablesNamesFinder finder = new TablesNamesFinder();
        List<String> tables = finder.getTableList(stmt);

        // 遍历所有表,替换为 schema.table
        // (此处简化,实际需递归遍历 AST 节点)
        // 更佳做法:实现 DeParser 修改表名
        return ...; 
    } catch (JSQLParserException e) {
        throw new RuntimeException(e);
    }
}

✅ 生产环境务必使用 AST 级别解析,避免误替换(如字符串常量中的 "from")。


六、方案三:独立数据库(动态数据源)

每个租户使用独立数据库实例(IP/Port/DBName 不同)。

6.1 动态数据源路由

复制代码
// datasource/TenantRoutingDataSource.java
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContext.getCurrentTenantId();
    }
}

6.2 配置多数据源

复制代码
@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    public DataSource tenantRoutingDataSource() {
        TenantRoutingDataSource routingDs = new TenantRoutingDataSource();
        
        // 从配置或数据库加载所有租户数据源
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("abc", createDataSource("jdbc:mysql://db-abc:3306/app"));
        targetDataSources.put("xyz", createDataSource("jdbc:mysql://db-xyz:3306/app"));
        
        routingDs.setTargetDataSources(targetDataSources);
        routingDs.setDefaultTargetDataSource(createDefaultDataSource()); // 默认
        return routingDs;
    }

    private DataSource createDataSource(String url) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(url);
        config.setUsername("user");
        config.setPassword("pass");
        return new HikariDataSource(config);
    }
}

✅ 适用于租户数量较少、隔离要求极高的场景;

❌ 租户数 > 100 时,连接池资源爆炸,不推荐。


七、统一抽象:多租户策略接口

为支持运行时切换策略,定义统一接口:

复制代码
// strategy/TenantStrategy.java
public interface TenantStrategy {
    String processTableName(String originalTable);
    void injectTenantCondition(BoundSql boundSql, String tenantId);
    boolean isApplicable();
}

// 实现类:FieldTenantStrategy, SchemaTenantStrategy, DatabaseTenantStrategy

拦截器中根据配置选择策略:

复制代码
@Autowired
private List<TenantStrategy> strategies;

private TenantStrategy getCurrentStrategy() {
    return strategies.stream()
        .filter(TenantStrategy::isApplicable)
        .findFirst()
        .orElseThrow(() -> new IllegalStateException("No tenant strategy found"));
}

✅ 未来可轻松从字段隔离升级到 Schema 隔离!


八、安全加固:防止租户越权

即使有拦截器,仍需双重保障:

8.1 Service 层显式校验(关键操作)

复制代码
public OrderVO getOrder(Long orderId) {
    String currentTenant = TenantContext.getCurrentTenantId();
    Order order = orderMapper.selectById(orderId);
    
    // 额外校验:防止拦截器失效导致越权
    if (!currentTenant.equals(order.getTenantId())) {
        throw new SecurityException("Access denied");
    }
    return converter.toVO(order);
}

8.2 数据库层面:Row Level Security(RLS)

PostgreSQL/Oracle 支持 RLS,MySQL 可通过 View + Trigger 模拟:

复制代码
-- 创建视图,自动过滤当前租户(需会话变量)
CREATE VIEW orders_view AS
SELECT * FROM orders WHERE tenant_id = @current_tenant_id;

🔒 安全原则:"防御纵深" ------ 应用层 + 数据库层双重防护!


九、性能与监控

9.1 拦截器性能影响

  • JSqlParser 解析:约 0.1~0.5ms/SQL
  • 字符串替换:<0.01ms,但不安全;
  • 建议:对高频查询缓存解析结果(如 SQL 模板 + 租户ID 组合缓存)。

9.2 监控指标

  • 每个租户的 QPS、慢 SQL;
  • 拦截器处理耗时分布;
  • 异常租户访问尝试(安全审计)。

十、测试策略

10.1 单元测试:模拟多租户上下文

复制代码
@Test
void shouldOnlyReturnCurrentTenantOrders() {
    // Given
    TenantContext.setTenantId("abc");
    orderMapper.insert(new Order("ORD-001", "abc"));
    orderMapper.insert(new Order("ORD-002", "xyz")); // 其他租户

    // When
    List<Order> orders = orderMapper.selectAll();

    // Then
    assertThat(orders).hasSize(1);
    assertThat(orders.get(0).getOrderNo()).isEqualTo("ORD-001");
}

10.2 集成测试:多租户数据隔离验证

使用 Testcontainers 启动 MySQL,创建多个 Schema,验证数据互不可见。


十一、总结:多租户方案选型指南

维度 字段隔离 Schema 隔离 独立数据库
成本 ★☆☆☆☆(最低) ★★☆☆☆ ★★★★★(最高)
隔离性 ★★☆☆☆ ★★★★☆ ★★★★★
运维复杂度 ★☆☆☆☆ ★★★☆☆ ★★★★★
扩展性 租户数 > 10万 可能瓶颈 租户数 < 1万 较合适 租户数 < 100
MyBatis 改造难度 中(需 SQL 拦截) 高(需 AST 解析) 低(仅数据源路由)

推荐路径

  • 初创期:字段隔离(快速上线);
  • 成长期:Schema 隔离(平衡成本与隔离);
  • 企业级:独立数据库(金融、医疗等强监管行业)。

本文系统讲解了 MyBatis 在 SaaS 多租户架构下的三种实现方案,涵盖代码、安全、性能、测试全链路。

下一篇我们将探索 MyBatis 与分布式事务(Seata)集成,解决微服务下的数据一致性难题!

👍 如果你觉得有帮助,欢迎点赞、收藏、转发!

💬 你的系统采用哪种多租户方案?欢迎评论区交流!

相关推荐
白衣衬衫 两袖清风2 小时前
SQL联查案例
数据库·sql
ShirleyWang0122 小时前
VMware如何导入vmdk文件
linux·数据库
老前端的功夫3 小时前
Vue 3 vs Vue 2 深度解析:从架构革新到开发体验全面升级
前端·vue.js·架构
gugugu.3 小时前
Redis Set类型完全指南:无序集合的原理与应用
数据库·windows·redis
wang6021252183 小时前
为什么不采用级联删除而选择软删除
数据库·oracle
变形侠医4 小时前
比 Kettle 快2倍的 Java ETL 开源库:Etl-engine
数据库
soft20015254 小时前
从一次增删改操作开始:彻底理解 MySQL Buffer Pool 的地位与作用
数据库·mysql
feathered-feathered4 小时前
Redis基础知识+RDB+AOF(面试)
java·数据库·redis·分布式·后端·中间件·面试
测试人社区-小明4 小时前
智能测试误报问题的深度解析与应对策略
人工智能·opencv·线性代数·微服务·矩阵·架构·数据挖掘