4. 隔离模式深度解析(中):共享数据库、独立Schema模式(Schema per Tenant)的架构设计与实现
好,咱们接着聊隔离模式。上一章我们讲了独立数据库模式,那个方案隔离性最强,但成本也最高。今天要聊的共享数据库、独立Schema模式,说白了就是“分库不分表”——所有租户的数据还在同一个数据库实例里,但每个租户有自己的Schema(命名空间)。
我个人习惯把这种模式叫做“Schema per Tenant”。它在隔离性和成本之间找到了一个不错的平衡点。我在做SaaS产品的头两年,大部分时间都在跟这个模式打交道。
什么是Schema per Tenant?
简单说,就是每个租户在数据库里拥有独立的Schema。Schema你可以理解成“文件夹”,里面装着这个租户专属的表、视图、存储过程等。
举个例子:
- 租户A的数据在
tenant_a.orders表里 - 租户B的数据在
tenant_b.orders表里 - 租户C的数据在
tenant_c.orders表里
表结构完全一样,但数据是物理隔离的。嗯,这里要注意:虽然表名相同,但它们属于不同的Schema,互不干扰。
架构设计要点
设计这个模式,有几个关键点你得想清楚。我当年第一次做的时候,就踩过不少坑。
1. 连接管理
所有租户共享同一个数据库连接池吗?
我个人建议:不要。为什么呢?因为一旦某个租户的查询把连接池占满了,其他租户就跟着遭殃。我曾经遇到过这种情况——一个租户跑了个全表扫描,结果所有租户都超时了。
更好的做法是:
- 为每个租户维护独立的连接池
- 或者使用连接池分组,按租户级别分配连接数
代码示例(Java + HikariCP):
// 为每个租户创建独立的连接池
public class TenantDataSourceManager {
private Map<String, HikariDataSource> dataSourceMap = new ConcurrentHashMap<>();
public DataSource getDataSource(String tenantId) {
return dataSourceMap.computeIfAbsent(tenantId, id -> {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/saas_db");
config.setUsername("tenant_" + id);
config.setPassword("password_" + id);
config.setSchema(id); // 关键:指定Schema
config.setMaximumPoolSize(10); // 每个租户最多10个连接
return new HikariDataSource(config);
});
}
}
2. Schema路由
用户请求进来后,怎么知道该用哪个Schema?
常见的做法是:
- 从JWT Token或Session中提取租户ID
- 在数据库连接层面设置当前Schema
- 或者通过ORM框架的拦截器动态切换
我用过最顺手的方式是Spring的AbstractRoutingDataSource。说白了,就是写一个路由拦截器,每次请求来了,根据租户ID动态切换数据源。
public class TenantAwareDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getCurrentTenantId();
}
}
你想想看,这个方案的好处是:业务代码完全不用关心数据隔离的问题。你写SQL的时候,就像操作单租户系统一样。路由层帮你搞定了所有脏活累活。
3. 租户元数据管理
每个租户的Schema信息存在哪?
我建议单独建一个“元数据库”或者用配置文件。里面记录:
- 租户ID
- Schema名称
- 数据库连接信息(如果跨实例)
- Schema状态(激活、冻结、删除)
表格示例:
| 租户ID | Schema名称 | 状态 | 创建时间 |
|---|---|---|---|
| t001 | tenant_a | ACTIVE | 2024-01-15 |
| t002 | tenant_b | ACTIVE | 2024-02-20 |
| t003 | tenant_c | FROZEN | 2024-03-10 |
Schema的创建与迁移
新租户注册时,需要自动创建Schema。这个流程我建议做成自动化脚本:
- 生成唯一的Schema名称(比如
tenant_{uuid}) - 执行DDL脚本创建Schema
- 创建该Schema下的所有表、索引、视图
- 插入初始数据(比如默认配置、角色权限等)
- 更新租户元数据表
代码示例(PostgreSQL):
-- 创建Schema
CREATE SCHEMA IF NOT EXISTS tenant_a;
-- 在该Schema下创建表
CREATE TABLE tenant_a.orders (
id SERIAL PRIMARY KEY,
order_number VARCHAR(50) NOT NULL,
amount DECIMAL(10,2),
created_at TIMESTAMP DEFAULT NOW()
);
-- 创建索引
CREATE INDEX idx_tenant_a_orders_created
ON tenant_a.orders(created_at);
这里有个坑:Schema创建要放在事务里。我曾经因为没加事务,Schema创建了一半,租户元数据没更新,结果那个租户就变成了“幽灵租户”——有数据但没法用。
隔离性与性能的权衡
这个模式的好处很明显:
- 隔离性中等:数据在Schema级别隔离,一个租户的SQL错误不会影响其他租户
- 成本可控:共享数据库实例,比独立数据库省钱多了
- 维护方便:数据库备份、监控都集中管理
但缺点也得说清楚:
- 资源竞争:所有租户共享CPU、内存、IO。一个租户的慢查询可能拖慢整个实例
- 扩展上限:单个数据库实例能承载的Schema数量有限。我见过一个案例,一个PostgreSQL实例里放了5000多个Schema,最后查询性能惨不忍睹
- 跨租户查询困难:如果你想做全局统计,得遍历所有Schema,性能开销很大
我的建议:如果你的租户数量在1000以内,每个租户的数据量不大(百万级),这个模式非常合适。超过这个量级,建议考虑分库或者混合模式。
避坑指南
嗯,这里我总结几个实战中容易踩的坑:
我曾经犯过的错:
- 没有限制单个租户的连接数,结果一个租户把连接池占满,所有租户都连不上
- Schema名称用了租户ID明文,后来租户ID变了,迁移数据痛苦得要死
- 忘了给每个Schema单独设置搜索路径,导致SQL执行时查到了错误的表
小技巧:
在PostgreSQL里,可以用SET search_path TO tenant_a;来切换当前Schema。这样你写的SQL就不用带Schema前缀了,清爽很多。
什么时候选这个模式?
说白了,这个模式适合“中间地带”的场景:
- 你的SaaS产品面向中小企业,租户数量几百到几千
- 每个租户的数据量不大,但需要一定的数据隔离
- 你希望控制成本,不想为每个租户单独开数据库实例
- 你的运维团队能力有限,不想管理太多数据库实例
我个人的经验是:80%的SaaS产品,用这个模式就够了。别一上来就搞独立数据库,成本扛不住。也别用共享表,隔离性太差。Schema per Tenant是个很舒服的折中方案。
下一章我们聊共享表模式(Shared Table),那个方案更轻量,但隔离性也更弱。到时候我会详细讲讲怎么用租户ID字段做数据隔离,以及那些让人头疼的“数据泄露”问题怎么避免。