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; // 连接字符串
}
路由的核心逻辑其实不复杂:
- 从请求中提取租户ID(通常来自域名、Header或JWT Token)
- 查询租户-数据库映射表
- 动态切换数据源
- 执行业务操作
我在项目中遇到过一个问题:连接池管理。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租户的数据,直接就是生产事故。
方式二: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 实战中的几个关键决策点
最后,分享几个我在实战中总结的经验:
- 数据库实例要不要共用? 我建议共用实例,但独立database。除非客户特别要求独享实例。
- 连接数怎么估算? 每个租户活跃连接数 × 租户数 × 1.5(冗余系数)。超过数据库上限就要考虑分实例。
- 要不要做读写分离? 如果租户数据量大,建议做。但路由逻辑会更复杂。
- 监控怎么做? 每个租户的数据库都要有独立的监控指标。我习惯用Prometheus + Grafana,每个数据库一个panel。
vip_tenant_001、free_tenant_002。这样运维人员一眼就能看出这个库属于什么级别的租户,方便做差异化运维。
好了,独立数据库模式就讲到这里。下一节我们会聊Schema模式——一种在隔离性和成本之间取得平衡的方案。到时候我会分享一个我踩过的「Schema切换导致死锁」的坑,保证让你印象深刻。