3、隔离模式深度解析(上):独立数据库模式(Database per Tenant)的架构设计与实现

3.1 为什么我偏爱独立数据库模式?

聊到多租户隔离,第一个绕不开的模式就是独立数据库模式。说白了,就是每个租户拥有自己独立的数据库实例。

我个人习惯把这个模式叫做「物理级隔离」。为什么?因为数据真的分开了,一个租户一个库,互不干扰。我在2018年给一家金融科技公司做架构时,客户上来就要求「租户之间绝对不能有任何数据交叉的可能」。嗯,那没得选,就是它了。

你想想看,这种模式最大的好处是什么?安全。一个租户的数据库挂了,不影响其他人。一个租户要备份恢复,直接操作自己的库就行。我见过不少团队在共享模式下为了「软删除」和「数据过滤」写出一堆bug,但在独立数据库模式下,这些问题天然不存在。

核心结论:独立数据库模式 = 最高级别的数据隔离 + 最简单的运维模型 + 最贵的资源成本。

3.2 架构全景图:一个租户一个库

先看整体架构。我们有一个路由层,负责把请求分发到正确的数据库。

// 租户与数据库的映射关系
public class TenantDatabaseMapping {
    private String tenantId;      // 租户ID
    private String dbHost;        // 数据库主机
    private int dbPort;           // 端口
    private String dbName;        // 数据库名
    private String connectionString; // 连接字符串
}

路由的核心逻辑其实不复杂:

  1. 从请求中提取租户ID(通常来自域名、Header或JWT Token)
  2. 查询租户-数据库映射表
  3. 动态切换数据源
  4. 执行业务操作

我在项目中遇到过一个问题:连接池管理。100个租户,每个租户一个连接池,那就是100个连接池。每个池子默认10个连接,瞬间就是1000个连接。数据库服务器扛得住吗?

我的建议:不要为每个租户创建独立的连接池。使用「共享连接池 + 动态数据库切换」的方案。连接池只管理到数据库实例级别,而不是租户级别。

3.3 数据源路由的两种实现方式

方式一:AbstractRoutingDataSource(Spring生态)

Spring框架提供了现成的方案。我直接上代码:

public class TenantAwareDataSource extends AbstractRoutingDataSource {
    
    @Override
    protected Object determineCurrentLookupKey() {
        // 从ThreadLocal中获取当前租户的数据库标识
        return TenantContext.getCurrentTenantDbKey();
    }
}

// 使用示例
public class TenantContext {
    private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
    
    public static void setTenantId(String tenantId) {
        currentTenant.set(tenantId);
    }
    
    public static String getTenantId() {
        return currentTenant.get();
    }
    
    public static void clear() {
        currentTenant.remove();
    }
}

这里有个坑,我曾经踩过:ThreadLocal的内存泄漏。如果请求结束后没有调用clear(),线程池复用时会读到上一个租户的数据。这可不是闹着玩的,A租户看到了B租户的数据,直接就是生产事故。

避坑指南:一定要在Filter或Interceptor中确保TenantContext.clear()被调用。建议使用try-finally块,或者Spring的@After注解。我曾经因为漏了这一步,差点让客户数据「串门」。

方式二:MyBatis拦截器 + 动态Schema

如果你的数据库是同一个实例,只是不同Schema(比如MySQL的database),可以用拦截器:

@Intercepts({
    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class TenantInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 在SQL执行前,动态切换数据库
        String tenantId = TenantContext.getTenantId();
        if (tenantId != null) {
            // 执行 USE `tenant_${tenantId}`;
            // 或者修改SQL中的表名,加上schema前缀
        }
        return invocation.proceed();
    }
}

3.4 租户创建流程:自动化才是王道

新租户注册时,不能手动去建库。必须自动化。我设计的流程是这样的:

步骤 操作 说明
1 租户注册 填写基本信息,系统生成tenantId
2 分配数据库 从数据库资源池中选取一个实例
3 执行建库脚本 CREATE DATABASE tenant_xxx; 并执行初始化DDL
4 写入映射表 将tenantId与数据库信息存入路由表
5 初始化数据 插入默认配置、角色、权限等基础数据

这里要注意的是幂等性。如果第4步写入映射表时失败了,但第3步的数据库已经创建了,怎么办?我习惯用「两阶段提交」的思路:先写入映射表(状态为「创建中」),再执行建库,最后更新状态为「已就绪」。如果中间失败,有定时任务去清理「创建中」的脏数据。

3.5 备份与恢复:每个租户都是独立的「小系统」

独立数据库模式最大的运维优势就在这里。你可以针对每个租户制定不同的备份策略:

  • VIP租户:每小时增量备份 + 每天全量备份,保留30天
  • 普通租户:每天全量备份,保留7天
  • 免费租户:每3天备份一次,保留3份

恢复操作也简单:

# 恢复某个租户的数据库
mysql -h ${DB_HOST} -u root -p ${DB_NAME} < backup_tenant_xxx.sql

# 或者使用物理备份恢复
xtrabackup --copy-back --target-dir=/backup/tenant_xxx/

我曾经帮一个客户恢复过数据。他们误操作删了一个租户的核心业务表。因为用的是独立数据库,我直接拿昨天的备份恢复,5分钟搞定。如果是共享模式,还得担心恢复会不会影响其他租户。这就是独立模式的好处——故障隔离

3.6 成本与性能:你需要知道的真相

独立数据库模式不是银弹。它有明显的短板:

成本分析:
  • 100个租户 = 至少100个数据库连接
  • 每个数据库需要独立的存储空间(即使数据很少)
  • 数据库实例数量多,运维复杂度上升
  • 连接数上限容易成为瓶颈

我见过一个极端案例:某SaaS平台有5000个租户,每个租户数据量不到100MB。如果用独立数据库,光元数据开销就比实际数据还大。后来他们改成了Schema模式,成本直接降了60%。

所以我的建议是:

  • 金融、医疗、政务等强合规行业 → 独立数据库模式
  • 企业级大客户(数据量大、要求高隔离) → 独立数据库模式
  • 中小客户、免费用户 → 考虑共享模式或Schema模式

3.7 实战中的几个关键决策点

最后,分享几个我在实战中总结的经验:

  1. 数据库实例要不要共用? 我建议共用实例,但独立database。除非客户特别要求独享实例。
  2. 连接数怎么估算? 每个租户活跃连接数 × 租户数 × 1.5(冗余系数)。超过数据库上限就要考虑分实例。
  3. 要不要做读写分离? 如果租户数据量大,建议做。但路由逻辑会更复杂。
  4. 监控怎么做? 每个租户的数据库都要有独立的监控指标。我习惯用Prometheus + Grafana,每个数据库一个panel。
一个小技巧:在数据库命名上加上租户类型前缀。比如:vip_tenant_001free_tenant_002。这样运维人员一眼就能看出这个库属于什么级别的租户,方便做差异化运维。

好了,独立数据库模式就讲到这里。下一节我们会聊Schema模式——一种在隔离性和成本之间取得平衡的方案。到时候我会分享一个我踩过的「Schema切换导致死锁」的坑,保证让你印象深刻。