多租户数据隔离怎么做?SaaS共享库架构+行级安全实战(附完整方案)
大家好我是数据库小学妹 前段时间接手了一个SaaS系统。客户越来越多一百多个企业都在同一个库里跑。有一天一个客户发来截图说他的数据看板里出现了别的公司的数据。我心里一紧打开数据库查了查发现确实是串了。原因是开发同学写报表查询的时候漏加了WHERE tenant_id ?这个条件。没有租户过滤查出来的就是全量数据所有客户的信息一览无余。还好发现得早只泄露了一条。但这件事让我意识到一个问题我们的租户隔离完全靠开发自觉。SQL里写了tenant_id就能隔离漏写了就串。这不是隔离这是运气。而且这种运气模式有个更可怕的特点问题不会立刻暴露。漏写的SQL可能查了十次都恰好只返回当前租户的数据第十一次才串。测试环境更难发现因为测试数据量小不同租户的数据混在一起看不出问题。那天之后我花了一周时间把多租户的隔离方案重新设计了一遍。今天把这些经验整理出来。三种隔离模式独立实例、独立库、共享库的取舍多租户的数据隔离有三种模式。第一种是独立实例。每个租户一个独立的数据库实例。物理上完全隔离A的数据和B的数据在不同的进程里甚至不同的机器上。安全性最高但成本也最高。一百个客户就要一百个实例运维成本扛不住。第二种是独立库。所有租户共用一个数据库实例但每个租户一个独立的数据库Schema。逻辑上隔离物理上共享。成本比独立实例低但租户数量上千以后Schema管理就成问题了。第三种是共享库加tenant_id。所有租户共用同一张表通过tenant_id字段区分。成本最低扩展性最好但安全性也最差。因为隔离完全依赖应用层的SQL漏写条件就串。我们原来用的就是第三种。成本是低了但隔离性全靠人肉保障。隔离模式安全性成本扩展性适用租户数独立实例最高最高差50独立库高中等中等50-500共享库tenant_id依赖应用最低好500选哪种模式取决于你的业务阶段和团队能力。初创期租户少独立库方案最安全。到了几百上千租户共享库方案的性价比最高但必须在隔离机制上做足功课不能只靠开发自觉。Row-Level Security让数据库帮你守门我调研了一圈发现数据库层面有一个现成的方案行级安全Row-Level Security简称RLS。RLS最早是PostgreSQL在9.0版本引入的后来被很多数据库借鉴。它的核心思想是把访问控制的逻辑从应用层下沉到数据库引擎层。以前的权限控制只能精确到表级——这个用户能访问orders表那个用户不能。RLS把精度提到了行级——你能访问orders表但只能看到tenant_id等于你自己的那些行。RLS的思路很直接在数据库层面定义策略自动给每条查询加上租户过滤条件。不管应用层的SQL怎么写数据库都会自动过滤只返回当前租户的数据。RLS的生效位置在查询执行器层不是在SQL解析层。也就是说它是在SQL已经解析完、生成执行计划之后由执行器在真正读数据之前加上过滤条件。这意味着即使有人绕过应用层直接连数据库执行SQLRLS照样生效。这是它比中间件拦截更可靠的地方。-- 开启行级安全ALTERTABLEordersENABLEROWLEVELSECURITY;-- 创建策略当前会话的tenant_id只能看到自己的数据CREATEPOLICY tenant_isolationONordersFORALLUSING(tenant_idcurrent_setting(app.current_tenant_id)::BIGINT);开启之后任何对orders表的查询都会自动加上WHERE tenant_id 当前租户ID的过滤。开发不需要在每条SQL里手动加条件数据库帮你守门。但这里有一个关键前提current_setting(app.current_tenant_id)这个值必须在每次请求开始时正确设置。怎么设在连接池获取连接之后执行一条SET命令。-- 应用在获取连接后立即设置 SET app.current_tenant_id 1001;这个SET是session级别的。也就是说每个连接在使用前都要设置一次。连接池的session清理一个容易忽略的坑说到连接池这里有一个我踩过的坑。连接池为了性能会复用连接。一个连接被A请求用完之后不会关闭而是放回池里给下一个请求用。问题是上一个请求设置的session变量还在。如果A请求设了app.current_tenant_id 1001这个连接被B请求拿到后如果没有重新设置B看到的还是1001的数据。如果B是1002号租户就串了。更隐蔽的情况是A请求设了变量B请求拿到了连接B设了自己的变量。但C请求又拿到了同一个连接C没有设变量。C就继承了B的tenant_id。解决方案是在连接池配置里加上连接归还时的清理逻辑。我试过两种解法第一种是配置连接归还时的重置SQL在连接还回池里之前执行第二种是不信任连接状态每次借出时强制重置。最终选了第二种因为第一种依赖连接池框架支持不是所有连接池都提供归还时的回调。而且即使配了归还回调如果连接异常断开比如超时被服务端踢掉回调也执行不了。-- HikariCP不支持归还时执行SQL-- 所以在获取连接时重置-- 应用层代码Java示例Connection conndataSource.getConnection();conn.createStatement().execute(SET app.current_tenant_id currentTenantId);更稳妥的做法是每次获取连接都重置不依赖上一次的状态。宁可多执行一条SET也不要假设连接是干净的。我在代码审查时专门加了一条规则所有数据库操作必须在获取连接之后立即设置tenant_id。用公共方法封装不允许在业务代码里直接调getConnection。中间件自动注入让tenant_id无需手动编写RLS解决了漏写WHERE的问题。但还有一个问题每个请求都要手动设置tenant_id。有没有更优雅的方式有的。在应用层的数据库访问中间件里自动注入。以MyBatis为例写一个拦截器在每条SQL执行之前自动注入tenant_id条件。Intercepts({Signature(typeStatementHandler.class,methodprepare,args{Connection.class,Integer.class})})publicclassTenantInterceptorimplementsInterceptor{OverridepublicObjectintercept(Invocationinvocation)throwsThrowable{StatementHandlerhandler(StatementHandler)invocation.getTarget();BoundSqlboundSqlhandler.getBoundSql();StringoriginalSqlboundSql.getSql();// 自动加上 tenant_id 条件StringnewSqladdTenantCondition(originalSql,getCurrentTenantId());// 反射替换SQLFieldfieldboundSql.getClass().getDeclaredField(sql);field.setAccessible(true);field.set(boundSql,newSql);returninvocation.proceed();}}这种方式的好处是开发者不需要关心租户隔离中间件自动处理。缺点是只能处理简单的SELECT和UPDATE复杂的子查询、JOIN、UNION可能注入失败。我遇到过一个坑一条包含子查询的SQL拦截器在子查询的FROM子句里也注入了tenant_id条件结果子查询本意是要查全量数据做参照注入之后逻辑就错了。后来我在拦截器里加了白名单哪些表需要注入、哪些不需要都要显式配置。我最后的做法是双保险中间件自动注入加RLS兜底。中间件处理了95%的情况剩下5%的复杂SQL靠RLS兜底。两层保护任何一层漏了都不影响安全性。上线之后跑了半年没有再出现过数据串号的问题。单租户迁移实战不停机给现有数据加上tenant_id还有一种场景系统一开始是单租户的后来业务发展需要改成多租户。怎么在不停机的情况下给现有数据加上tenant_id我的做法分三步。每一步都在预发环境跑过确认没问题才上生产。第一步给所有核心表加上tenant_id字段。这一步不能用INSTANT算法因为tenant_id需要设默认值。大表加字段要走在线DDL工具。-- 用gh-ost在线加字段ALTERTABLEordersADDCOLUMNtenant_idBIGINTNOTNULLDEFAULT0;第二步按业务规则回填tenant_id。比如根据订单关联的用户表来确定每条订单属于哪个租户。这个过程要分批执行不能一次更新全表。我用的是带游标的分批更新每批一万条批次之间sleep两百毫秒避免把主库打满。回填期间业务正常运行新写入的数据在应用层已经带了tenant_id不会受影响。-- 分批回填每批一万条UPDATEorders oJOINusers uONo.user_idu.idSETo.tenant_idu.tenant_idWHEREo.tenant_id0LIMIT10000;第三步加上联合索引和RLS策略。tenant_id必须是联合索引的第一列否则查询还是会全表扫描。联合索引的列顺序很关键tenant_id在最前面后续的列按查询频率排序。我们最常见的查询是某个租户最近的订单所以索引建的是(tenant_id, created_at)。ALTERTABLEordersADDINDEXidx_tenant_order(tenant_id,created_at);整个迁移过程持续了一周。白天正常业务晚上跑回填脚本。每一步都在预发环境验证过确保不会影响线上服务。租户级资源隔离防止邻居噪音拖垮整个系统数据隔离只是第一步还要考虑资源隔离。如果某个租户跑了一条慢查询把CPU打满了其他租户的查询也跟着慢。这叫邻居噪音问题。MySQL本身没有原生的资源隔离机制但可以通过连接池和限流来实现。每个租户分配独立的连接池。连接池的大小根据租户的套餐等级来定。基础版10个连接高级版50个连接。某个租户的连接池满了只影响它自己不影响别人。在中间件层面做SQL限流。每条SQL的执行时间超过阈值就kill掉。某个租户的慢查询不会拖垮整个系统。我们在中间件里加了两层防护第一层是单条SQL超时kill设的是30秒第二层是租户级并发限制某个租户同时在跑的SQL超过上限就排队等待。两层配合基本杜绝了邻居噪音问题。避坑清单多租户隔离的四条底线第一不要只靠应用层的WHERE条件做租户隔离。人会犯错代码会改但数据库的RLS策略不会忘。用双保险中间件自动注入加RLS兜底任何一层漏了都不影响安全性。第二连接池的session变量必须每次重置。别假设连接是干净的。获取连接之后立即设置tenant_id用公共方法封装。宁可多执行一条SET也不要冒串数据的风险。第三tenant_id必须在联合索引的第一列。别的列再怎么加索引没有tenant_id在前面查询还是会全表扫描。这是性能的底线。第四迁移过程中分批执行每批都有回滚方案。给现有数据加tenant_id是高风险操作。分批跑每批一万条出问题立即回滚。别想着一口气跑完。那次客户数据串号的事故之后我把整个系统的租户隔离重新设计了一遍。现在回想起来问题的根源不是技术不到位是意识不到位。大家都觉得加个WHERE条件而已不会忘的。但系统大了、人多了、时间紧了总有疏忽的时候。数据库层面的隔离机制就是防止这种疏忽的最后一道门。朋友你在多租户架构上踩过哪些坑欢迎在评论区聊聊。我是数据库小学妹咱们下篇见