多租户 SaaS 架构实战——数据隔离方案的选型与代价分析
多租户 SaaS 架构实战——数据隔离方案的选型与代价分析
适读人群:正在做或准备做 SaaS 产品的工程师、架构师 | 阅读时长:约17分钟 | 核心价值:多租户数据隔离没有万能方案,每种方案的收益和代价都是真实存在的,从我的选型经历说清楚
一个选型决策影响了我们两年
2020 年,我们在把一个 ToB 产品改造成 SaaS 版本的时候,面临一个核心决策:租户之间的数据怎么隔离?
当时我们在三种方案里选择,每个方案都有支持者。我们最终选了最省成本的方案,然后在接下来两年里,为这个选择付出了持续的代价。
这篇文章把三种方案的真实收益和真实代价都写出来,以及我当时是怎么想的,最终是否后悔了。
三种数据隔离方案
方案一:独立数据库
每个租户一个独立的数据库实例(或 schema)。
tenant-001 → db_tenant_001
tenant-002 → db_tenant_002
tenant-003 → db_tenant_003数据隔离方式:
// 根据租户 ID 路由到不同的数据源
public class TenantAwareDatasourceRouter extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getCurrentTenantId(); // ThreadLocal 里存的当前租户
}
}
// 每个请求进来时,从 JWT 或 Header 里提取租户 ID
@Component
public class TenantContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
String tenantId = extractTenantId((HttpServletRequest) request);
TenantContext.setTenantId(tenantId);
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}优点:
- 数据隔离最彻底,租户之间物理隔离
- 某个租户的大查询不影响其他租户
- 容易做租户级别的备份和恢复
- 数据迁移(租户从 SaaS 私有化部署)方便,导出一个 DB 就够了
缺点:
- 每个租户一个 DB,100 个租户 = 100 个 DB 实例,运维成本高
- DB Schema 变更(加字段)需要对所有租户的 DB 执行,一次 DDL 要执行 N 次
- 连接池管理复杂,每个 DB 有独立连接池,总连接数 = 租户数 × 每库连接数
- 资源利用率低,小租户的 DB 大多数时候是空跑的
适合: 对数据隔离要求极高的行业(金融、医疗、政府),或者每个租户规模较大愿意付高价格的高端 SaaS。
方案二:独立 Schema,共享数据库实例
所有租户共用一个 MySQL 实例,但每个租户有独立的 schema(数据库)。
MySQL Instance
├── db_tenant_001 (schema)
│ ├── orders
│ └── users
├── db_tenant_002 (schema)
│ ├── orders
│ └── users优点:
- 隔离性比共享表强:schema 级别的隔离,不同租户的表互相独立
- 比独立 DB 省机器:多个租户共享一个 MySQL 实例
- DDL 变更仍然需要对每个 schema 执行(这是主要缺点)
缺点:
- 同一 MySQL 实例,某个租户的大查询仍然会影响其他租户(CPU/IO 竞争)
- DDL 变更问题和独立 DB 一样
适合: 租户规模中等(几十到几百),隔离要求较高,但不需要独立实例级别的隔离。
方案三:共享表,用 tenant_id 区分
所有租户共享同一套表,每张表有一个 tenant_id 字段,所有查询都带上 tenant_id 作为过滤条件。
CREATE TABLE orders (
id BIGINT,
tenant_id VARCHAR(64) NOT NULL, -- 所有表都有这个字段
user_id BIGINT,
amount DECIMAL(10,2),
-- ...
INDEX idx_tenant_status (tenant_id, status, create_time)
);
-- 所有查询都必须带 tenant_id
SELECT * FROM orders WHERE tenant_id = 'tenant-001' AND status = 'PAID';优点:
- 开发运维成本最低:只有一套表,DDL 只需执行一次
- 资源利用率最高:多租户共享计算和存储资源
- 实现简单,只需要在查询层面注入 tenant_id
缺点:
- 隔离性最差:如果 tenant_id 过滤条件漏写,会查到其他租户的数据(数据泄露)
- 某个租户数据量大时,影响所有租户(表级别的 IO 竞争)
- 租户数据不好迁移(数据和其他租户混在一起,导出一个租户的数据需要全表扫描)
适合: 租户规模小(中小企业用户),隔离要求不高,成本敏感的 SaaS。
我们当时的选择和后续代价
我们在 2020 年选了方案三(共享表),原因是:
- 当时客户都是中小企业,隔离要求不高
- 团队规模小,维护多个 DB 的运维能力不够
- 要快速上线,共享表实现最快
前两年还好,但慢慢出现了几个问题:
问题一:大租户拖慢小租户
有一个大租户,订单数据占了整张表数据量的 40%。某天他们跑了一个历史数据导出,全表扫描,导致其他所有租户的查询都慢了。
我们不得不为这个大租户单独配置了限速策略,但这加剧了代码复杂度。
问题二:数据泄露风险
有一次代码 Review,发现一个接口的查询漏了 tenant_id 条件,理论上可以查到所有租户的数据。虽然没有真的泄露,但这个"可能泄露"的风险让我们非常不安。
我们后来引入了 MyBatis 拦截器,在 SQL 执行前自动校验是否有 tenant_id 条件:
@Interceptor
public class TenantIdInjectionInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 解析 SQL,确保所有涉及租户数据的查询都带了 tenant_id
String sql = getSql(invocation);
String tenantId = TenantContext.getCurrentTenantId();
if (requiresTenantFilter(sql) && !hasTenantFilter(sql, tenantId)) {
throw new TenantFilterMissingException("查询缺少 tenant_id 过滤条件:" + sql);
}
return invocation.proceed();
}
}但这个拦截器有时候误判,误报率让开发者开始绕过它,又回到了不安全的状态。
问题三:大客户要私有化部署
有一个大客户要买私有化部署版本,但我们的数据全混在一起,导出这个客户的全量数据要全表扫描加数据清洗,花了 3 天时间才完成。
如果当初用了独立 Schema,10 分钟 mysqldump 就搞定了。
我们最终的演进方向
事后,我们做了以下演进:
- 存量数据保持共享表(改动成本太高)
- 新功能模块(特别是高价值模块)按独立 Schema 开发
- 大客户提供独立数据库的选项,作为高级套餐
这个"缝合"方案并不优雅,但是务实的。
选型建议框架
踩坑记录
踩坑三:索引设计没有考虑 tenant_id
共享表里,如果索引没有把 tenant_id 放在最左前缀,所有查询都会全表扫描。
-- 错误的索引设计(不含 tenant_id)
INDEX idx_status (status, create_time)
-- 正确的索引设计(tenant_id 放最左)
INDEX idx_tenant_status (tenant_id, status, create_time)这个错误在早期数据量小时不明显,数据量大了之后查询突然变慢,排查才发现是索引问题。
教训: 共享表方案里,所有索引设计必须把 tenant_id 放在复合索引的最左边。
多租户是个长期演进的系统设计问题,没有一次性做对的方案,只有随着业务发展持续调整的方案。
最重要的是:在做初始选型时,清醒地认识到每个方案的代价,不要因为短期省事,给未来埋雷。
