前情回顾:
在 《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 = ?实现;- 统一抽象:无论哪种方案,业务代码无需感知租户逻辑!
二、核心设计原则
- 透明性:Service/Controller 层完全 unaware 租户存在;
- 安全性:杜绝跨租户数据访问(即使 SQL 写错);
- 性能:拦截器开销可控,避免全表扫描;
- 可扩展:支持未来切换隔离策略(如从字段隔离升级到 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、HeaderX-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.orders、tenant_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)集成,解决微服务下的数据一致性难题!👍 如果你觉得有帮助,欢迎点赞、收藏、转发!
💬 你的系统采用哪种多租户方案?欢迎评论区交流!