3. SaaS多租户模型:单租户与多租户架构对比、数据隔离策略、租户上下文概念
在构建SaaS鉴权链路时,多租户(Multi-Tenancy)是区别于传统单体应用的核心特征。租户(Tenant)代表一个独立的客户组织(如一家企业、一个部门),其内部用户共享该组织的资源与权限策略。本章将深入剖析单租户与多租户的架构差异、三种主流数据隔离策略,以及贯穿鉴权全流程的租户上下文概念。
3.1 单租户 vs 多租户:架构思维转变
传统企业软件通常采用单租户架构:每个客户部署一套独立的应用实例与数据库。SaaS平台则要求多租户架构:一套代码、一套基础设施,同时服务成百上千个客户。
| 对比维度 | 单租户架构 | 多租户架构 |
|---|---|---|
| 部署方式 | 每客户独立部署 | 共享部署,逻辑隔离 |
| 资源利用率 | 低,存在大量空闲资源 | 高,弹性伸缩共享 |
| 运维成本 | 高,版本碎片化严重 | 低,统一升级与监控 |
| 数据隔离强度 | 物理隔离,安全性最高 | 取决于隔离策略,可灵活配置 |
| 鉴权复杂度 | 低,无需感知租户 | 高,JWT中必须携带租户标识 |
| 定制化能力 | 强,可独立修改代码 | 弱,通过配置/扩展点实现 |
| 适用场景 | 大型企业、强合规行业 | 中小客户、标准化SaaS产品 |
架构选择提示: 对于初创SaaS,建议从多租户起步,利用共享成本快速验证市场。当遇到超大型客户要求物理隔离时,可提供“专属集群”作为高级套餐,而非整体回退到单租户。
3.2 三大数据隔离策略:Database / Schema / Table
数据隔离是多租户架构设计的核心决策点,直接影响鉴权查询的性能与复杂度。业界主流有三种策略,按隔离强度从高到低排列:
3.2.1 策略一:独立数据库(Database per Tenant)
- 隔离级别: 最高。每个租户拥有独立的数据库实例或逻辑库。
- 鉴权影响: JWT解析出租户ID后,动态切换数据源连接。鉴权查询完全隔离,无跨租户数据泄露风险。
- 优点: 天然物理隔离,备份恢复独立,适合金融、医疗等强合规场景。
- 缺点: 连接数随租户增长线性增加,维护成本高,跨租户统计困难。
3.2.2 策略二:共享数据库、独立Schema(Schema per Tenant)
- 隔离级别: 中等。同一数据库实例内,每个租户拥有独立的Schema(命名空间)。
- 鉴权影响: 通过租户ID映射到对应Schema,鉴权SQL中指定Schema前缀(如
tenant_123.users)。 - 优点: 兼顾隔离与资源利用率,支持租户级DDL变更。
- 缺点: 数据库实例故障影响所有租户;Schema数量过多时管理复杂。
3.2.3 策略三:共享数据库、共享Schema、通过租户ID列隔离(Table per Tenant via Tenant ID)
- 隔离级别: 最低。所有租户数据存储在相同表中,通过
tenant_id列区分。 - 鉴权影响: 每条鉴权SQL的WHERE条件必须强制追加
tenant_id = ?,否则将导致严重的数据泄露。 - 优点: 资源利用率最高,部署最简单,适合海量小租户场景。
- 缺点: 隔离依赖应用层逻辑,一旦漏加条件后果严重;数据恢复需全表扫描。
⚠️ 安全红线: 采用共享表策略时,必须在ORM层或数据库中间件层实现“租户上下文自动注入”,禁止开发人员手动拼接
tenant_id 条件。任何遗漏都等于将全平台数据暴露给所有租户。
3.3 租户上下文:鉴权链路的“隐形护照”
租户上下文(Tenant Context) 是一个贯穿请求全生命周期的数据结构,用于携带当前请求所属的租户信息。在JWT鉴权链路中,租户上下文通常在网关或认证过滤器中被解析并注入到线程局部变量(ThreadLocal)或请求属性中。
租户上下文的核心字段:
tenantId:租户唯一标识,JWT中必须包含。tenantName:租户名称,用于日志与审计。tenantTier:租户套餐等级(如免费版/专业版/企业版),影响功能权限。region:租户所属区域,用于数据合规路由。features:租户已开通的功能特性列表,用于特性开关判断。
代码示例:租户上下文在JWT过滤器中的注入
// 1. 从JWT中提取租户信息
String tenantId = claims.get("tenant_id", String.class);
String tenantTier = claims.get("tenant_tier", String.class);
// 2. 构建租户上下文对象
TenantContext context = TenantContext.builder()
.tenantId(tenantId)
.tenantTier(tenantTier)
.build();
// 3. 注入到ThreadLocal(请求结束后必须清理)
TenantContextHolder.set(context);
// 4. 在数据访问层自动使用
// 例如MyBatis拦截器自动追加 tenant_id = #{context.tenantId}
3.4 数据隔离策略对比总览
| 策略 | 隔离强度 | 资源利用率 | 鉴权复杂度 | 运维成本 | 推荐场景 |
|---|---|---|---|---|---|
| 独立Database | ★★★★★ | ★☆☆☆☆ | ★★☆☆☆ | ★★★★★ | 金融、医疗、大型企业 |
| 独立Schema | ★★★★☆ | ★★★☆☆ | ★★★☆☆ | ★★★☆☆ | 中型SaaS、多租户PaaS |
| 共享表+租户ID | ★★☆☆☆ | ★★★★★ | ★★★★★ | ★☆☆☆☆ | 海量小租户、标准化SaaS |
3.5 租户上下文在鉴权链路中的流转
下图展示了从JWT解析到数据隔离执行的完整租户上下文传递路径:
最佳实践: 租户上下文必须在请求结束时显式清理(如使用Filter的finally块或Spring的RequestContextListener),防止ThreadLocal复用导致租户信息串扰。建议使用
try-finally 或装饰器模式确保清理。
3.6 选择数据隔离策略的决策树
在实际项目中,选择哪种策略取决于租户规模、合规要求与团队运维能力。以下决策树可辅助判断:
- 客户是否要求物理隔离?(如金融、医疗合规)→ 选择 独立Database。
- 租户数量是否超过1000? → 是,选择 共享表+租户ID;否,进入下一步。
- 是否需要租户级DDL定制?(如每个租户自定义字段)→ 选择 独立Schema。
- 默认推荐: 对于大多数标准化SaaS,共享表+租户ID 配合ORM层自动注入是最经济的选择。
关键结论: 无论选择哪种隔离策略,租户上下文都是JWT鉴权链路中不可或缺的中间件。它连接了“你是谁”(认证)与“你能看到什么数据”(数据隔离),是SaaS权限验证的基石。在后续章节中,我们将基于共享表+租户ID策略,构建完整的JWT鉴权与权限验证系统。