多租户架构深度:行级隔离、Schema隔离、数据库隔离的工程权衡
多租户架构深度:行级隔离、Schema隔离、数据库隔离的工程权衡
适读人群:Java架构师、SaaS平台工程师 | 阅读时长:约18分钟 | 技术栈:Spring Boot 3.x、MyBatis-Plus、PostgreSQL、Hibernate
开篇故事
做SaaS的朋友应该都经历过多租户方案选型的纠结。我做过三个不同规模的SaaS项目,三个项目用了三种不同的多租户方案,各有各的坑。
第一个项目用行级隔离(所有租户共享表,靠tenant_id字段区分),够简单,但扩展到几十个租户后,大租户的数据量影响了所有小租户的查询性能,还有一次因为一个SQL忘记加tenant_id过滤条件,导致A租户看到了B租户的数据,险些出大事故。
第二个项目用Schema隔离,每个租户独立Schema。设计上更安全,但到了租户300+的时候,Schema数量管理起来头疼,数据库连接池配置也很麻烦。
第三个项目是混合方案——按租户价值分层:免费租户行级隔离,付费租户Schema隔离,大客户独立数据库实例。这个方案整体上最合理,但实现复杂度也最高。
今天把这三种方案的详细比较和实现写出来。
一、核心问题:多租户的四个维度
选择多租户方案,需要在四个维度上做权衡:
二、三种方案的原理与对比
2.1 方案一:行级隔离(Shared Database, Shared Schema)
优点:实现简单、资源利用率最高、运维成本低 缺点:数据泄漏风险最高、大租户影响小租户、难以实现差异化备份
2.2 方案二:Schema隔离(Shared Database, Separate Schema)
优点:天然隔离、数据库层面保证安全、可以差异化备份 缺点:Schema数量受限(PostgreSQL推荐不超过1000个)、连接池复杂
2.3 方案三:独立数据库
优点:最强隔离、可以完全差异化配置 缺点:成本最高、运维最复杂
2.4 综合对比
| 维度 | 行级隔离 | Schema隔离 | 独立数据库 |
|---|---|---|---|
| 实现复杂度 | 低 | 中 | 高 |
| 数据隔离性 | 低 | 高 | 最高 |
| 资源效率 | 最高 | 高 | 低 |
| 租户规模 | 10000+ | 100-1000 | < 100 |
| 适用场景 | ToC/中小租户 | ToB/普通企业 | 大企业/金融 |
三、完整代码实现
3.1 行级隔离的自动化实现
/**
* 行级多租户:用MyBatis-Plus的拦截器自动注入tenant_id
* 这样业务代码不需要手动处理租户过滤
*/
@Configuration
public class MybatisTenantConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 多租户插件:自动在SQL中添加tenant_id条件
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 从ThreadLocal获取当前租户ID
Long tenantId = TenantContext.getCurrentTenantId();
return new LongValue(tenantId);
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
// 这些表不做租户隔离
return Set.of("sys_config", "sys_dict", "user_login_log").contains(tableName);
}
}));
return interceptor;
}
}
/**
* 租户上下文:存储当前请求的租户信息
*/
public class TenantContext {
private static final ThreadLocal<Long> TENANT_ID = new InheritableThreadLocal<>();
private static final ThreadLocal<TenantInfo> TENANT_INFO = new InheritableThreadLocal<>();
public static void setCurrentTenant(Long tenantId, TenantInfo info) {
TENANT_ID.set(tenantId);
TENANT_INFO.set(info);
}
public static Long getCurrentTenantId() {
Long tenantId = TENANT_ID.get();
if (tenantId == null) {
throw new TenantRequiredException("当前上下文没有设置租户信息");
}
return tenantId;
}
public static void clear() {
TENANT_ID.remove();
TENANT_INFO.remove();
}
}
/**
* 租户过滤器:从请求中解析租户信息,设置到上下文
*/
@Component
@Order(1)
public class TenantFilter implements Filter {
@Autowired
private TenantResolver tenantResolver;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
try {
// 从请求头/域名/路径中解析租户ID
Long tenantId = tenantResolver.resolve(httpRequest);
TenantInfo tenantInfo = tenantResolver.loadTenantInfo(tenantId);
TenantContext.setCurrentTenant(tenantId, tenantInfo);
chain.doFilter(request, response);
} finally {
TenantContext.clear(); // 必须清理,防止线程复用时泄漏
}
}
}
/**
* 租户解析策略
*/
@Component
public class TenantResolver {
@Autowired
private TenantRepository tenantRepo;
@Autowired
private RedisTemplate<String, Object> redis;
public Long resolve(HttpServletRequest request) {
// 策略1:从JWT Token中提取
String token = request.getHeader("Authorization");
if (token != null) {
return extractTenantFromToken(token);
}
// 策略2:从域名中提取(company-a.saas.com)
String host = request.getHeader("Host");
if (host != null && host.contains(".saas.com")) {
String subdomain = host.split("\\.")[0];
return resolveTenantBySubdomain(subdomain);
}
// 策略3:从请求头中获取
String tenantIdHeader = request.getHeader("X-Tenant-Id");
if (tenantIdHeader != null) {
return Long.parseLong(tenantIdHeader);
}
throw new TenantResolvedException("无法识别租户信息");
}
}3.2 Schema隔离的动态数据源方案
/**
* Schema隔离:通过动态切换Schema实现
* PostgreSQL支持SET search_path切换Schema
*/
@Configuration
public class MultiSchemaConfig {
@Bean
public DataSource dataSource() {
// 使用单个数据库连接,通过search_path切换Schema
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/saas_db");
config.setConnectionInitSql("SELECT 1"); // 连接测试
config.setMaximumPoolSize(50);
return new HikariDataSource(config);
}
@Bean
public MybatisPlusInterceptor schemaInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new SchemaInterceptor());
return interceptor;
}
}
/**
* Schema切换拦截器
*/
@Slf4j
public class SchemaInterceptor implements InnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter,
RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
setSchema(executor);
}
@Override
public void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) {
setSchema(executor);
}
private void setSchema(Executor executor) {
String schema = TenantContext.getCurrentSchema();
if (schema != null) {
try {
Connection conn = executor.getTransaction().getConnection();
// PostgreSQL切换Schema
conn.createStatement().execute("SET search_path TO " + schema + ", public");
} catch (Exception e) {
log.error("切换Schema失败: {}", schema, e);
}
}
}
}
/**
* 租户创建时,自动创建对应的Schema
*/
@Service
public class TenantProvisionService {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 为新租户创建Schema和表结构
*/
@Transactional
public void provisionTenant(String tenantId) {
String schema = "tenant_" + tenantId;
// 创建Schema
jdbcTemplate.execute("CREATE SCHEMA IF NOT EXISTS " + schema);
// 在新Schema下创建所有表
// 方案1:执行DDL SQL文件
executeSqlScript(schema, "classpath:db/tenant-schema.sql");
// 方案2:用Flyway对每个Schema执行迁移
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.schemas(schema)
.locations("classpath:db/migration/tenant")
.load();
flyway.migrate();
log.info("租户{}的Schema创建完成: {}", tenantId, schema);
}
private void executeSqlScript(String schema, String scriptPath) {
// 先切换到目标Schema,再执行建表SQL
jdbcTemplate.execute("SET search_path TO " + schema);
// 执行SQL脚本...
}
}3.3 混合方案:按租户级别路由
/**
* 混合多租户:根据租户级别路由到不同的隔离模式
*/
@Configuration
public class HybridTenantConfig {
@Bean
@Primary
public DataSource hybridDataSource(
@Qualifier("sharedDataSource") DataSource sharedDS,
@Qualifier("premiumDataSource") DataSource premiumDS) {
// 动态路由数据源
AbstractRoutingDataSource routingDS = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
TenantInfo tenant = TenantContext.getCurrentTenantInfo();
return switch (tenant.getTier()) {
case ENTERPRISE -> "enterprise_" + tenant.getTenantId();
case PREMIUM -> "premium";
case FREE -> "shared";
};
}
};
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("shared", sharedDS); // 免费租户共享库
targetDataSources.put("premium", premiumDS); // 付费租户专属库
// 企业租户每个都单独配置
routingDS.setTargetDataSources(targetDataSources);
routingDS.setDefaultTargetDataSource(sharedDS);
return routingDS;
}
}3.4 多租户数据库迁移管理
/**
* 对所有租户执行Schema变更
* 场景:新版本增加了字段,需要对所有租户的Schema执行迁移
*/
@Service
public class TenantMigrationService {
@Autowired
private TenantRepository tenantRepo;
@Autowired
private DataSource dataSource;
/**
* 对所有活跃租户执行数据库迁移
*/
public void migrateAllTenants() {
List<Tenant> tenants = tenantRepo.findByStatus(TenantStatus.ACTIVE);
// 并行迁移,但控制并发
ExecutorService executor = Executors.newFixedThreadPool(5);
List<CompletableFuture<Void>> futures = tenants.stream()
.map(tenant -> CompletableFuture.runAsync(() -> migrateTenant(tenant), executor))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.exceptionally(e -> {
log.error("部分租户迁移失败", e);
return null;
})
.join();
executor.shutdown();
}
private void migrateTenant(Tenant tenant) {
String schema = "tenant_" + tenant.getTenantId();
try {
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.schemas(schema)
.locations("classpath:db/migration/tenant")
.load();
flyway.migrate();
log.info("租户{}迁移完成", tenant.getTenantId());
} catch (Exception e) {
log.error("租户{}迁移失败", tenant.getTenantId(), e);
}
}
}四、工程实践
4.1 数据泄漏防护
行级隔离最大的风险是SQL忘记加tenant_id过滤。防护措施:
- 拦截器自动注入:用MyBatis-Plus的TenantLineHandler,强制所有查询加tenant_id
- 单元测试覆盖:每个Repository方法都有测试验证不同租户数据隔离
- 代码审查检查项:原生SQL必须有tenant_id过滤
4.2 跨租户操作(管理后台需求)
/**
* 特殊场景:管理员需要查看所有租户数据
* 需要绕过租户隔离
*/
public class AdminContext {
// 管理员模式:跳过租户过滤
public static void runAsAdmin(Runnable task) {
TenantContext.setAdminMode(true);
try {
task.run();
} finally {
TenantContext.setAdminMode(false);
}
}
}
// MyBatis-Plus拦截器中检查Admin模式
@Override
public Expression getTenantId() {
if (TenantContext.isAdminMode()) {
return null; // 管理员模式不过滤
}
return new LongValue(TenantContext.getCurrentTenantId());
}五、踩坑实录
坑一:ThreadLocal在异步场景下的租户上下文丢失
多租户上下文通常存在ThreadLocal里,但异步任务(@Async、CompletableFuture)运行在不同线程,ThreadLocal无法继承。
// 问题
@Async
public void processAsync() {
Long tenantId = TenantContext.getCurrentTenantId(); // null!
}
// 解决方案1:在@Async配置中传递租户信息
@Bean
public Executor asyncTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(runnable -> {
Long tenantId = TenantContext.getCurrentTenantId();
return () -> {
TenantContext.setCurrentTenantId(tenantId);
try {
runnable.run();
} finally {
TenantContext.clear();
}
};
});
return executor;
}坑二:Schema隔离下的连接池管理
Schema切换通过执行SQL实现,如果连接复用时上一次的search_path设置还在,可能导致查询错误Schema。
解决方案:每次从连接池获取连接后,显式设置search_path,不依赖上一次设置。
坑三:行级隔离下的全表扫描
大租户数据很多,小租户数据很少,但由于共表存储,小租户的查询可能扫描大量大租户的数据行(在索引不理想的情况下)。
解决方案:所有索引都以tenant_id为前缀,确保分区剪枝生效。
坑四:租户数据备份和恢复困难
行级隔离下,如果一个租户想要备份自己的数据,需要从全量数据中按tenant_id筛选,操作复杂。
建议:对于有独立备份需求的租户,考虑升级到Schema或数据库隔离级别。
六、总结与个人判断
多租户方案没有最好,只有最适合。我的选型原则:
- 租户数量多(> 1000)、差异性小(ToC类产品):行级隔离,配合MyBatis-Plus自动注入,风险可控
- 租户数量中等(100-1000)、有一定隔离需求(ToB SaaS):Schema隔离,对PostgreSQL友好
- 大客户、强隔离需求(金融、医疗、政府):独立数据库实例,不能在成本上将就
实际项目中,混合方案往往最合理——按租户价值分层,高价值租户给更好的隔离,低价值租户共享资源。这不只是技术选型,也是商业模式设计的一部分。
