4. SaaS多租户架构设计:租户隔离策略、路由与元数据管理
多租户,说白了就是一套系统服务N个客户。每个客户的数据怎么隔开?这是SaaS架构里最核心的决策之一。我这些年见过不少团队,一开始图省事选了简单的隔离方案,结果业务一扩张,数据迁移搞得焦头烂额。嗯,咱们今天就把这事聊透。
4.1 三种隔离策略:选对路子,少走弯路
租户隔离,本质上就三个流派。我习惯把它们比作租房:
- 数据库级隔离:每个租户独享一栋楼(独立数据库)
- Schema级隔离:每个租户一个独立楼层(同一数据库,不同Schema)
- 表级隔离:大家住大通铺,靠身份证号(租户ID)区分
你想想看,这三种方案没有绝对的好坏,关键看你的业务场景。
4.1.1 数据库级隔离
每个租户一个独立数据库实例。这是最彻底的隔离方式。
适用场景:金融、医疗等强合规行业,或者大客户要求数据物理隔离。
我在项目中遇到过一家做HR SaaS的公司,他们的头部客户直接要求「数据不能和任何其他客户放在同一个MySQL实例里」。没得选,只能上数据库级隔离。
优点:
- 数据完全隔离,一个租户出问题不影响别人
- 备份恢复简单,按库操作就行
- 可以针对大客户单独做性能优化
缺点:
- 成本高,每个库都要消耗连接数、内存
- 运维复杂,几百个库的监控、备份够你喝一壶
- 跨租户查询基本不可能
4.1.2 Schema级隔离
同一个数据库实例,每个租户一个Schema(在MySQL里就是Database)。
我个人比较推荐这个方案,尤其是中小型SaaS。它平衡了隔离性和成本。
-- 租户A的数据
CREATE DATABASE tenant_001;
USE tenant_001;
CREATE TABLE orders (...);
-- 租户B的数据
CREATE DATABASE tenant_002;
USE tenant_002;
CREATE TABLE orders (...);
优点:
- 比数据库级省钱,共享一个实例
- 隔离性还不错,表结构可以不同
- 迁移方便,把整个Schema导出就行
缺点:
- 单个实例有上限(MySQL一般建议不超过1000个库)
- 一个租户的慢查询可能拖累整个实例
避坑指南:我曾经遇到一个客户,所有租户共用一个数据库实例,结果某个大租户跑了个全表扫描,整个实例CPU飙到100%,所有租户都挂了。后来我们加了实例级别的资源隔离,才解决这个问题。
4.1.3 表级隔离
所有租户共用同一套表,通过tenant_id字段区分数据。
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
tenant_id VARCHAR(32) NOT NULL,
order_no VARCHAR(64),
amount DECIMAL(10,2),
created_at DATETIME,
INDEX idx_tenant_id (tenant_id)
);
这是最经济的方案,但也是最容易出问题的。
优点:
- 成本最低,一套表搞定
- 跨租户统计分析方便
- 运维简单,就一个库
缺点:
- 隔离性最差,代码里漏掉
where tenant_id=?就完蛋 - 数据量大了以后,索引和查询性能是噩梦
- 一个租户的数据损坏,可能影响所有人
我的建议:如果选表级隔离,一定要在ORM层做强制拦截。比如MyBatis的拦截器,自动给所有查询加上tenant_id条件。别指望每个开发人员都记得写。
4.2 租户路由:请求怎么找到自己的家
隔离策略定好了,接下来就是路由问题。用户发来一个请求,系统怎么知道他是哪个租户的?
常见的路由方式有三种:
| 路由方式 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 域名路由 | tenant-a.your-saas.com | 最直观,用户无感知 | 需要通配符域名和证书 |
| 请求头路由 | Header: X-Tenant-ID: abc123 | 灵活,适合API调用 | 前端需要维护租户上下文 |
| 路径路由 | /abc123/api/orders | 实现简单 | URL不够优雅 |
我个人习惯用域名路由。为什么?因为用户访问acme.myapp.com,他根本不知道背后还有别的租户。体验最好。
// 一个简单的租户路由中间件(Node.js示例)
app.use(async (req, res, next) => {
// 从域名提取租户标识
const host = req.hostname;
const tenantCode = host.split('.')[0];
// 查询租户元数据
const tenant = await TenantService.getByCode(tenantCode);
if (!tenant) {
return res.status(404).send('租户不存在');
}
// 绑定到请求上下文
req.tenant = tenant;
req.tenantId = tenant.id;
// 动态切换数据源
await DataSourceSwitcher.switchTo(tenant.databaseName);
next();
});
注意:路由信息一定要缓存。我曾经见过一个系统,每次请求都去查数据库获取租户信息,QPS一上来直接打挂了。用Redis缓存租户路由表,TTL设个5分钟,够用了。
4.3 租户元数据管理:系统的「户口本」
租户元数据,就是记录每个租户「是谁、用什么数据库、什么配置」的那张表。这张表要是设计不好,后面运维能累死你。
我一般这样设计:
CREATE TABLE tenants (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_code VARCHAR(32) NOT NULL UNIQUE COMMENT '租户编码,用于路由',
tenant_name VARCHAR(128) NOT NULL COMMENT '租户名称',
status TINYINT DEFAULT 1 COMMENT '1:启用 0:禁用',
-- 隔离策略相关
isolation_level ENUM('database', 'schema', 'table') NOT NULL,
database_name VARCHAR(64) COMMENT '数据库名或Schema名',
db_host VARCHAR(128) COMMENT '独立数据库时使用',
db_port INT DEFAULT 3306,
db_user VARCHAR(64),
db_password VARCHAR(256), -- 建议加密存储
-- 配置信息
plan_type VARCHAR(32) COMMENT '套餐类型',
max_users INT DEFAULT 10 COMMENT '最大用户数',
storage_quota BIGINT DEFAULT 1073741824 COMMENT '存储配额,默认1GB',
-- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这张表是整个多租户架构的「大脑」。每次请求进来,先查这张表,才知道往哪路由。
核心要点:
- tenant_code要唯一,且不可变。一旦变了,所有域名、缓存都得更新
- 数据库连接信息建议加密存储,别明文存密码
- 加一个status字段,可以快速禁用某个租户
4.4 混合策略:现实世界没有银弹
说了这么多,其实真实项目里很少只用一种策略。我见过最典型的做法是:
- 免费版/小客户:表级隔离,降低成本
- 标准版/中型客户:Schema级隔离,性价比高
- 企业版/大客户:数据库级隔离,满足合规和性能要求
这种混合模式,需要在租户元数据表里记录每个租户的隔离级别和连接信息。路由层根据这些信息动态切换数据源。
// 动态数据源切换的核心逻辑
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
TenantContext tenant = TenantContextHolder.get();
if (tenant == null) {
return "default";
}
// 根据隔离级别返回不同的key
switch (tenant.getIsolationLevel()) {
case "database":
return "db_" + tenant.getDatabaseName();
case "schema":
return "schema_" + tenant.getTenantCode();
default:
return "shared"; // 表级隔离用共享数据源
}
}
}
避坑指南:我曾经在一个项目里,把所有租户的连接信息都放在一个Map里。结果租户一多,Map越来越大,GC压力剧增。后来改用连接池分组管理,每个租户一个连接池子,才解决问题。记住,连接池不是越多越好,但也不是越少越好。
4.5 总结一下
多租户架构设计,说白了就是三个选择:
- 怎么隔离:数据库、Schema、表,或者混着来
- 怎么路由:域名、请求头、路径,选个顺手的方式
- 怎么管理:一张元数据表,管好所有租户的「户口信息」
没有完美的方案,只有适合你业务的方案。我个人的建议是:
- 起步阶段用Schema级隔离,成本可控,扩展方便
- 等有了大客户,再单独给他们开独立数据库
- 元数据表设计时留好扩展字段,后面加配置不用改表结构
嗯,多租户这事,想清楚再动手。一旦选错了隔离策略,后面数据迁移的成本,够你哭半年的。