2. 多租户模型深度解析:数据隔离模式与租户上下文传递机制
多租户(Multi-Tenancy)是 SaaS 架构的核心特征,其本质是在一份运行实例上服务多个租户,同时保证各租户数据的安全隔离与独立访问。数据隔离模式的选择直接决定了鉴权拦截链路的复杂度、数据库连接池的分配策略以及中间件的性能瓶颈位置。本节将深入剖析三种主流数据隔离模式,并详解租户上下文在请求链路中的传递机制。
2.1 三大数据隔离模式对比
根据数据隔离的粒度与资源复用程度,业界将多租户数据模型分为三种模式:Database-per-Tenant、Schema-per-Tenant 和 Shared Table。下表从隔离性、性能、运维复杂度三个维度进行对比。
| 特性维度 | Database-per-Tenant | Schema-per-Tenant | Shared Table |
|---|---|---|---|
| 隔离粒度 | 物理数据库级 | 逻辑命名空间级 | 行级(tenant_id 字段) |
| 数据安全性 | 最高(独立备份/恢复) | 高(Schema 间天然隔离) | 中等(依赖应用层过滤) |
| 连接池开销 | 高(每租户独立连接池) | 中(共享连接池,切换 Schema) | 低(单连接池,行级过滤) |
| 跨租户查询 | 困难(跨库查询) | 较困难(跨 Schema 查询) | 容易(SQL 中加 tenant_id 条件) |
| 运维复杂度 | 高(数据库实例管理) | 中(Schema 创建/迁移) | 低(单库单表运维) |
| 适用场景 | 金融、医疗等强合规行业 | 中型 SaaS,租户数 100~1000 | 超大规模 SaaS,租户数 10万+ |
性能调优启示: 在鉴权拦截链路中,Database-per-Tenant 模式需要在中间件层维护租户→数据源的映射缓存,连接池数量随租户数线性增长,容易导致线程阻塞和连接泄漏。Shared Table 模式虽然连接池开销最低,但所有 SQL 都必须强制携带
tenant_id 条件,否则将造成严重的数据泄露事故。
2.2 数据隔离模式架构图
下图展示了三种模式在应用层与数据层之间的映射关系,以及鉴权拦截器如何根据租户标识选择对应的数据源或 Schema。
2.3 租户上下文传递机制
在鉴权拦截链路中,租户上下文(TenantContext)的传递是连接请求入口与数据访问层的桥梁。一个高效的传递机制需要满足:线程安全、低开销、无侵入。以下是业界主流的传递方案对比。
| 传递方式 | 实现原理 | 性能开销 | 适用场景 | 注意事项 |
|---|---|---|---|---|
| ThreadLocal | 将租户 ID 绑定到当前线程 | 极低(纳秒级) | 同步请求处理(Tomcat 线程模型) | 必须在使用后清除,防止内存泄漏 |
| 请求头传递 | HTTP Header 携带 X-Tenant-ID | 低(微秒级) | 网关层、REST API 入口 | 需校验 Header 合法性,防止伪造 |
| JWT Token 内嵌 | 将租户信息编码在 Token 的 claims 中 | 中(需解码验证) | 分布式系统、微服务间调用 | Token 过期策略需与租户状态联动 |
| MDC(Mapped Diagnostic Context) | 日志框架的上下文传递 | 低 | 日志链路追踪 + 租户标识 | 仅用于日志,不用于业务逻辑 |
2.4 租户上下文传递流程图
下图展示了一个典型请求从网关到数据层的完整租户上下文传递路径,重点标注了鉴权拦截器在链路中的关键作用。
2.5 租户上下文传递代码示例
以下是一个基于 ThreadLocal + Filter 的租户上下文传递实现,展示了从请求解析到数据源切换的完整链路。
// TenantContext.java - 租户上下文持有者
public class TenantContext {
private static final ThreadLocal<String> TENANT_ID_HOLDER = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
TENANT_ID_HOLDER.set(tenantId);
}
public static String getTenantId() {
return TENANT_ID_HOLDER.get();
}
public static void clear() {
TENANT_ID_HOLDER.remove();
}
}
// TenantFilter.java - 鉴权拦截过滤器
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String tenantId = httpRequest.getHeader("X-Tenant-ID");
// 从 JWT Token 中提取(如果 Header 为空)
if (tenantId == null || tenantId.isEmpty()) {
String token = httpRequest.getHeader("Authorization");
tenantId = JwtUtils.extractTenantId(token);
}
try {
TenantContext.setTenantId(tenantId);
// 动态数据源路由
DataSourceContextHolder.setDataSource(tenantId);
chain.doFilter(request, response);
} finally {
TenantContext.clear();
DataSourceContextHolder.clear();
}
}
}
// DynamicDataSource.java - 动态数据源路由
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContext.getTenantId();
// 根据租户 ID 返回对应的数据源 Key
return tenantId;
}
}
⚠️ 性能陷阱: 在异步场景(如 @Async、CompletableFuture、消息队列)中,ThreadLocal 无法自动传递到子线程。必须使用 InheritableThreadLocal 或手动传递 TenantContext 对象,否则会导致租户上下文丢失,造成数据泄露或路由错误。建议在异步任务提交时显式捕获当前租户 ID,并在任务执行开始时重新设置。
2.6 模式选择与性能权衡
在实际的 SaaS 架构中,数据隔离模式的选择需要结合租户规模、合规要求和运维能力综合决策。以下是一份决策参考清单:
- 租户数 < 100:优先选择 Database-per-Tenant,隔离性最强,运维成本可控。
- 租户数 100 ~ 1000:推荐 Schema-per-Tenant,在隔离性与资源利用率之间取得平衡。
- 租户数 > 10000:必须采用 Shared Table,但需配合 强制 tenant_id 索引 和 行级安全策略(RLS)。
- 混合模式:大型 SaaS 通常采用 分层隔离——核心租户独享数据库,普通租户共享 Schema 或表。
💡 调优建议: 在鉴权拦截链路中,建议将租户上下文的解析与设置放在 Filter 链的最前端,并配合 缓存(如 Caffeine)存储租户与数据源的映射关系,避免每次请求都查询数据库。缓存过期时间建议设置为 5~10 分钟,并支持主动失效(租户配置变更时触发)。