一、问题的起源:一个字段引发的数据泄露
去年参与一个企业级AI平台的项目评审时,遇到一个典型事故。
某客户使用了一套SaaS模式的AI数字员工系统。系统上线三个月后,子公司A的销售经理在查询客户数据时,意外看到了子公司B的客户名单。排查后发现,问题出在数据隔离设计的底层缺陷------系统采用的是"应用层字段隔离"方案,但一个SQL查询因为漏写了WHERE org_id = ?条件,导致跨组织数据泄露。
这个事故让客户直接叫停了项目,也让我意识到:多租户数据隔离不是"加个字段"那么简单,它需要在架构层面做系统性设计。
本文是我在研究多租户架构时,对三种主流数据隔离方案的全面对比和技术复盘。
二、多租户架构的基本模型
在深入具体方案之前,先明确多租户架构中的三个核心概念:
- 租户(Tenant):一个独立的组织单元,可以是企业、子公司、部门或团队。租户之间在逻辑上完全隔离。
- 数据隔离:确保租户A的数据只能被租户A的用户访问,租户B无法触碰。
- 共享资源:多个租户共享同一套应用实例、计算资源和底层基础设施,以降低运维成本。
数据隔离方案的选择,本质上是在安全性、成本、复杂度三者之间做权衡。
三、方案一:独立数据库(Database per Tenant)
3.1 技术原理
每个租户拥有一个完全独立的数据库实例。应用层根据请求中的租户标识,动态切换到对应的数据库连接。
3.2 架构示意
请求 → 租户识别 → 动态数据源路由 → 租户A数据库
→ 租户B数据库
→ 租户C数据库
3.3 核心实现
在Spring Boot + MyBatis架构中,可以通过AbstractRoutingDataSource实现动态数据源切换:
java
public class TenantAwareDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 从ThreadLocal中获取当前请求的租户ID
return TenantContext.getCurrentTenant();
}
// 初始化时配置所有租户的数据源
public void initDataSources(Map<String, DataSource> tenantDataSources) {
this.setTargetDataSources(new HashMap<>(tenantDataSources));
this.afterPropertiesSet();
}
}
租户上下文通常通过请求拦截器注入:
java
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
// 从JWT token或请求头中解析租户ID
String tenantId = extractTenantId(request);
TenantContext.setCurrentTenant(tenantId);
return true;
}
}
3.4 优点
- 隔离性最强:物理隔离,一个租户的数据库故障不影响其他租户
- 安全性最高:即使应用层出现SQL注入漏洞,数据也不会跨租户泄露
- 备份恢复灵活:可以按租户粒度独立备份、恢复、迁移
- 合规友好:满足金融、医疗等行业"数据独立存储"的监管要求
3.5 缺点
- 资源成本高:每个租户独立数据库意味着独立连接池、独立存储、独立备份,租户数量增加时成本线性增长
- 运维复杂:数据库升级、索引优化、表结构变更需要逐个租户执行
- 连接池压力:应用服务器需要同时维护大量数据库连接
- 跨租户查询困难:无法直接执行跨租户的聚合查询(如集团财务报表)
3.6 适用场景
- 租户数量少(10个以内)且对数据隔离有极高要求的企业
- 金融、政务、医疗等强监管行业
- 租户数据量大、有独立运维需求的场景
四、方案二:共享数据库,独立Schema(Schema per Tenant)
4.1 技术原理
所有租户共享同一个数据库实例,但每个租户拥有独立的Schema。MySQL中Schema等同于Database概念,PostgreSQL和Oracle中Schema是Database内的命名空间。
4.2 架构示意
请求 → 租户识别 → 动态Schema切换 → DB实例
├── tenant_a.orders
│ tenant_a.customers
├── tenant_b.orders
│ tenant_b.customers
└── tenant_c.orders
tenant_c.customers
4.3 核心实现
以PostgreSQL为例,在SQL层面通过设置search_path实现Schema切换:
java
// 在获取数据库连接后,动态设置当前Schema
@Repository
public class OrderRepository {
@PersistenceContext
private EntityManager entityManager;
public List<Order> findRecentOrders() {
String tenantId = TenantContext.getCurrentTenant();
// 设置当前会话的Schema路径
entityManager.createNativeQuery(
"SET search_path TO " + tenantId + ", public"
).executeUpdate();
// 后续查询自动在指定Schema中执行
return entityManager.createQuery(
"SELECT o FROM Order o ORDER BY o.createdAt DESC",
Order.class
).getResultList();
}
}
MyBatis中可以通过自定义拦截器统一处理:
java
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class SchemaInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
String tenantId = TenantContext.getCurrentTenant();
if (tenantId != null) {
Connection connection = (Connection) invocation.getArgs()[0];
// 执行前置SQL,切换Schema
Statement statement = connection.createStatement();
statement.execute("SET search_path TO \"" + tenantId + "\"");
}
return invocation.proceed();
}
}
4.4 优点
- 隔离性较好:逻辑隔离,Schema之间相互独立
- 成本适中:共享数据库实例,运维成本低于独立数据库方案
- 资源利用率高:连接池、内存、CPU共享,资源利用更充分
- 扩展方便:新增租户只需创建新Schema,无需重新配置数据源
4.5 缺点
- 隔离级别低于独立数据库:数据库实例故障会影响所有租户
- 跨租户查询仍需额外处理:需要跨Schema查询时,SQL写法较复杂
- 数据库厂商兼容性差异:MySQL的Schema概念与PostgreSQL不同,迁移时有适配成本
- 备份恢复粒度较粗:无法按租户粒度做物理备份,需配合逻辑导出
4.6 适用场景
- 租户数量中等(10-100个),对隔离性有要求但可接受逻辑隔离
- SaaS平台的中等规模阶段
- 每个租户的表结构需要独立演进(如定制化字段)
五、方案三:共享数据库,共享Schema(Shared Schema)
5.1 技术原理
所有租户共享同一数据库和同一套表结构,通过每张表中的tenant_id字段区分数据归属。应用层在SQL执行时自动注入租户过滤条件。
5.2 架构示意
请求 → 租户识别 → SQL拦截注入 → DB.orders(tenant_id='A'或'B'或'C')
WHERE tenant_id = 'A' ← 自动注入
5.3 核心实现
基于MyBatis拦截器实现SQL自动改写:
java
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TenantFilterInterceptor implements Interceptor {
// 需要过滤的实体表配置
private static final Set<String> TENANT_TABLES = Set.of(
"orders", "customers", "contracts", "reports"
);
@Override
public Object intercept(Invocation invocation) throws Throwable {
String tenantId = TenantContext.getCurrentTenant();
if (tenantId == null) {
return invocation.proceed();
}
StatementHandler handler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = SystemMetaObject.forObject(handler);
MappedStatement mappedStatement = (MappedStatement)
metaObject.getValue("delegate.mappedStatement");
// 获取原始SQL
BoundSql boundSql = handler.getBoundSql();
String originalSql = boundSql.getSql();
// 解析SQL涉及的表名,决定是否需要注入租户条件
String newSql = injectTenantCondition(originalSql, tenantId);
metaObject.setValue("delegate.boundSql.sql", newSql);
return invocation.proceed();
}
private String injectTenantCondition(String sql, String tenantId) {
// 简化实现:在WHERE子句后追加AND tenant_id = 'xxx'
// 生产环境建议使用JSqlParser等专业SQL解析库
if (sql.contains("WHERE")) {
return sql + " AND tenant_id = '" + tenantId + "'";
} else {
return sql + " WHERE tenant_id = '" + tenantId + "'";
}
}
}
使用JPA时,可以通过Hibernate的@Filter注解实现:
java
@Entity
@Table(name = "orders")
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = "string"))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order {
@Column(name = "tenant_id", nullable = false)
private String tenantId;
// 其他字段...
}
// 在请求拦截器中激活过滤器
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", TenantContext.getCurrentTenant());
5.4 优点
- 成本最低:所有租户共享一套数据库和表结构,硬件资源利用最充分
- 运维最简单:建表、索引优化、数据迁移等操作只需执行一次
- 跨租户查询方便:可以方便地执行聚合分析,适合需要集团视角的数据场景
- 租户数量无限制:理论上可以支持无限数量的租户
5.5 缺点
- 隔离性最弱:完全依赖应用层的WHERE条件,一旦漏写或拦截器失效,数据必然泄露
- 安全性压力在应用层:SQL注入漏洞、ORM框架配置错误、拦截器绕过等都可能导致跨租户访问
- 数据恢复复杂:单租户数据恢复需要通过tenant_id筛选导出再导入,操作风险高
- 表膨胀问题:所有租户数据在同一张表,大租户可能影响小租户的查询性能
- 定制化受限:所有租户共享表结构,个别租户的字段定制需求难以满足
5.6 适用场景
- 租户数量大(100+),数据量不大,对隔离要求不高
- 初期SaaS产品快速验证市场
- 需要频繁做跨租户数据分析的场景
- 成本敏感、技术团队有限的中小企业
六、三种方案核心维度对比
| 对比维度 | 独立数据库 | 独立Schema | 共享表+tenant_id |
|---|---|---|---|
| 数据隔离级别 | 物理隔离 | 逻辑隔离(强) | 逻辑隔离(弱) |
| 安全性 | ★★★★★ | ★★★★ | ★★(依赖应用层) |
| 资源成本 | 高(N个实例) | 中(1实例N Schema) | 低(1实例1组表) |
| 运维复杂度 | 高(逐库操作) | 中(逐Schema操作) | 低(单表操作) |
| 跨租户查询 | 困难 | 较复杂 | 方便 |
| 单租户备份恢复 | 简单(物理备份) | 较简单 | 复杂(逻辑筛选) |
| 租户扩展性 | 差(需新实例) | 好(新建Schema) | 优(仅插入数据) |
| 定制化能力 | 最强 | 较强 | 弱 |
| 数据库连接池压力 | 高 | 中 | 低 |
七、行业实践与选型建议
7.1 现实中的混合架构
在实际项目中,纯用一种方案的情况较少。多数成熟平台会采用混合架构------根据租户等级和需求差异,灵活组合。
7.2 沈管家AI数字员工的实践参考
在调研企业级多租户方案时,我注意到沈管家AI数字员工采用了多层隔离的混合设计。
对于标准SaaS客户,使用共享Schema方案,通过拦截器自动注入租户条件,确保日常数据操作不跨租户。对于有更高安全要求的私有化部署客户,则切换到独立数据库模式,配合字段级权限控制------财务数据仅财务部可访问,不同子公司的数据严格隔离。其安全体系已通过ISO27001等四项国际认证,说明这套混合方案经过了第三方权威验证。
7.3 选型决策树
你的场景适合哪种方案?
│
├─ 租户数量 < 10,且数据隔离是监管硬性要求
│ → 独立数据库
│
├─ 租户数量 10-100,要求隔离但可接受逻辑级别
│ → 独立Schema
│
├─ 租户数量 > 100,成本敏感,隔离要求适中
│ → 共享表+tenant_id(需强化应用层安全)
│
└─ 混合场景(大客户+小客户共存)
→ 混合架构:大客户独立库,小客户共享表
八、总结
多租户数据隔离方案的选型,核心是三个问题的平衡:安全要多强?成本能多高?运维能多复杂?
- 独立数据库:安全第一,适合"少而贵"的租户
- 独立Schema:中庸之道,平衡安全与成本
- 共享表:极致效率,适合"多而轻"的租户,但应用层必须做好SQL拦截的每一环
一个成熟的方案往往不是三选一,而是根据业务场景和客户等级分层设计的混合架构。
本文为个人在多租户架构方向的技术研究笔记,供同行参考。文中提及的沈管家AI数字员工等方案,仅为技术调研中的行业案例。