
内容管理系统的数据库设计多租户SaaS场景下的Schema隔离策略一、租户A的数据出现在租户B的报表里多租户隔离失败的典型事故某内容管理SaaS平台在一次数据库迁移后客户A的内容编辑在后台看到了客户B的文章草稿——一个经典的多租户隔离失败事故。排查发现开发写了一个不带tenant_id过滤条件的查询SELECT * FROM articles WHERE status draft ORDER BY updated_at DESC LIMIT 10;这条SQL在测试环境只有一个租户时完全没问题上生产后直接变成了跨租户数据泄露。事故暴露了多租户数据隔离的三个层次代码层的SQL过滤是门锁Schema层的物理隔离是保险柜而安全审计日志是监控探头。三者缺一不可。二、三大隔离策略的深度对比共享表方案是SaaS创业公司的起点。在MySQL 8.0中可以借助Row-Level Security强化-- 为租户创建受限视图 CREATE VIEW tenant_articles AS SELECT * FROM articles WHERE tenant_id SUBSTRING_INDEX(USER(), , 1); -- 创建租户专用数据库账号 CREATE USER tenant_A% IDENTIFIED BY xxx; GRANT SELECT, INSERT, UPDATE, DELETE ON cms.tenant_articles TO tenant_A%; -- 设置默认的tenant_id ALTER TABLE articles MODIFY tenant_id VARCHAR(36) NOT NULL DEFAULT (SUBSTRING_INDEX(USER(), , 1));Schema隔离方案适合中型租户100-10000个租户-- 创建租户Schema CREATE SCHEMA tenant_abc123; CREATE SCHEMA tenant_def456; -- 在每个Schema中创建相同结构的表 -- 使用存储过程批量管理 DELIMITER // CREATE PROCEDURE create_tenant_schema(IN tenant_id VARCHAR(36)) BEGIN SET schema_name CONCAT(tenant_, REPLACE(tenant_id, -, _)); SET create_schema CONCAT(CREATE SCHEMA IF NOT EXISTS , schema_name, ); PREPARE stmt FROM create_schema; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET create_tables CONCAT( CREATE TABLE , schema_name, .articles LIKE cms_template.articles; CREATE TABLE , schema_name, .media LIKE cms_template.media; CREATE TABLE , schema_name, .users LIKE cms_template.users; ); PREPARE stmt FROM create_tables; EXECUTE stmt; DEALLOCATE PREPARE stmt; END // DELIMITER ;三、多租户路由中间件的实现public class TenantRoutingDataSource extends AbstractRoutingDataSource { private static final ThreadLocalString CURRENT_TENANT new ThreadLocal(); private final LoadingCacheString, DataSource tenantCache; private final TenantMetadataService metadataService; public TenantRoutingDataSource(MapObject, Object targetDataSources) { this.tenantCache CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterAccess(30, TimeUnit.MINUTES) .build(new CacheLoaderString, DataSource() { Override public DataSource load(String tenantId) { return createTenantDataSource(tenantId); } }); this.setTargetDataSources(targetDataSources); } Override protected Object determineCurrentLookupKey() { String tenantId CURRENT_TENANT.get(); if (tenantId null) { throw new TenantNotFoundException(未设置租户上下文); } TenantTier tier metadataService.getTenantTier(tenantId); switch (tier) { case FREE: return shared_datasource; // 共享表 case PRO: return tenantId; // Schema隔离 case ENTERPRISE: // 独立数据库已在tenantCache中 return tenantId; default: throw new TenantNotSupportedException(不支持的租户级别); } } Override public Connection getConnection() throws SQLException { Connection conn super.getConnection(); String tenantId CURRENT_TENANT.get(); TenantTier tier metadataService.getTenantTier(tenantId); if (tier TenantTier.FREE) { // 共享表设置会话变量做行级过滤 try (Statement stmt conn.createStatement()) { stmt.execute(SET current_tenant_id tenantId ); } } else if (tier TenantTier.PRO) { // Schema隔离切换默认Schema conn.setCatalog(tenant_ tenantId.replace(-, _)); } return conn; } public static void setCurrentTenant(String tenantId) { CURRENT_TENANT.set(tenantId); } public static void clear() { CURRENT_TENANT.remove(); } private DataSource createTenantDataSource(String tenantId) { // 从元数据服务获取租户的独立数据库连接信息 TenantDatabaseConfig config metadataService.getDatabaseConfig(tenantId); HikariConfig hikariConfig new HikariConfig(); hikariConfig.setJdbcUrl(config.getJdbcUrl()); hikariConfig.setUsername(config.getUsername()); hikariConfig.setPassword(config.getPassword()); hikariConfig.setMaximumPoolSize(10); hikariConfig.setMinimumIdle(2); return new HikariDataSource(hikariConfig); } } // 租户级别的拦截器 Component public class TenantInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenantId request.getHeader(X-Tenant-Id); if (tenantId null || tenantId.isEmpty()) { throw new TenantNotFoundException(缺少X-Tenant-Id请求头); } // 验证租户是否有效且未被禁用 if (!tenantService.isActive(tenantId)) { throw new TenantDeactivatedException(tenantId); } TenantRoutingDataSource.setCurrentTenant(tenantId); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantRoutingDataSource.clear(); } }四、多租户隔离的四个工程陷阱陷阱一Schema数量爆炸。MySQL单个实例的Schema数量建议不超过10000个。超过后information_schema查询变慢DDL变更需要在所有Schema上执行。当租户数达到万级时必须开始考虑独立数据库。陷阱二跨租户查询的需求。运营人员需要所有租户的内容发布量排行。在共享表方案中这是简单的GROUP BY tenant_id在Schema隔离中需要UNION ALL所有Schema在独立数据库中几乎不可行。这就需要在数据仓库层做统一汇聚。陷阱三Schema迁移的一致性。新增一个字段content_tags需要在2000个租户Schema上执行ALTER TABLE。需要DDL变更工具如pt-online-schema-change或gh-ost并且确保所有Schema的迁移都在同一窗口内完成——否则API返回的字段会不一致。陷阱四备份恢复的细粒度。免费租户误删了数据如何从全量备份中只恢复这一个租户的信息在共享表中是精细的WHERE过滤恢复在独立数据库中直接恢复整个DB实例。混合方案需要备份策略支持租户级别的恢复粒度。五、总结SaaS多租户的数据库隔离策略是成本-隔离性-运维复杂度的不可能三角。免费租户用共享表成本最优付费租户用Schema隔离隔离性和运维的平衡大客户用独立数据库安全达标。通过TenantRoutingDataSource统一路由业务代码无需感知底层隔离模式的差异。在内容管理SaaS场景中混合方案是最务实的选择——没有一种策略能覆盖所有租户的需求。本文属于「行业场景与项目复盘」系列深度对比多租户SaaS场景下的数据库Schema隔离策略与工程实践。