多租户架构设计
多租户架构的核心是在同一套系统中为多个租户提供服务,同时确保租户间数据的隔离性与安全性。本文梳理三种主流架构模式及基于 MyBatis 拦截器的自动租户识别方案。
多租户架构三种模式
| 模式 | 隔离级别 | 成本 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|
| 独立数据库(DB per Tenant) | 物理隔离 | 高 | 高 | 金融、医疗等强合规场景 |
| 独立 Schema(Schema per Tenant) | 逻辑隔离 | 中 | 中 | 中型 SaaS、企业级应用 |
| 共享数据库(Shared DB + tenant_id) | 行级隔离 | 低 | 低 | 轻量级 SaaS、海量租户 |
架构对比图
Java
模式一:独立数据库
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Tenant A │ │ Tenant B │ │ Tenant C │
│ DB_A │ │ DB_B │ │ DB_C │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└────────────────┼────────────────┘
│
┌───────┴───────┐
│ MyBatis App │
│ (动态数据源) │
└───────────────┘
模式二:独立 Schema
┌──────────────────────────────────┐
│ 单个 PostgreSQL │
│ ┌─────────┬─────────┬─────────┐ │
│ │schema_A │schema_B │schema_C │ │
│ │ table1 │ table1 │ table1 │ │
│ │ table2 │ table2 │ table2 │ │
│ └─────────┴─────────┴─────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ │ MyBatis App │ │
│ │ (切换schema) │ │
│ └───────────────┘ │
└──────────────────────────────────┘
模式三:共享数据库
┌──────────────────────────────────┐
│ 单个数据库 │
│ ┌────────────────────────────┐ │
│ │ 共享 Table │ │
│ │ tenant_id │ col1 │ col2 │ │
│ │ A │ ... │ ... │ │
│ │ B │ ... │ ... │ │
│ │ C │ ... │ ... │ │
│ └────────────────────────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ │ MyBatis App │ │
│ │ (拦截器过滤) │ │
│ └───────────────┘ │
└──────────────────────────────────┘
模式一:独立数据库实现
动态数据源路由
Java
public class TenantContextHolder {
private static final ThreadLocal<String> TENANT_KEY = new ThreadLocal<>();
public static void setTenant(String tenantId) {
TENANT_KEY.set(tenantId);
}
public static String getTenant() {
return TENANT_KEY.get();
}
public static void clear() {
TENANT_KEY.remove();
}
}
YAML
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
private final Map<String, DataSource> dataSourceMap;
public TenantRoutingDataSource(Map<String, DataSource> dataSourceMap) {
this.dataSourceMap = dataSourceMap;
setTargetDataSources(new HashMap<>(dataSourceMap));
setDefaultTargetDataSource(dataSourceMap.get("default"));
afterPropertiesSet();
}
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContextHolder.getTenant();
if (tenantId == null || !dataSourceMap.containsKey(tenantId)) {
return "default";
}
return tenantId;
}
/** 运行时动态注册新租户数据源 */
public synchronized void registerTenant(String tenantId, DataSource ds) {
dataSourceMap.put(tenantId, ds);
setTargetDataSources(new HashMap<>(dataSourceMap));
afterPropertiesSet();
}
}
Spring Boot 配置
Java
# application.yml
tenant:
datasources:
tenant_a:
url: jdbc:mysql://host1:3306/tenant_a_db
username: root
password: xxx
tenant_b:
url: jdbc:mysql://host2:3306/tenant_b_db
username: root
password: xxx
default:
url: jdbc:mysql://localhost:3306/default_db
username: root
password: xxx
Java
@Configuration
public class TenantDataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "tenant.datasources")
public Map<String, DataSourceProperties> dataSourcePropertiesMap() {
return new HashMap<>();
}
@Bean
public TenantRoutingDataSource tenantRoutingDataSource(
Map<String, DataSourceProperties> propsMap) {
Map<String, DataSource> dsMap = new HashMap<>();
propsMap.forEach((key, props) -> {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(props.getUrl());
ds.setUsername(props.getUsername());
ds.setPassword(props.getPassword());
dsMap.put(key, ds);
});
return new TenantRoutingDataSource(dsMap);
}
@Bean
public SqlSessionFactory sqlSessionFactory(TenantRoutingDataSource ds) throws Exception {
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(ds);
return factory.getObject();
}
}
模式三:共享数据库 + MyBatis 拦截器
租户拦截器核心实现
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})
})
public class TenantInterceptor implements Interceptor {
private static final String TENANT_COLUMN = "tenant_id";
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
// 获取当前租户ID
String tenantId = TenantContextHolder.getTenant();
if (tenantId == null) {
return invocation.proceed();
}
// 获取原始 BoundSql
BoundSql boundSql = ms.getBoundSql(parameter);
String originalSql = boundSql.getSql();
// 判断是否为 SELECT/UPDATE/DELETE 且未包含 tenant_id
if (!originalSql.contains(TENANT_COLUMN) && isTenantAwareStatement(ms)) {
String newSql = appendTenantCondition(originalSql, tenantId);
// 创建新的 MappedStatement 和 BoundSql
MappedStatement newMs = copyMappedStatement(ms, newSql);
args[0] = newMs;
}
return invocation.proceed();
}
private boolean isTenantAwareStatement(MappedStatement ms) {
// 通过自定义注解或表名判断是否需要租户过滤
String id = ms.getId();
// 排除系统表、租户管理表等
return !id.contains("SysTenant") && !id.contains("TenantConfig");
}
private String appendTenantCondition(String sql, String tenantId) {
String lowerSql = sql.toLowerCase().trim();
if (lowerSql.startsWith("select")) {
return sql.replaceAll("(?i)WHERE", "WHERE tenant_id = '" + tenantId + "' AND ")
.replaceAll("(?i)FROM", "FROM")
.replaceFirst("(?i)(FROM\\s+\\w+)", "$0 WHERE tenant_id = '" + tenantId + "'");
} else if (lowerSql.startsWith("update")) {
return sql.replaceAll("(?i)SET", "SET").replaceFirst(
"(?i)(UPDATE\\s+\\w+)", "$0 WHERE tenant_id = '" + tenantId + "' AND ");
} else if (lowerSql.startsWith("delete")) {
return sql.replaceFirst("(?i)(DELETE\\s+FROM\\s+\\w+)", "$0 WHERE tenant_id = '" + tenantId + "' AND ");
}
return sql;
}
private MappedStatement copyMappedStatement(MappedStatement original, String newSql) {
MappedStatement.Builder builder = new MappedStatement.Builder(
original.getConfiguration(),
original.getId(),
new BoundSqlSqlSource(original.getConfiguration(), newSql, original.getBoundSql(null)),
original.getSqlCommandType()
);
builder.resource(original.getResource());
builder.fetchSize(original.getFetchSize());
builder.statementType(original.getStatementType());
builder.parameterMap(original.getParameterMap());
builder.resultMaps(original.getResultMaps());
builder.cache(original.getCache());
builder.useCache(original.isUseCache());
return builder.build();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties props) {
// 可配置忽略的表名、租户字段名等
}
// 内部 SqlSource 实现
private static class BoundSqlSqlSource implements SqlSource {
private final BoundSql boundSql;
BoundSqlSqlSource(Configuration cfg, String sql, BoundSql original) {
this.boundSql = new BoundSql(cfg, sql, original.getParameterMappings(), original.getParameterObject());
}
@Override
public BoundSql getBoundSql(Object parameterObject) {
return boundSql;
}
}
}
租户拦截器注册
Java
@Configuration
public class MyBatisConfig {
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> {
configuration.addInterceptor(new TenantInterceptor());
};
}
}
租户识别与上下文传递
Filter 层识别
Java
@Component
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String tenantId = req.getHeader("X-Tenant-ID");
if (tenantId != null) {
TenantContextHolder.setTenant(tenantId);
}
try {
chain.doFilter(request, response);
} finally {
TenantContextHolder.clear();
}
}
}
Feign/Dubbo 跨服务传递
Java
public class TenantFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String tenantId = TenantContextHolder.getTenant();
if (tenantId != null) {
template.header("X-Tenant-ID", tenantId);
}
}
}
混合模式策略
生产环境中常采用混合模式:大租户独立数据库,小租户共享。
text
public class HybridTenantRouter {
private final Map<String, DataSource> dedicatedDsMap; // 大租户独立DB
private final DataSource sharedDs; // 小租户共享DB
private final Set<String> dedicatedTenants; // 大租户ID集合
public DataSource route(String tenantId) {
if (dedicatedTenants.contains(tenantId)) {
return dedicatedDsMap.get(tenantId);
}
return sharedDs;
}
}
text
混合模式架构
┌──────────────────────────────────────────────┐
│ 租户路由器 │
│ tenantId in [A, B, C]? → 独立DB │
│ 其他? → 共享DB (tenant_id 过滤) │
└──────────┬─────────────────┬─────────────────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ 独立DB集群 │ │ 共享数据库 │
│ A/B/C各一个 │ │ 行级隔离 │
└─────────────┘ └─────────────┘
注意事项
- INSERT 不自动加租户条件:INSERT 需手动在业务层设置 tenant_id,不应由拦截器自动注入,避免掩盖业务逻辑遗漏
- 跨租户查询豁免:统计分析、后台管理等场景需绕过租户过滤,通过自定义注解
@IgnoreTenant标识- 线程池场景清理:异步任务中使用 ThreadLocal 存储租户ID时,必须在 finally 中清理,防止线程复用导致数据泄漏
- DDL 操作排除:拦截器仅作用于 DML,表结构变更不应携带租户条件
- SQL 解析精度:简单字符串替换无法处理子查询、JOIN 等复杂场景,生产环境建议使用 JSqlParser 进行 AST 级 SQL 改写
要点总结
- 多租户三种模式:独立数据库(物理隔离)、独立 Schema(逻辑隔离)、共享数据库(行级隔离),隔离级别与成本依次递减
- 独立数据库模式通过
AbstractRoutingDataSource+ThreadLocal实现运行时数据源切换 - 共享数据库模式通过 MyBatis
Interceptor拦截 Executor,在 SQL 中自动注入tenant_id条件 - 租户识别通过 HTTP Header / RPC Header 传递,使用
ThreadLocal存储并在请求结束后清理 - 生产环境常采用混合模式:大租户独立数据库,小租户共享数据库 + 行级过滤
- 复杂 SQL 改写应使用 JSqlParser 进行 AST 级解析,而非简单字符串替换
存放路径:D:\git2\jwdev\articles\MYBATIS\专家\多租户与数据权限\多租户架构设计.md
📝 发现内容有误?点击此处直接编辑