2. 多租户模型设计:数据隔离方案与租户上下文
多租户(Multi-Tenancy)是 SaaS 架构的核心特征。本章聚焦于数据隔离方案的选择与租户上下文(Tenant Context)的设计,这是构建鉴权拦截链路的基础。错误的隔离策略会导致数据泄露或性能瓶颈,而租户上下文的缺失则会使后续的鉴权、路由、计费逻辑难以扩展。
2.1 数据隔离方案对比:共享数据库 vs 独立数据库
数据隔离方案决定了租户数据在存储层的物理或逻辑分离程度。主流方案有三种,其中 共享数据库 与 独立数据库 是两种极端,共享表 + 租户 ID 列 则是折中方案。下表从关键维度进行对比:
| 维度 | 共享数据库(共享表) | 共享数据库(独立 Schema) | 独立数据库 |
|---|---|---|---|
| 隔离级别 | 逻辑隔离(通过 tenant_id 列) | 逻辑隔离(通过 Schema 命名空间) | 物理隔离(独立实例/库) |
| 资源成本 | 最低(共享连接池、缓存) | 中等(共享实例,Schema 分离) | 最高(每租户独立资源) |
| 扩展性 | 受限于单库容量,需分库分表 | 受限于实例,Schema 数有限 | 水平扩展能力强,可独立扩缩 |
| 运维复杂度 | 低(统一备份、迁移) | 中(Schema 级 DDL 管理) | 高(备份、监控、升级需逐租户) |
| 数据恢复 | 全量恢复,影响所有租户 | Schema 级恢复,影响单个租户 | 独立恢复,影响最小 |
| 适用场景 | 中小型 SaaS,租户量级大但单租户数据量小 | 中型 SaaS,需要一定隔离但不想增加实例 | 金融、医疗等强合规场景,或大客户专属部署 |
架构决策建议: 在 CI/CD 流水线实战中,我们默认采用 共享数据库 + tenant_id 列 方案。该方案对流水线改动最小,只需在 SQL 查询中强制注入租户过滤条件,且便于在测试环境中快速创建租户模拟数据。若后续客户提出独立部署需求,可通过多数据源路由切换至独立数据库模式。
2.2 数据隔离架构决策树
下图展示了从业务需求出发,选择数据隔离方案的核心决策路径:
2.3 租户上下文(Tenant Context)设计
租户上下文是贯穿整个请求生命周期的租户身份标识。在微服务架构中,它通常以 请求头(Header) 或 线程局部变量(ThreadLocal) 的形式传递。设计良好的租户上下文应包含以下核心字段:
- tenantId:租户唯一标识(UUID 或业务编码),用于数据隔离过滤。
- tenantName:租户名称,用于日志和审计。
- tenantTier:租户等级(如 free / pro / enterprise),用于限流和功能开关。
- region:租户所属区域(如 cn-east-1),用于多区域路由。
- features:租户已开通的功能列表(如 ["audit", "export"]),用于特性鉴权。
CI/CD 流水线中的上下文注入点: 在网关层(如 Spring Cloud Gateway / Kong)完成租户解析后,将 TenantContext 对象注入到请求头中。下游微服务通过过滤器(Filter)或拦截器(Interceptor)从请求头中提取并绑定到当前线程。流水线中应包含 TenantContext 传递测试 用例,确保异步线程(如 @Async)不会丢失上下文。
2.4 租户上下文传递流程
下图展示了从客户端请求到数据库查询的完整租户上下文传递链路:
2.5 代码实现:租户上下文与数据隔离拦截器
以下是一个基于 Spring Boot 的租户上下文实现示例,包含上下文持有者、过滤器以及 MyBatis 拦截器:
// TenantContext.java - 租户上下文持有者
public class TenantContext {
private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();
private String tenantId;
private String tenantTier;
private List<String> features;
public static void set(TenantContext context) {
CONTEXT.set(context);
}
public static TenantContext get() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
// getters and setters
}
// TenantFilter.java - 从请求头解析租户上下文
@Component
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String tenantId = httpRequest.getHeader("X-Tenant-Id");
if (tenantId != null) {
TenantContext context = new TenantContext();
context.setTenantId(tenantId);
// 可继续解析 tenantTier、features 等
TenantContext.set(context);
}
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear(); // 防止内存泄漏
}
}
}
// TenantInterceptor.java - MyBatis 拦截器自动追加 tenant_id 条件
@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 {
String tenantId = TenantContext.get().getTenantId();
// 通过反射修改 SQL,追加 "AND tenant_id = #{tenantId}"
// 具体实现可使用 JSqlParser 或 MyBatis 的 BoundSql 拦截
return invocation.proceed();
}
}
注意: 在 CI/CD 流水线中,必须为 TenantContext 编写单元测试和集成测试。测试用例应覆盖:正常传递、缺失租户 ID 时的降级处理、异步线程上下文丢失场景。建议在流水线的 测试阶段 加入
TenantContextPropagationTest,确保每次代码提交都不会破坏上下文传递链路。
2.6 数据隔离方案对 CI/CD 流水线的影响
不同的数据隔离方案会直接影响流水线的构建策略、测试数据管理和部署方式:
- 共享数据库方案: 流水线中只需维护 一套 数据库迁移脚本(Flyway / Liquibase)。测试环境可复用同一数据库实例,通过不同的 tenant_id 隔离测试数据。CI 阶段可并行执行租户级测试,互不干扰。
- 独立 Schema 方案: 流水线需支持 Schema 级别的 DDL 管理。每个租户的 Schema 版本可能不同,建议在部署脚本中引入 租户版本号,按需执行增量迁移。
- 独立数据库方案: 流水线需集成 多数据源路由 能力。部署阶段需为每个租户创建独立的数据库实例,并更新配置中心的路由规则。建议使用 Infrastructure as Code(Terraform / Pulumi)管理数据库实例的生命周期。