设计一个多租户SaaS系统:数据隔离方案、性能与安全的权衡
设计一个多租户SaaS系统:数据隔离方案、性能与安全的权衡
适读人群:Java中高级工程师、SaaS产品技术负责人 | 阅读时长:约20分钟 | 难度:★★★★☆
开篇故事
两年前我们把一个企业内部系统改造成SaaS多租户产品。改造前,我以为只是"加个tenant_id字段"的事,改造完才发现我低估了这件事的复杂度。
数据隔离是最基础的,但数据隔离方案不同,对性能、安全性、运维成本的影响天差地别。我们踩了不少坑——共享数据库时一个SQL写错了WHERE条件,查出了别的租户的数据;独立数据库时运维一下要管几千个数据库实例,升级Schema要花一整天;中间方案每次写SQL都要带着tenant_id,漏了就是数据泄露。
这篇文章把多租户系统的三种隔离方案、优缺点对比、以及我们最终选择的混合方案全部讲清楚。
一、需求分析与规模估算
多租户系统的核心挑战
- 数据隔离: 租户A绝对看不到租户B的数据
- 性能隔离: 一个租户的大查询不能影响其他租户
- 定制化: 不同租户可能有不同的功能开关、配置项
- 运维效率: 几千个租户,数据库变更如何批量执行
规模估算
以一个面向中小企业的HR SaaS为例:
租户规模: 1000个企业客户(租户),每个租户平均500员工
数据量: 每个租户约10万条员工相关记录(考勤、薪资、档案)
QPS: 平均每租户2 QPS = 总2000 QPS;峰值约5倍 = 10000 QPS
二、三种数据隔离方案对比
方案一:独立数据库(Database-per-Tenant)
每个租户有独立的数据库实例(或独立的数据库)。
优点: 最强隔离,一个租户出问题不影响其他人;可以独立备份、独立扩容
缺点: 运维成本极高(1000个租户 = 1000个数据库),资源浪费严重(每个DB都要预留资源)
适用场景: 大企业客户,安全合规要求极高,愿意付高价的客户
方案二:独立Schema(Schema-per-Tenant)
所有租户共享同一个数据库实例,但每个租户有独立的Schema(PostgreSQL等支持良好)。
优点: 比独立数据库省资源,隔离性仍然较好
缺点: Schema数量多时,数据库元数据膨胀,管理复杂;MySQL的Schema实际上等于数据库,优势不明显
适用场景: 使用PostgreSQL,租户数量适中(< 100个)
方案三:共享表(Row-Level Security)
所有租户的数据在同一张表中,用tenant_id字段区分。
优点: 资源利用率最高,运维简单,Schema变更只需改一次
缺点: 需要在每个查询中都加WHERE tenant_id = ?,一旦遗漏就数据泄露;性能隔离差
适用场景: 中小租户,开发团队能严格保证代码质量
推荐方案:混合架构(按客户等级分层)
大客户(Top 10%)→ 独立数据库实例
中等客户(30%)→ 独立Schema(多租户共享一个DB实例,每租户独立Schema)
小客户(60%)→ 共享表(Row-Level)三、系统架构设计
四、关键代码实现
4.1 租户上下文管理
/**
* 租户上下文:存储当前请求的租户信息
* 使用ThreadLocal保证线程安全
*/
public class TenantContext {
private static final ThreadLocal<TenantInfo> CURRENT = new ThreadLocal<>();
public static void set(TenantInfo tenant) {
CURRENT.set(tenant);
}
public static TenantInfo get() {
TenantInfo tenant = CURRENT.get();
if (tenant == null) {
throw new IllegalStateException("租户上下文未设置,请检查请求拦截器");
}
return tenant;
}
public static String getTenantId() {
return get().getTenantId();
}
public static void clear() {
CURRENT.remove();
}
@Data
@Builder
public static class TenantInfo {
private String tenantId;
private String tenantName;
private TenantLevel level; // PREMIUM/STANDARD/BASIC
private String dataSourceKey; // 对应的数据源Key
}
}4.2 租户识别拦截器
@Component
@Slf4j
public class TenantIdentifyInterceptor implements HandlerInterceptor {
@Autowired
private TenantMetaService tenantMetaService;
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 租户识别策略(多种方式)
String tenantId = null;
// 1. 从JWT Token中提取
String token = extractToken(request);
if (token != null) {
tenantId = jwtService.extractTenantId(token);
}
// 2. 从域名中提取(如 xxx.saas.com → tenantId=xxx)
if (tenantId == null) {
String host = request.getServerName();
tenantId = extractFromDomain(host);
}
// 3. 从请求头中提取
if (tenantId == null) {
tenantId = request.getHeader("X-Tenant-Id");
}
if (tenantId == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
// 加载租户信息(走缓存)
TenantContext.TenantInfo tenantInfo = tenantMetaService.loadTenant(tenantId);
TenantContext.set(tenantInfo);
return true;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
TenantContext.clear(); // 清理,防止线程复用时污染
}
private String extractFromDomain(String host) {
// xxx.saas.com → xxx
if (host.endsWith(".saas.com")) {
return host.replace(".saas.com", "");
}
return null;
}
}4.3 动态数据源路由
/**
* 动态数据源:根据租户级别路由到不同的数据源
*/
public class TenantDynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
TenantContext.TenantInfo tenant = TenantContext.get();
return tenant.getDataSourceKey();
}
}@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource dataSource(
@Qualifier("premiumDataSource") DataSource premiumDs,
@Qualifier("standardDataSource") DataSource standardDs,
@Qualifier("basicDataSource") DataSource basicDs) {
TenantDynamicDataSource dataSource = new TenantDynamicDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("premium", premiumDs); // 高端客户独立DB
targetDataSources.put("standard", standardDs); // 标准客户共享DB
targetDataSources.put("basic", basicDs); // 基础客户共享DB
dataSource.setTargetDataSources(targetDataSources);
dataSource.setDefaultTargetDataSource(basicDs);
return dataSource;
}
}4.4 自动注入tenant_id(防漏写)
对于共享表方案,最大的风险是WHERE条件忘写tenant_id。用MyBatis拦截器自动注入,彻底解决这个问题:
/**
* MyBatis拦截器:自动为所有SQL注入tenant_id条件
* 防止开发者忘记写 WHERE tenant_id = ?
*/
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})
})
@Component
@Slf4j
public class TenantSqlInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = statementHandler.getBoundSql();
String originalSql = boundSql.getSql();
// 只对基础客户(共享表)注入tenant_id
TenantContext.TenantInfo tenant = TenantContext.get();
if (tenant.getLevel() != TenantLevel.BASIC) {
return invocation.proceed(); // 独立DB/Schema不需要注入
}
String tenantId = tenant.getTenantId();
// 解析SQL,自动追加WHERE tenant_id = 'xxx' 条件
String newSql = injectTenantId(originalSql, tenantId);
if (!newSql.equals(originalSql)) {
// 用新SQL替换原SQL
Field sqlField = BoundSql.class.getDeclaredField("sql");
sqlField.setAccessible(true);
sqlField.set(boundSql, newSql);
}
return invocation.proceed();
}
/**
* 向SQL注入租户ID条件
* 支持SELECT/UPDATE/DELETE,使用JSqlParser解析
*/
private String injectTenantId(String sql, String tenantId) {
try {
Statement statement = CCJSqlParserUtil.parse(sql);
if (statement instanceof Select) {
injectIntoSelect((Select) statement, tenantId);
} else if (statement instanceof Update) {
injectIntoUpdate((Update) statement, tenantId);
} else if (statement instanceof Delete) {
injectIntoDelete((Delete) statement, tenantId);
}
return statement.toString();
} catch (Exception e) {
log.warn("SQL租户ID注入失败,使用原SQL, sql={}", sql, e);
return sql;
}
}
private void injectIntoSelect(Select select, String tenantId) {
SelectBody selectBody = select.getSelectBody();
if (selectBody instanceof PlainSelect) {
PlainSelect plainSelect = (PlainSelect) selectBody;
Expression tenantCondition = buildTenantCondition(tenantId);
if (plainSelect.getWhere() == null) {
plainSelect.setWhere(tenantCondition);
} else {
plainSelect.setWhere(new AndExpression(
plainSelect.getWhere(), tenantCondition));
}
}
}
private Expression buildTenantCondition(String tenantId) throws JSQLParserException {
return CCJSqlParserUtil.parseCondExpression(
"tenant_id = '" + tenantId + "'");
}
}4.5 Schema隔离实现(标准租户)
/**
* 标准租户使用独立Schema
* 通过连接池的连接初始化SQL,每次连接时切换到对应Schema
*/
@Configuration
public class SchemaRoutingConfig {
/**
* HikariCP连接池:连接建立时执行schema切换
*/
@Bean("standardDataSource")
public DataSource standardDataSource(
@Value("${db.standard.url}") String url,
@Value("${db.standard.username}") String username,
@Value("${db.standard.password}") String password) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
// 每次获取连接时,自动切换到当前租户的Schema
config.setConnectionInitSql("SET search_path TO " + getCurrentSchema());
return new HikariDataSource(config);
}
/**
* 通过AOP在SQL执行前切换Schema(更灵活的方案)
*/
@Around("execution(* com.lz..*Repository.*(..))")
public Object switchSchema(ProceedingJoinPoint pjp) throws Throwable {
TenantContext.TenantInfo tenant = TenantContext.get();
if (tenant.getLevel() == TenantLevel.STANDARD) {
// 执行 USE schema_xxx
jdbcTemplate.execute("USE tenant_schema_" + tenant.getTenantId());
}
return pjp.proceed();
}
}五、扩展性设计
租户onboard(新租户开通)
新客户注册后,需要自动完成数据库初始化、租户配置写入、权限初始化等。这个流程要全自动,否则几百个租户手动操作是噩梦。
@Service
public class TenantOnboardingService {
public void onboard(TenantOnboardRequest request) {
String tenantId = request.getTenantId();
TenantLevel level = request.getLevel();
// 1. 根据级别初始化数据库
if (level == TenantLevel.STANDARD) {
// 在共享DB中创建新Schema,执行DDL初始化
schemaInitService.createSchema(tenantId);
schemaInitService.initTables(tenantId);
}
// 2. 写入租户元数据
tenantMetaService.register(tenantId, level);
// 3. 初始化默认配置
tenantConfigService.initDefault(tenantId);
// 4. 创建管理员账号
userService.createAdmin(tenantId, request.getAdminEmail());
log.info("租户开通完成, tenantId={}, level={}", tenantId, level);
}
}Schema变更的多租户执行
所有标准租户的Schema结构相同,每次表结构变更需要对所有租户的Schema执行一遍。
@Component
public class MultiTenantMigrationRunner {
@Autowired
private TenantMetaService tenantMetaService;
@Autowired
private DataSource standardDataSource;
/**
* 对所有标准租户执行Schema迁移
* 实际中用Flyway/Liquibase管理迁移脚本
*/
public void runMigration(String migrationSql) {
List<String> standardTenantIds = tenantMetaService
.findByLevel(TenantLevel.STANDARD);
int success = 0, failed = 0;
for (String tenantId : standardTenantIds) {
try {
// 切换到租户Schema并执行迁移
String schemaName = "tenant_schema_" + tenantId;
jdbcTemplate.execute("USE " + schemaName);
jdbcTemplate.execute(migrationSql);
success++;
} catch (Exception e) {
log.error("租户迁移失败, tenantId={}", tenantId, e);
failed++;
}
}
log.info("多租户迁移完成, 成功={}, 失败={}", success, failed);
}
}六、踩坑实录
坑1:租户上下文在异步线程中丢失
Service层方法里调用了@Async注解的方法,异步线程里的TenantContext.get()拿到null,然后SQL查询不带租户条件,查出了所有租户的数据。
解决方案:同第696篇日志系统里的MDC传递方案,异步线程执行前恢复TenantContext,执行后清理。
坑2:同一数据库连接被不同租户使用
连接池里的连接被不同租户复用,如果上一次用这个连接的是租户A(切换到了Schema A),下一次租户B拿到这个连接时,如果没有重新切换Schema,就会在Schema A里写入租户B的数据。
解决方案:在连接归还到连接池时,重置Schema到默认值;或者在每次从池里获取连接时强制执行Schema切换。
坑3:分库后跨租户聚合查询变得困难
运营需要查"所有租户的今日活跃用户总数",但数据分散在几十个数据库实例和几百个Schema里,没有一个地方能直接查。
解决方案:建立一个"汇总数据库",各租户每天把核心业务指标同步到汇总库(简单的数值型数据,不包含敏感信息),运营查询走汇总库。
七、总结
多租户数据隔离方案选型:
| 方案 | 隔离性 | 成本 | 运维 | 适用场景 |
|---|---|---|---|---|
| 独立数据库 | 最强 | 最高 | 最复杂 | 大客户/合规要求高 |
| 独立Schema | 强 | 中 | 中等 | 中等客户(<100个) |
| 共享表 | 弱 | 最低 | 简单 | 小客户/创业期 |
| 混合方案 | 按需 | 合理 | 复杂 | 规模化SaaS(推荐) |
多租户的本质是资源共享和隔离的平衡。 创业初期可以从共享表做起,随着大客户增多,逐步为高价值客户升级到独立Schema乃至独立数据库。技术架构要跟着商业模式走,不需要一开始就做最复杂的方案。
