多租户 SaaS 架构:隔离模型与租户上下文传递

深入多租户 SaaS 架构设计,对比共享库、共享 schema 与独立 schema 三种隔离模型,详解租户上下文传递、数据隔离拦截器、租户路由与数据迁移的完整实现

SaaS 系统的第一性约束是「一份代码服务所有客户」,同时每个客户的数据必须严格隔离。多租户架构正是围绕这个矛盾展开的:如何在共享资源与隔离安全之间取平衡。本文从隔离模型选型、租户上下文传递、数据隔离拦截器到租户路由与迁移,给出可落地的完整方案。

前置基础可先阅读 ORM 框架:JPA/Hibernate 与 MyBatis 与 数据库连接池、读写分离与分库分表。

1. 多租户与隔离模型

1.1 三种隔离模型对比

维度独立数据库独立 Schema共享表
数据隔离物理隔离逻辑隔离行级隔离
单租户容量可弹性扩展受实例限制受共享资源限制
恢复难度单独备份恢复按 schema 恢复全库恢复
租户数量适合少而大适合中等规模适合海量小租户
运维成本高中低
新增租户建库+迁移建 schema+迁移仅插入配置

1.2 隔离模型选择矩阵

租户数量 × 单租户数据量 → 隔离级别:
  租户少 + 数据量大  → 独立数据库(金融、政企大客户)
  租户中等 + 数据中  → 独立 Schema(中型 SaaS)
  租户海量 + 数据小  → 共享表 + tenant_id(C 端 SaaS)
  混合              → 独立库给 VIP,共享表给长尾

1.3 混合租户模型

大型 SaaS 通常采用混合策略:把关键大客户放在独立库,把海量小微客户放在共享表,通过租户路由表把请求映射到正确的数据源。路由信息必须可配置、可热更新。

2. 租户上下文

2.1 TenantContext 与 ThreadLocal

租户上下文是整个多租户体系的中枢,几乎所有拦截器都要读取它:

public final class TenantContext {

    private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();

    private TenantContext() {}

    public static void setTenantId(String tenantId) {
        CURRENT.set(tenantId);
    }

    public static String getTenantId() {
        return CURRENT.get();
    }

    public static void clear() {
        CURRENT.remove();
    }
}

2.2 请求头传递与过滤器

网关在鉴权后把租户 ID 注入请求头,服务端用过滤器统一解析并写入上下文:

@Component
public class TenantFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String tenantId = request.getHeader("X-Tenant-Id");
        if (!StringUtils.hasText(tenantId)) {
            tenantId = resolveTenantFromToken(request);   // 从 JWT 或子域名解析
        }
        TenantContext.setTenantId(tenantId);
        try {
            chain.doFilter(request, response);
        } finally {
            TenantContext.clear();                        // 防止线程池串租户
        }
    }
}

2.3 异步线程上下文传递

ThreadLocal 在异步线程中天然丢失,必须显式传递。常规做法是用装饰器把上下文透传:

@Component
public class TenantTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        String tenantId = TenantContext.getTenantId();
        return () -> {
            TenantContext.setTenantId(tenantId);
            try {
                runnable.run();
            } finally {
                TenantContext.clear();
            }
        };
    }
}

// 配置异步线程池使用装饰器
@Bean
public ThreadPoolTaskExecutor taskExecutor(TenantTaskDecorator decorator) {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setTaskDecorator(decorator);
    return executor;
}

响应式场景(WebFlux)则改用 reactor.util.context.Context,本文聚焦阻塞模型。

3. 数据隔离实现

3.1 独立 Schema 与独立库路由

独立 Schema 模型下,租户 ID 决定连接指向哪个 schema 或数据源。JDBC URL 可以直接带 schema,也可用连接参数切换:

// 独立库路由:按租户选择数据源
public DataSource resolveDataSource(String tenantId) {
    if (vipTenants.contains(tenantId)) {
        return vipDataSourceMap.get(tenantId);   // VIP 独立库
    }
    return sharedDataSource;                     // 长尾共享库
}

3.2 共享表 tenant_id 列

共享表模型必须为每张业务表增加 tenant_id 列,所有 SQL 都要带租户条件。手工加条件极易遗漏,必须依赖框架级拦截器。

3.3 Hibernate 多租户

Hibernate 原生支持多租户:CurrentTenantIdentifierResolver 决定当前租户,MultiTenantConnectionProvider 决定连接:

public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver<String> {
    @Override
    public String resolveCurrentTenantIdentifier() {
        return TenantContext.getTenantId();
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}

public class SchemaConnectionProvider implements MultiTenantConnectionProvider {
    // 每次 getConnection 时根据租户切换 schema
    @Override
    public Connection getConnection(String tenantIdentifier) throws SQLException {
        Connection conn = sharedDataSource.getConnection();
        try (Statement st = conn.createStatement()) {
            st.execute("USE `schema_" + tenantIdentifier + "`");
        }
        return conn;
    }
}

4. 拦截器与 AOP 注入

4.1 MyBatis 拦截器注入租户条件

MyBatis 官方多租户插件通过拦截 SQL 自动追加 tenant_id 条件,业务代码无需感知:

@Intercepts({
    @Signature(type = StatementHandler.class,
              method = "prepare",
              args = {Connection.class, Integer.class})
})
public class TenantLineInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = handler.getBoundSql();
        String sql = boundSql.getSql();
        String tenantId = TenantContext.getTenantId();
        if (isTenantTable(boundSql) && !sql.contains("tenant_id")) {
            // 重写 SQL:插入 tenant_id 过滤条件
            String rewritten = rewriteSql(sql, tenantId);
            ReflectUtil.setField(boundSql, "sql", rewritten);
        }
        return invocation.proceed();
    }
}

