极简架构在SaaS产品中的复盘多租户数据隔离的设计演变与经验一、多租户的起点三选一的架构抉择SaaS产品的多租户数据隔离有三种经典方案方案A独立数据库每个租户一个Database。隔离性最强但运维成本最高——100个租户100个数据库。方案B共享数据库独立Schema每个租户一个Schema。折中方案但PostgreSQL的Schema管理复杂度不低。方案C共享数据库共享表tenant_id隔离。隔离性最弱但运维成本最低。初期选择了方案C——因为产品只有12个租户隔离性风险可控。这个选择在产品增长到80个租户时受到挑战但没有选择迁移到方案A而是在方案C的基础上加了多层防护。二、三层防护的架构设计第一层应用层——中间件自动注入tenant_id在API层识别租户通过JWT中的tenant_id并自动注入到所有数据库查询中// 租户中间件 func TenantMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // 从JWT提取租户ID claims : c.MustGet(claims).(*JWTClaims) tenantID : claims.TenantID // 注入到上下文 ctx : context.WithValue(c.Request.Context(), tenant_id, tenantID) c.Request c.Request.WithContext(ctx) c.Next() } } // Repository层自动注入 func (r *BaseRepo) FindByTenant(ctx context.Context, id string) (*Entity, error) { tenantID : ctx.Value(tenant_id).(string) var entity Entity err : r.db.QueryRowContext(ctx, SELECT * FROM users WHERE id $1 AND tenant_id $2, id, tenantID, ).Scan(entity) if err sql.ErrNoRows { return nil, ErrNotFound // 不区分不存在和不属于此租户 } return entity, err }第二层数据库层——Row-Level SecurityPostgreSQL的行级安全策略作为最后防线的安全网-- 为每个表启用RLS ALTER TABLE users ENABLE ROW LEVEL SECURITY; -- 创建策略用户只能看到自己租户的数据 CREATE POLICY tenant_isolation ON users USING (tenant_id current_setting(app.tenant_id)); -- 应用连接时设置租户上下文 -- 在每个事务开始时执行 -- SELECT set_config(app.tenant_id, $1, true);RLS的价值在于即使应用层代码忘记了加WHERE tenant_id ?数据库层也会拦截。这是纵深防御的核心——不信任单层保护。第三层审计层——跨租户访问检测定期扫描应用日志检测潜在的跨租户数据访问// 审计检查检测是否有可能的跨租户数据泄露 func (s *AuditService) CheckCrossTenantAccess() { rows, _ : s.db.Query( SELECT al.tenant_id as request_tenant, dl.tenant_id as data_tenant, al.user_id, al.query, al.timestamp FROM access_logs al JOIN data_lineage dl ON al.resource_id dl.resource_id WHERE al.tenant_id ! dl.tenant_id AND al.timestamp NOW() - INTERVAL 1 hour ) for rows.Next() { var event CrossTenantEvent rows.Scan(event.RequestTenant, event.DataTenant, event.UserID, event.Query, event.Timestamp) // 告警可能是Bug或攻击 s.alertService.Send(Alert{ Severity: CRITICAL, Message: fmt.Sprintf(跨租户访问: %s尝试访问%s数据, event.RequestTenant, event.DataTenant), Detail: event, }) } }三、租户隔离的存储设计在共享表中加入tenant_id并确保所有查询带上这个条件-- 核心表设计 CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, email VARCHAR(255) NOT NULL, name VARCHAR(100), created_at TIMESTAMPTZ DEFAULT NOW(), -- 租户内唯一而非全局唯一 UNIQUE(tenant_id, email) ); -- 性能关键复合索引tenant_id 常用查询字段 CREATE INDEX idx_users_tenant_email ON users(tenant_id, email); CREATE INDEX idx_users_tenant_name ON users(tenant_id, name); -- 分区策略当租户数量500时考虑 -- CREATE TABLE users PARTITION BY HASH(tenant_id);四、方案C的实际表现与边界产品运行18个月80个租户总计约200万条数据隔离性零次跨租户数据泄露。三层防护有效但增加了复杂度——每条SQL都必须带tenant_id一旦有遗漏就会在代码Review中被发现。性能在200万条数据下复合索引(tenant_id, email)的查询在5ms以内。如果单一租户的数据量增长到百万级别性能挑战可能出现。运维方案C的真正优势——80个租户的备份和恢复只需要1个数据库。方案A需要80次备份。在SaaS产品早期运维简单性的价值大于隔离性的细微差异。边界条件当某租户数据量超过500万条时与其他租户共享表会导致查询变慢——无论索引多好某租户的特殊合规要求如数据必须存储在中国境内时方案C无法满足何时需要从C升级到A/B单一租户数据量 500万条性能边界租户有独立数据库的合规要求需要给某些大客户专属优化的能力租户之间的备份/恢复需求不同五、总结共享数据库共享表的方案C在SaaS初期是务实之选三层防护应用数据库审计将隔离风险降到可接受范围复合索引(tenant_id, ...)是性能的关键保障RLS作为数据库层的安全网防止应用层代码遗漏定期审计扫描是发现潜在Bug的有效手段方案C不是最好的架构而是最适合早期SaaS的架构。当数据量和合规需求到达临界点时向方案A/B演进是自然的选择。但在那之前节省下来的运维精力应该投入到产品功能上——这才是SaaS早期竞争的核心。
极简架构在SaaS产品中的复盘:多租户数据隔离的设计演变与经验
极简架构在SaaS产品中的复盘多租户数据隔离的设计演变与经验一、多租户的起点三选一的架构抉择SaaS产品的多租户数据隔离有三种经典方案方案A独立数据库每个租户一个Database。隔离性最强但运维成本最高——100个租户100个数据库。方案B共享数据库独立Schema每个租户一个Schema。折中方案但PostgreSQL的Schema管理复杂度不低。方案C共享数据库共享表tenant_id隔离。隔离性最弱但运维成本最低。初期选择了方案C——因为产品只有12个租户隔离性风险可控。这个选择在产品增长到80个租户时受到挑战但没有选择迁移到方案A而是在方案C的基础上加了多层防护。二、三层防护的架构设计第一层应用层——中间件自动注入tenant_id在API层识别租户通过JWT中的tenant_id并自动注入到所有数据库查询中// 租户中间件 func TenantMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // 从JWT提取租户ID claims : c.MustGet(claims).(*JWTClaims) tenantID : claims.TenantID // 注入到上下文 ctx : context.WithValue(c.Request.Context(), tenant_id, tenantID) c.Request c.Request.WithContext(ctx) c.Next() } } // Repository层自动注入 func (r *BaseRepo) FindByTenant(ctx context.Context, id string) (*Entity, error) { tenantID : ctx.Value(tenant_id).(string) var entity Entity err : r.db.QueryRowContext(ctx, SELECT * FROM users WHERE id $1 AND tenant_id $2, id, tenantID, ).Scan(entity) if err sql.ErrNoRows { return nil, ErrNotFound // 不区分不存在和不属于此租户 } return entity, err }第二层数据库层——Row-Level SecurityPostgreSQL的行级安全策略作为最后防线的安全网-- 为每个表启用RLS ALTER TABLE users ENABLE ROW LEVEL SECURITY; -- 创建策略用户只能看到自己租户的数据 CREATE POLICY tenant_isolation ON users USING (tenant_id current_setting(app.tenant_id)); -- 应用连接时设置租户上下文 -- 在每个事务开始时执行 -- SELECT set_config(app.tenant_id, $1, true);RLS的价值在于即使应用层代码忘记了加WHERE tenant_id ?数据库层也会拦截。这是纵深防御的核心——不信任单层保护。第三层审计层——跨租户访问检测定期扫描应用日志检测潜在的跨租户数据访问// 审计检查检测是否有可能的跨租户数据泄露 func (s *AuditService) CheckCrossTenantAccess() { rows, _ : s.db.Query( SELECT al.tenant_id as request_tenant, dl.tenant_id as data_tenant, al.user_id, al.query, al.timestamp FROM access_logs al JOIN data_lineage dl ON al.resource_id dl.resource_id WHERE al.tenant_id ! dl.tenant_id AND al.timestamp NOW() - INTERVAL 1 hour ) for rows.Next() { var event CrossTenantEvent rows.Scan(event.RequestTenant, event.DataTenant, event.UserID, event.Query, event.Timestamp) // 告警可能是Bug或攻击 s.alertService.Send(Alert{ Severity: CRITICAL, Message: fmt.Sprintf(跨租户访问: %s尝试访问%s数据, event.RequestTenant, event.DataTenant), Detail: event, }) } }三、租户隔离的存储设计在共享表中加入tenant_id并确保所有查询带上这个条件-- 核心表设计 CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, email VARCHAR(255) NOT NULL, name VARCHAR(100), created_at TIMESTAMPTZ DEFAULT NOW(), -- 租户内唯一而非全局唯一 UNIQUE(tenant_id, email) ); -- 性能关键复合索引tenant_id 常用查询字段 CREATE INDEX idx_users_tenant_email ON users(tenant_id, email); CREATE INDEX idx_users_tenant_name ON users(tenant_id, name); -- 分区策略当租户数量500时考虑 -- CREATE TABLE users PARTITION BY HASH(tenant_id);四、方案C的实际表现与边界产品运行18个月80个租户总计约200万条数据隔离性零次跨租户数据泄露。三层防护有效但增加了复杂度——每条SQL都必须带tenant_id一旦有遗漏就会在代码Review中被发现。性能在200万条数据下复合索引(tenant_id, email)的查询在5ms以内。如果单一租户的数据量增长到百万级别性能挑战可能出现。运维方案C的真正优势——80个租户的备份和恢复只需要1个数据库。方案A需要80次备份。在SaaS产品早期运维简单性的价值大于隔离性的细微差异。边界条件当某租户数据量超过500万条时与其他租户共享表会导致查询变慢——无论索引多好某租户的特殊合规要求如数据必须存储在中国境内时方案C无法满足何时需要从C升级到A/B单一租户数据量 500万条性能边界租户有独立数据库的合规要求需要给某些大客户专属优化的能力租户之间的备份/恢复需求不同五、总结共享数据库共享表的方案C在SaaS初期是务实之选三层防护应用数据库审计将隔离风险降到可接受范围复合索引(tenant_id, ...)是性能的关键保障RLS作为数据库层的安全网防止应用层代码遗漏定期审计扫描是发现潜在Bug的有效手段方案C不是最好的架构而是最适合早期SaaS的架构。当数据量和合规需求到达临界点时向方案A/B演进是自然的选择。但在那之前节省下来的运维精力应该投入到产品功能上——这才是SaaS早期竞争的核心。