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。这个流程我建议做成自动化脚本:

  1. 生成唯一的Schema名称(比如 tenant_{uuid}
  2. 执行DDL脚本创建Schema
  3. 创建该Schema下的所有表、索引、视图
  4. 插入初始数据(比如默认配置、角色权限等)
  5. 更新租户元数据表

代码示例(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字段做数据隔离,以及那些让人头疼的“数据泄露”问题怎么避免。