4.2 租户条件注入规则

SELECT  → 追加 WHERE tenant_id = ?
UPDATE  → 追加 WHERE tenant_id = ?
DELETE  → 追加 WHERE tenant_id = ?
INSERT  → 自动填充 tenant_id 列
JOIN 子查询 → 需要表别名识别,复杂查询可能需人工标注

4.3 排除全局表

租户配置表、系统字典、全局序列等不应加租户条件。拦截器需要一张白名单,按表名或注解排除:

@TableName(value = "tenant_config", ignoreTenant = true)
public class TenantConfig { }

5. 租户路由

5.1 路由解析来源

来源示例适用场景
子域名acme.plume.com品牌化 SaaS
路径前缀/tenant/acme/api单域名多租户
请求头X-Tenant-Id服务间调用
JWT 声明token 内 tenantId鉴权后直接取

5.2 连接池管理

独立库与独立 schema 场景下,每个租户的连接池或 schema 连接要按租户复用:

@Component
public class TenantDataSourceRouter {

    private final Map<String, DataSource> tenantDataSources = new ConcurrentHashMap<>();

    public DataSource getDataSource(String tenantId) {
        return tenantDataSources.computeIfAbsent(tenantId, this::createPooledSource);
    }

    private DataSource createPooledSource(String tenantId) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://db-shard-" + shardOf(tenantId) + "/schema_" + tenantId);
        config.setMaximumPoolSize(8);
        return new HikariDataSource(config);
    }
}

5.3 缓存键隔离

缓存是租户隔离最容易遗漏的角落。Redis 键必须带租户前缀,防止跨租户数据穿透:

public String tenantKey(String tenantId, String rawKey) {
    return "t:" + tenantId + ":" + rawKey;
}

6. 数据迁移与租户生命周期

6.1 租户开通与停用

租户开通要按模型执行对应动作:独立库场景建库建 schema 并执行初始化迁移;共享表场景仅插入租户配置并建立默认数据。停用时先做只读冻结,再按保留策略归档。

6.2 Schema 迁移工具

独立 schema 模型的迁移依赖 Flyway/Liquibase 的多租户支持,把迁移脚本对每个租户 schema 依次执行:

// Flyway 对每个租户 schema 执行迁移
public void migrateTenant(String tenantId) {
    Flyway flyway = Flyway.configure()
            .dataSource(dataSource)
            .schemas("schema_" + tenantId)
            .locations("classpath:db/migration")
            .load();
    flyway.migrate();
}

6.3 从共享库迁移到独立 schema

租户成长后需要从共享表迁移到独立 schema,本质是一次有损的风险操作,需要停机或双写窗口:

1. 目标 schema 建表并预迁移
2. 业务低峰停止该租户写入
3. 全量拷贝数据并校验行数
4. 切换路由,灰度放量
5. 保留原共享表数据一段时间用于回滚

7. 生产挑战

7.1 租户泄漏与防护

泄漏场景原因防护
线程复用串租户ThreadLocal 未清理finally 中 clear + 装饰器
异步丢失上下文线程池未传递TaskDecorator 透传
查询遗漏条件手写 SQL 绕过拦截器审计 SQL + 强制走 DAO
缓存串租户键不含租户租户前缀规范化

7.2 监控与容量规划

关键指标:各租户 QPS、活跃连接数、最大 schema 大小、慢查询占比。容量规划按「最大租户」而非「平均租户」设计,避免单租户拖垮共享资源。

7.3 与缓存和消息队列的隔离

消息队列同样要带租户标识,消费者按租户路由到对应处理逻辑,事件消息体中必须包含 tenantId 字段。跨租户的任何共享状态都要以租户为粒度做分区或加前缀。

8. 总结

主题核心要点
隔离模型独立库强隔离、共享表低成本、混合模型取平衡
租户上下文ThreadLocal 承载,请求头传递,异步需装饰器透传
数据隔离Hibernate 多租户或 MyBatis 拦截器自动注入
路由与连接子域名/头/JWT 解析,按租户复用连接池
缓存与消息键带租户前缀,事件带 tenantId
迁移开通建 schema、升级跑迁移、成长迁独立库

多租户是 SaaS 的骨架工程。隔离模型的选型决定成本与安全的取舍,租户上下文的正确传递决定系统的正确性,而框架级拦截器把「每 SQL 带租户条件」从人工纪律变成机制保障。三者配合,才能支撑「一份代码、万租户安全隔离」的规模化交付。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java-enterprise」更多文章

  1. JPMS 模块化:module-info 与 JLink 精简运行时
  2. CDC 数据同步:Debezium 与 Kafka 架构实战
  3. 可观测性工程:Micrometer 指标模型与 OTLP 导出