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 带租户条件」从人工纪律变成机制保障。三者配合,才能支撑「一份代码、万租户安全隔离」的规模化交付。
延伸阅读
- ORM 框架:JPA/Hibernate 与 MyBatis — 多租户拦截器与连接提供者基础
- 数据库连接池、读写分离与分库分表 — 租户路由与连接池复用的扩展
- Java 缓存策略与 Redis 集成 — 租户级缓存键隔离设计
- Java 安全认证与授权体系 — 从令牌解析租户身份的实践
- Java 高并发与并发工具包 — ThreadLocal 与线程池传递机制
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。