前情回顾 :
在 《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)集成,解决微服务下的数据一致性难题!
👍 如果你觉得有帮助,欢迎点赞、收藏、转发!
💬 你的系统采用哪种多租户方案?欢迎评论区交流!