
那天下午运营群里弹出一张截图某婚介所的经营者在后台会员列表里翻到了另一家机构的会员资料。第一反应是对方看错了拉日志一看确实是我们返回的数据。请求参数没问题登录态也没问题问题出在一条查询上SQL 里少了tenant_id条件返回了全库的数据。这是典型的多租户数据隔离事故。做 SaaS 绕不开它。行级隔离是多租户方案里最常用的一种所有租户的数据放在同一批表里靠每行一个tenant_id字段区分。开发快成本低但安全完全建立在每条查询都带上租户条件这个假设上。假设只要破一次就是跨租户数据泄露。这篇复盘那次事故前后的补课过程聊三个坑和对应的修法。坑一靠人记住加条件迟早会漏事故的直接原因很普通一个新同事写统计报表手写了 SQL绕过了 ORM忘了拼租户条件。代码审查也没拦住因为谁也不可能逐条比对每条 SQL 的 WHERE 子句。结论很直接只要多租户的数据隔离依赖开发者的自觉它就一定会失效。正确的做法是让默认路径就是安全路径。我们在数据访问层做了两件事。第一租户上下文在请求入口统一解析之后全链路不再手动传// 请求入口中间件解析一次全局可用app.use(async (ctx, next) { // 只信任登录态里的租户归属绝不从请求参数里读 tenant_id ctx.tenantId await resolveTenantFromToken(ctx.headers.authorization); if (!ctx.tenantId) throw new AuthError(missing tenant context); await next();});第二ORM 层给所有业务模型挂全局过滤器查询自动追加租户条件// 所有租户级模型的基类class TenantModel extends Model { static register(scope) { this.addScope(default, { where: { tenantId: scope.tenantId }, }); }}// 业务代码写 Member.findAll() 时实际执行的是// SELECT ... FROM member WHERE tenant_id ? AND ...要跨租户操作的场景比如平台运营后台必须显式声明unscoped()并在代码审查里重点盯这个关键词。默认收紧、按需放开比默认放开、靠记得收紧可靠得多。坑二只盯着数据库缓存和文件在裸奔补完查询层我们做了一轮多租户隔离自查把链路从头捋了一遍。发现隔离的根本不止数据库一处。缓存就是多租户场景下的重灾区早期为了省事缓存 key 直接用的查询条件哈希没带租户标识。租户 A 先查了会员列表缓存里存了他的数据租户 B 用相同查询条件过来命中了同一条缓存读到的就是 A 的数据。数据库隔离做得再干净缓存这一层照样串号。修法是定死一条规矩所有缓存 key 必须以租户 ID 开头由统一封装的缓存客户端自动拼业务代码不允许手写 key。member:list:{tenantId}:{条件哈希}member:detail:{tenantId}:{memberId}文件存储同理。上传目录早期是按业务类型分的/upload/avatar/xxx.jpg任何租户拿到 URL 都能访问。改成了租户前缀加签名 URL/upload/t{tenantId}/avatar/{fileId}?sign...expire...后端在签发时校验文件归属租户与当前请求租户一致签名过期即失效。改造完我们画了一张请求链路图把租户上下文必须生效的位置全部标了出来渲染错误:Mermaid 渲染失败: Parse error on line 1: graph LR A[客户端请求] -- B[网关: 解 ----------^ Expecting NEWLINE, got NODE_STRING多租户的数据隔离是全链路的事数据库、缓存、对象存储、消息队列每一处持久化或传递数据的环节都要过一遍。漏掉任何一层前面的功夫都白费。坑三没有试图串号的测试隔离就是口头承诺修完之后我们补了最后一环把多租户隔离从一句口头承诺变成可回归的测试。专门写了一组跨租户访问用例故意模拟攻击路径断言全部失败describe(跨租户隔离,(){it(租户 A 的登录态查不到租户 B 的会员,async(){constresawaitrequest(app).get(/api/members/${memberOfTenantB.id}).set(Authorization,tokenOfTenantA);expect(res.status).toBe(404);// 不是 403按不存在处理不泄露存在性 }); it(租户 A 不能读取租户 B 的文件签名, async () { const res await request(app) .get(/files/${fileOfTenantB.id}) .set(Authorization, tokenOfTenantA); expect(res.status).toBe(404); });});一个细节跨租户读取统一返回 404 而不是 403。403 等于告诉对方这个资源存在只是你没权限对数据探测是不必要的提示。当作不存在处理信息量最小。这组测试进了 CI任何改动如果让隔离失效合并会直接被挡住。比事故之后复盘一百次都有用。行级隔离的性能账也要提前算隔离做对了性能的债才刚开始算。行级隔离把所有租户放进同一批表意味着大家共享同一套索引和连接池几处细节得提前处理。索引是第一件事也是多租户表设计的第一条铁律。所有租户级表复合索引的第一列必须留给tenant_id否则按租户查数据就是全表扫描-- 反例查询带 tenant_id 但索引不含它全表扫KEY idx_status (status);-- 正例租户条件永远在索引最左KEY idx_tenant_status (tenant_id, status, created_at),KEY idx_tenant_phone (tenant_id, phone_hash)慢查询要按租户拆开看。多租户行级隔离下一个大租户的慢查询会拖慢整张表殃及所有租户。我们的做法是监控按租户分维度统计数据量明显偏大的租户单独跟踪等它大到影响别人再迁去独立库。隔离方案不是终身制可以随业务长大再换。还有两个容易漏的地方异步任务和导出。异步任务入队时必须把租户上下文一起带上消费端恢复上下文再执行否则后台任务查库时要么报错、要么裸查。导出报表是事故高发区那次事故本身就出在报表链路上后来所有导出接口都强制走同一套带过滤的查询封装不允许手写 SQL。复盘机制替人扛责任那次事故之后我们总结出一套顺序如果重来一次会按这个顺序做先定多租户模型一个租户是什么一家门店、一个独立经营者落到tenant表业务数据全部挂靠。这个定义晚一天定后账越难还。上下文先行登录态里带上租户归属请求入口解析一次全链路只读。默认过滤ORM 全局 scope 自动追加条件跨租户必须显式声明并接受审查。全链路自查数据库之外缓存 key、文件目录、异步任务、导出报表逐个过。测试兜底把试图串号的用例放进 CI隔离失效即构建失败。行级隔离本身没有问题问题在于它把安全责任压在了每个人的记性上。机制能替人扛的责任就不要留给人。做多租户 SaaS不必一上来就独立数据库但行级隔离的这五步值得在第一个客户进来之前做完。写在最后文中方案来自我们在婚恋行业 SaaS云中红线的一线实践踩过的坑不少欢迎交流。