租户隔离策略
租户隔离是多租户架构的基石,决定了租户间数据的隔离强度。本文从物理隔离到行级隔离,逐层分析三种策略的实现细节与选择依据。
三种隔离策略对比
| 维度 | 独立数据库 | 独立 Schema | 行级隔离 |
|---|---|---|---|
| 隔离级别 | 物理级 | 逻辑级 | 行级 |
| 数据安全性 | 最高 | 高 | 中 |
| 硬件成本 | 高(每租户独立资源) | 中 | 低 |
| 运维复杂度 | 高 | 中 | 低 |
| 扩展性 | 受限 | 中等 | 极高 |
| 备份恢复 | 独立操作 | Schema 级别 | 需按 tenant_id 筛选 |
| 适用租户规模 | < 100 个大型租户 | 100-1000 中型租户 | > 1000 小型租户 |
Java
隔离强度
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
独立DB ┃█ █ █┃ 物理隔离,零交叉风险
独立SC ┃████▒┃ 逻辑隔离,依赖DB约束
行级 ┃█████┃ 代码隔离,依赖应用层
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
安全性 →
策略一:独立数据库(DB per Tenant)
每个租户拥有独立的数据库实例,物理层面完全隔离。
架构图
SQL
┌────────────────────────────────────────────────┐
│ 应用层 │
│ 动态数据源路由 │
│ TenantRoutingDataSource │
└─────┬────────────┬──────────────┬──────────────┘
│ │ │
↓ ↓ ↓
┌───────┐ ┌───────┐ ┌───────┐
│ DB_A │ │ DB_B │ │ DB_C │
│ tenant│ │ tenant│ │ tenant│
│ table│ │ table│ │ table│
└───────┘ └───────┘ └───────┘
│ │ │
[MySQL-A] [MySQL-B] [MySQL-C]
数据源管理
Java
@Component
public class TenantDataSourceRegistry {
private final Map<String, HikariDataSource> poolMap = new ConcurrentHashMap<>();
private final TenantRoutingDataSource routingDs;
public TenantDataSourceRegistry(TenantRoutingDataSource routingDs) {
this.routingDs = routingDs;
}
/** 注册新租户数据源 */
public synchronized void register(String tenantId, DataSourceConfig config) {
if (poolMap.containsKey(tenantId)) {
return;
}
HikariConfig hc = new HikariConfig();
hc.setJdbcUrl(config.getUrl());
hc.setUsername(config.getUsername());
hc.setPassword(config.getPassword());
hc.setMaximumPoolSize(config.getPoolSize());
hc.setConnectionTimeout(config.getTimeout());
HikariDataSource ds = new HikariDataSource(hc);
poolMap.put(tenantId, ds);
// 注册到路由数据源
routingDs.registerTenant(tenantId, ds);
}
/** 注销租户数据源 */
public synchronized void deregister(String tenantId) {
HikariDataSource ds = poolMap.remove(tenantId);
if (ds != null) {
ds.close();
}
routingDs.removeTenant(tenantId);
}
/** 获取租户连接池状态 */
public PoolStatus getPoolStatus(String tenantId) {
HikariDataSource ds = poolMap.get(tenantId);
if (ds == null) return null;
return new PoolStatus(ds.getHikariPoolMXBean().getActiveConnections(),
ds.getHikariPoolMXBean().getIdleConnections());
}
}
自动建库脚本
Java
-- 新租户初始化脚本模板
CREATE DATABASE IF NOT EXISTS tenant_{id}
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE tenant_{id};
-- 执行基础表结构
SOURCE /path/to/schema/init.sql;
-- 初始化租户配置数据
INSERT INTO sys_config (tenant_id, config_key, config_value)
VALUES ('{id}', 'initialized', 'true');
SQL
@Service
public class TenantProvisioningService {
@Autowired
private JdbcTemplate adminJdbcTemplate; // 管理员连接
@Autowired
private TenantDataSourceRegistry registry;
@Transactional
public void provisionTenant(String tenantId, String dbName) {
// 1. 创建数据库
adminJdbcTemplate.execute(
"CREATE DATABASE IF NOT EXISTS `" + dbName + "` " +
"DEFAULT CHARACTER SET utf8mb4");
// 2. 执行建表脚本
Resource resource = new ClassPathResource("schema/init.sql");
String sql = new String(resource.getInputStream().readAllBytes());
adminJdbcTemplate.execute("USE `" + dbName + "`; " + sql);
// 3. 注册数据源
DataSourceConfig config = new DataSourceConfig();
config.setUrl("jdbc:mysql://localhost:3306/" + dbName);
config.setUsername("tenant_user");
config.setPassword(generatePassword());
registry.register(tenantId, config);
}
}
策略二:共享数据库 + 独立 Schema
多个租户共享同一数据库实例,但各自拥有独立的 Schema(命名空间)。
架构图(以 PostgreSQL 为例)
Java
┌──────────────────────────────────────────┐
│ PostgreSQL 实例 │
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ schema_A │ │ schema_B │ │
│ │ users │ │ users │ │
│ │ orders │ │ orders │ │
│ │ products │ │ products │ │
│ └────────────┘ └────────────┘ │
│ │
│ ┌────────────┐ │
│ │ schema_C │ ... │
│ └────────────┘ │
└──────────────────────────────────────────┘
│
┌────────┴────────┐
│ search_path切换 │
│ MyBatis App │
└─────────────────┘
Schema 切换实现
Java
public class SchemaRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContextHolder.getTenant();
return tenantId;
}
@Override
protected DataSource determineTargetDataSource() {
// 共享数据源,通过 search_path 切换
DataSource ds = super.determineTargetDataSource();
String schema = determineCurrentLookupKey().toString();
try (Connection conn = ds.getConnection()) {
conn.createStatement().execute("SET search_path TO " + schema);
} catch (SQLException e) {
throw new RuntimeException("Failed to set schema: " + schema, e);
}
return ds;
}
}
MySQL 不支持 Schema 级别的快速切换,此策略更适合 PostgreSQL、Oracle 等支持 Schema 的数据库。MySQL 中可通过数据库名模拟,但本质退化为"独立数据库"模式。
策略三:共享数据库 + 共享 Schema(行级隔离)
所有租户共享同一数据库和 Schema,通过 tenant_id 字段区分数据归属。
表结构设计
Java
-- 所有业务表统一包含 tenant_id
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL COMMENT '租户标识',
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 联合索引:tenant_id + 业务字段
INDEX idx_tenant_order (tenant_id, order_no),
INDEX idx_tenant_user (tenant_id, user_id),
INDEX idx_tenant_status(tenant_id, status),
INDEX idx_tenant_created(tenant_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Java
行级隔离数据分布
┌─────────────────────────────────────────┐
│ orders 表 │
│ id │ tenant_id │ order_no │ amount │
├─────┼───────────┼──────────┼────────────┤
│ 1 │ tenant_A │ A001 │ 100.00 │
│ 2 │ tenant_B │ B001 │ 200.00 │
│ 3 │ tenant_A │ A002 │ 150.00 │
│ 4 │ tenant_C │ C001 │ 300.00 │
│ 5 │ tenant_B │ B002 │ 250.00 │
└─────────────────────────────────────────┘
数据混合存储,靠 tenant_id 逻辑隔离
MyBatis 拦截器自动注入 tenant_id
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 TenantIsolationInterceptor implements Interceptor {
private static final String TENANT_COLUMN = "tenant_id";
private Set<String> ignoreTables = new HashSet<>(); // 不需要租户过滤的表
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
String tenantId = TenantContextHolder.getTenant();
if (tenantId == null) {
return invocation.proceed();
}
BoundSql boundSql = ms.getBoundSql(parameter);
String sql = boundSql.getSql().trim().toLowerCase();
// 判断是否需要租户过滤
if (!needsTenantFilter(sql, boundSql)) {
return invocation.proceed();
}
String newSql = injectTenantCondition(boundSql.getSql(), tenantId);
MappedStatement newMs = rebuildMappedStatement(ms, newSql, boundSql);
args[0] = newMs;
return invocation.proceed();
}
private boolean needsTenantFilter(String sql, BoundSql boundSql) {
// 排除系统表、租户管理表
String table = extractTableName(sql);
return table != null && !ignoreTables.contains(table);
}
private String injectTenantCondition(String originalSql, String tenantId) {
// 使用 JSqlParser 进行 AST 级改写
try {
Statement stmt = CCJSqlParserUtil.parse(originalSql);
if (stmt instanceof Select) {
Select select = (Select) stmt;
PlainSelect plain = (PlainSelect) select.getSelectBody();
Expression tenantExpr = new EqualsTo(
new Column(TENANT_COLUMN),
new StringValue(tenantId)
);
Expression where = plain.getWhere();
plain.setWhere(where != null
? new AndExpression(where, tenantExpr)
: tenantExpr
);
return select.toString();
}
} catch (JSQLParserException e) {
// 降级处理
}
return originalSql;
}
private MappedStatement rebuildMappedStatement(
MappedStatement original, String newSql, BoundSql oldBoundSql) {
BoundSql newBoundSql = new BoundSql(
original.getConfiguration(), newSql,
oldBoundSql.getParameterMappings(), oldBoundSql.getParameterObject()
);
// 复制原始 MappedStatement 的所有属性
MappedStatement.Builder builder = new MappedStatement.Builder(
original.getConfiguration(), original.getId(),
new BoundSqlSource(newBoundSql), original.getSqlCommandType()
);
builder.resource(original.getResource());
builder.fetchSize(original.getFetchSize());
builder.statementType(original.getStatementType());
builder.parameterMap(original.getParameterMap());
builder.resultMaps(original.getResultMaps());
builder.resultSetType(original.getResultSetType());
builder.cache(original.getCache());
builder.useCache(original.isUseCache());
builder.flushCacheRequired(original.isFlushCacheRequired());
builder.timeout(original.getTimeout());
return builder.build();
}
private String extractTableName(String sql) {
// 简化实现:从 SQL 中提取表名
// 生产环境应使用 JSqlParser 获取准确的表名
return null;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties props) {
String ignoreList = props.getProperty("ignoreTables", "");
if (!ignoreList.isEmpty()) {
ignoreTables = Set.of(ignoreList.split(","));
}
}
private static class BoundSqlSource implements SqlSource {
private final BoundSql boundSql;
BoundSqlSource(BoundSql sql) { this.boundSql = sql; }
@Override
public BoundSql getBoundSql(Object p) { return boundSql; }
}
}
INSERT 时自动填充 tenant_id
text
@Intercepts({
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
public class TenantInsertInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
String tenantId = TenantContextHolder.getTenant();
if (tenantId == null) {
return invocation.proceed();
}
BoundSql boundSql = ms.getBoundSql(parameter);
String sql = boundSql.getSql().trim().toLowerCase();
// 仅对 INSERT 处理
if (!sql.startsWith("insert")) {
return invocation.proceed();
}
// 检查 SQL 中是否已包含 tenant_id
if (sql.contains("tenant_id")) {
return invocation.proceed();
}
// 在 INSERT 列中追加 tenant_id
String newSql = injectTenantIntoInsert(sql, tenantId);
args[0] = rebuildMappedStatement(ms, newSql, boundSql);
return invocation.proceed();
}
private String injectTenantIntoInsert(String sql, String tenantId) {
// INSERT INTO table (col1, col2) VALUES (?, ?)
// → INSERT INTO table (col1, col2, tenant_id) VALUES (?, ?, 'tenantId')
int closeParenIdx = sql.indexOf(')');
if (closeParenIdx == -1) return sql;
String columns = sql.substring(0, closeParenIdx);
String rest = sql.substring(closeParenIdx);
columns += ", tenant_id";
// 在 VALUES 部分追加
int valuesParenIdx = rest.indexOf('(', 1);
if (valuesParenIdx == -1) return sql;
int valuesCloseIdx = rest.indexOf(')', valuesParenIdx);
String valuesPart = rest.substring(0, valuesCloseIdx + 1);
String afterPart = rest.substring(valuesCloseIdx + 1);
// 去掉最后一个 ) 然后追加 tenant_id 值
valuesPart = valuesPart.substring(0, valuesPart.length() - 1)
+ ", '" + tenantId + "')";
return columns + valuesPart + afterPart;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties props) {}
}
跨租户操作
后台管理场景
text
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CrossTenant {
}
text
public interface AdminMapper {
// 跨租户统计
@CrossTenant
@Select("SELECT tenant_id, COUNT(*) as cnt FROM orders GROUP BY tenant_id")
List<Map<String, Object>> countOrdersByTenant();
// 跨租户搜索
@CrossTenant
List<Order> globalSearch(@Param("keyword") String keyword);
}
拦截器中处理
text
private boolean isCrossTenant(MappedStatement ms) {
try {
String className = ms.getId().substring(0, ms.getId().lastIndexOf("."));
String methodName = ms.getId().substring(ms.getId().lastIndexOf(".") + 1);
Class<?> clazz = Class.forName(className);
for (Method m : clazz.getMethods()) {
if (m.getName().equals(methodName) && m.isAnnotationPresent(CrossTenant.class)) {
return true;
}
}
} catch (Exception ignored) {}
return false;
}
隔离策略选择决策树
text
是否有合规要求?
/ \
是 否
↓ ↓
独立数据库 租户数量 > 1000?
(物理隔离) / \
是 否
↓ ↓
行级隔离 独立 Schema
(tenant_id) (schema 隔离)
注意事项
- 索引设计:行级隔离模式下,所有查询必须包含
tenant_id索引,否则全表扫描导致性能灾难- DDL 变更:独立数据库模式需同步执行 DDL 到所有租户库,可使用 Flyway/Liquibase 多数据源方案
- 备份策略:行级隔离按 tenant_id 导出备份时需加
WHERE tenant_id = ?,避免全量导出- 租户迁移:从小租户升级到大租户时,需支持数据迁移(行级 → 独立库),设计迁移脚本和双写过渡
- 安全审计:行级隔离需在应用层保证
tenant_id不被篡改,数据库层可加 Row-Level Security(如 PostgreSQL RLS)
要点总结
- 三种隔离策略:独立数据库(物理隔离)、独立 Schema(逻辑隔离)、行级隔离(tenant_id 字段),隔离强度与成本依次递减
- 独立数据库模式通过动态注册
HikariDataSource到AbstractRoutingDataSource实现运行时切换,支持自动建库和建表 - 独立 Schema 模式适合 PostgreSQL,通过
SET search_path切换命名空间,MySQL 不支持此模式 - 行级隔离通过 MyBatis 拦截器在 SQL AST 中注入
tenant_id条件,使用 JSqlParser 保证改写精度 - INSERT 操作通过拦截器自动追加
tenant_id列和值,避免业务层遗漏 - 跨租户操作通过
@CrossTenant注解标识,在拦截器中判断并跳过租户过滤 - 索引设计是行级隔离的生命线,所有查询必须命中
(tenant_id, xxx)联合索引
存放路径:D:\git2\jwdev\articles\MYBATIS\专家\多租户与数据权限\租户隔离策略.md
📝 发现内容有误?点击此处直接编辑