那天下午,运营群里弹出一张截图:某婚介所的经营者在后台会员列表里,翻到了另一家机构的会员资料。第一反应是对方看错了,拉日志一看,确实是我们返回的数据。请求参数没问题,登录态也没问题,问题出在一条查询上: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=...后端在签发时校验文件归属租户与当前请求租户一致,签名过期即失效。改造完我们画了一张请求链路图,把"租户上下文必须生效"的位置全部标了出来:
多租户的数据隔离是全链路的事,数据库、缓存、对象存储、消息队列,每一处持久化或传递数据的环节都要过一遍。漏掉任何一层,前面的功夫都白费。
坑三:没有"试图串号"的测试,隔离就是口头承诺
修完之后我们补了最后一环:把多租户隔离从一句口头承诺变成可回归的测试。专门写了一组跨租户访问用例,故意模拟攻击路径,断言全部失败:
describe('跨租户隔离',()=>{it('租户 A 的登录态查不到租户 B 的会员',async()=>{constres=awaitrequest(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(云中红线)的一线实践,踩过的坑不少,欢迎交流。