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 数据隔离架构决策树

下图展示了从业务需求出发,选择数据隔离方案的核心决策路径:

数据隔离方案决策树 业务需求评估 强合规/大客户专属 成本敏感/租户量大 独立数据库 共享数据库 独立 Schema 共享表 + tenant_id 决策要点: • 独立数据库:适合 SLA 要求 99.99% 以上、数据必须物理隔离的场景。 • 共享表 + tenant_id:适合快速迭代、租户数万级、单租户数据量 < 100GB 的场景。

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 租户上下文传递流程

下图展示了从客户端请求到数据库查询的完整租户上下文传递链路:

租户上下文传递流程 客户端请求 API 网关 鉴权服务 业务服务 数据库(WHERE tenant_id=?) 上下文传递关键点: 1. 网关从 JWT Token 或 API Key 中解析出 tenantId,写入请求头 X-Tenant-Id。 2. 鉴权服务验证租户状态(是否过期、是否被封禁),并将 TenantContext 传递至下游。 3. 业务服务通过 MyBatis / Hibernate 拦截器自动拼接 tenant_id 过滤条件。

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)管理数据库实例的生命周期。