Node 后端实战 · 多租户 SaaS 的数据隔离:让 A 租户永远看不到 B 租户的一行数据
2026/8/14 11:11:05 网站建设 项目流程

Node 后端实战 · 多租户 SaaS 的数据隔离:让 A 租户永远看不到 B 租户的一行数据

各位看官,多租户 SaaS 最怕什么?不是宕机,不是慢,是数据串了。A 公司的销售在系统里翻线索,手指一滑翻到了 B 公司客户的手机号——这种事要是发生,轻则丢客户信任,重则上数据泄露的新闻。我这个后端跑在 Cloudflare Workers + D1 上,从第一天起就把"租户隔离"当成生死线来设计。今天不聊花活,就聊这套隔离是怎么一层一层焊死的:Schema、中间件、查询,三层防线,外加几个容易翻车的边界。

先定路线:为什么选共享库共享表

多租户隔离通常有三条路线,先说清楚我为什么选第三条:

方案做法隔离强度运维/成本我的取舍
独立数据库每租户一套库物理级最强运维爆炸、成本爆炸中小团队不现实
独立 Schema同库多 schema较强迁移、备份复杂仍偏重
共享库共享表 +tenantId所有表带tenantId逻辑隔离(靠代码保证)一份库、一份部署选它,用代码纪律补强

我的判断是:在 Workers + D1 这种边缘架构下,独立库/schema 的运维成本根本扛不住,而共享表 +tenantId配合得当,逻辑隔离足够稳。剩下的事,就是用代码把"每句话都带上tenantId"变成铁律。

第一道防线:Schema 层把tenantId焊死在表上

隔离的第一关不是代码,是表结构。我这里每一个业务表都有tenantId列,且大部分是notNull(平台级共享表除外,后面边界里讲):

exportconstleads=sqliteTable("leads",{// ... 其他字段tenantId:text("tenant_id").notNull(),// 所属租户,非空// ...});// 复合索引:租户 + 各高频过滤维度,保证"按租户过滤"不扫全表idxLeadsTenantStatus:index("idx_leads_tenant_status").on(t.tenantId,t.status),idxLeadsTenantOwner:index("idx_leads_tenant_owner").on(t.tenantId,t.ownerId),idxLeadsTenantCat:index("idx_leads_tenant_category").on(t.tenantId,t.categoryId),idxLeadsTenantPhone:index("idx_leads_tenant_phone").on(t.tenantId,t.phone),// ...

这里有个特别值得说的设计——租户内唯一约束

// 同一租户、同一项目、同一手机号,只算一条线索(软删除外)uniqLeadsTenantProjectPhone:uniqueIndex("uniq_leads_tenant_project_phone").on(t.tenantId,t.projectId,t.phone).where(isNull(t.deletedAt)),

tenantId进唯一索引的复合键,意味着"去重"天然限定在自己的租户内——你绝不会因为加了个全局唯一约束,就把别家租户的线索误判成重复。这条约束同时又是查询索引,一举两得。

Schema 层的索引策略可以归纳成一张表,基本是"租户 + 任何会被单独过滤的维度"都建复合索引:

索引类型示例目的
租户 + 状态idx_leads_tenant_status列表按状态筛
租户 + 负责人idx_leads_tenant_owner“我的线索”
租户 + 分类/项目idx_leads_tenant_category项目内视图
租户 + 手机号idx_leads_tenant_phone查重、按号码找
租户内唯一uniq_leads_tenant_project_phone防租户内重复入库

第二道防线:中间件注入tid+ 租户状态门控

表有了tenantId,谁来给每个请求贴上"你是哪个租户"?靠登录时签进 JWT 的tid(这点在上一篇 token 版本号里讲过),再由租户中间件统一注入到上下文:

// middleware/tenant.tsexportconsttenantMiddleware:MiddlewareHandler<AppBindings>=async(c,next)=>{constuser=c.get("user");if(!user)throwerr("AUTH_FORBIDDEN");constdb=getDb(c.env);awaitcheckTenantStatus(db,user.tenantId);// 状态门控c.set("tid",user.tenantId??"");// 注入 tid 给后续路由用awaitnext();};

注意checkTenantStatus这一步——它不只管隔离,还管"这个租户还活不活着"。状态判定有严格优先级,顺序错了就会出 bug:

优先级条件结果
① 最高status === suspended(手动暂停)TENANT_SUSPENDED
now >= expireAt + 宽限期TENANT_EXPIRED
expireAt <= now < 宽限期结束TENANT_IN_GRACE(宽限期内仍可用)

手动暂停必须压在到期门控之上——否则运营手动关停一个欠费租户,结果因为"还没到到期日"又给放进来,这逻辑就拧了。这个顺序是我踩过坑之后钉死的。而且登录接口也复用了同一个checkTenantStatus保证"停掉的租户连登录都进不来",不是进了系统才拦。

第三道防线:查询层,每一句都带上tenantId

前面两层只是"贴标签"和"建结构",真正的隔离发生在每一次查询。我的规矩很简单也很难耍滑:路由里取tid,然后每个 where 都必须and(eq(tenantId, tid), ...)

// routes/tenant/leads.ts —— 列表查询tenantLeadsRoutes.get("/",async(c)=>{consttid=requireTid(c);// 平台超管无 tid 直接 TENANT_FORBIDDEN,逼它走独立端点constdb=getDb(c.env);// 所有查询的共同底座:租户 + 未删除constbase=[eq(leads.tenantId,tid),isNull(leads.deletedAt)];// ...各种过滤往 base 里 push...});

而且关联查询一个都不许漏。看下面这段,取一条线索时连带它的客户、跟进、通话、日程,每一个子查询都重复eq(xxx.tenantId, tid)

.where(and(eq(leads.id,id),eq(leads.tenantId,tid),isNull(leads.deletedAt)))// 关联客户.where(and(eq(customers.id,lead.customerId),eq(customers.tenantId,tid),isNull(customers.deletedAt)))// 关联通话记录.where(and(eq(callRecords.leadId,id),eq(callRecords.tenantId,tid),isNull(callRecords.deletedAt)))// 关联日程.where(and(eq(schedules.leadId,id),eq(schedules.tenantId,tid),isNull(schedules.deletedAt)))

为什么这么啰嗦也要每句写?因为多租户隔离的事故,几乎全是"顺手写一个 join/子查询,忘了带tenantId"造成的。靠自觉不靠谱,靠的是把base条件抽出来复用 + 代码评审盯死 + 单元测试断言返回数据确实属于该租户。我把base数组当标配底座,所有过滤都往里 push,从机制上降低漏写概率。

几个容易翻车的边界

隔离最难的不是主干,是那些"看起来该共享"的地方:

场景处理为什么
黑名单blocklisttenantId可为 NULL = 平台级全局共享某些黑名单要对所有租户生效,不能按租户切
数据导出 R2 文件key 用exports/{tenantId}/{taskId}.csv文件落在对象存储,隔离靠路径前缀,别让 A 租户下到 B 的文件
平台超管(PSA)tid,访问直接TENANT_FORBIDDEN超管走独立管理端点,显式指定目标租户,绝不混用租户路由
审计日志auditLogstenantId可 NULL(平台操作)谁干的、哪个租户的操作,要能查也要能跨租户检索

尤其是平台超管那条——requireTid在取不到tid时直接抛TENANT_FORBIDDEN,意思是"你这个全局身份别来租户路由凑热闹,去你该去的平台端点"。这把"全局身份误入租户上下文"的风险在入口就掐灭了。

小结

回看这三层,你会发现隔离从来不是某个神奇开关,而是**结构(Schema)+ 流程(中间件)+ 纪律(查询)**叠出来的:表上焊死tenantId,上下文注入tid,每个查询强制过滤,再加边界处的显式规则。任何一层单拎出来都不保险,三层一起才敢说"A 租户永远看不到 B 租户的一行数据"。各位看官如果也在做多租户,这套三层防线可以直接照抄,重点是别偷懒省掉查询层那句eq(tenantId, tid)——省下的那一行,可能就是明天的新闻。

发财的小手点个小赞,下一篇聊聊边缘架构下的限流与审计。

相关阅读

  • Node 后端实战 · 为什么用 Cloudflare Workers + D1 扛起了整个多租户 SaaS 后端?架构决策全景复盘
  • Node 后端实战 · Cloudflare Workers 踩坑实录:TOML、D1 默认 local、CORS 与部署排障
  • Node 后端实战 · D1 那些坑:100 参数上限逼出的批量写入重构
  • Node 后端实战 · JWT 双密钥轮转与 token 版本号:多租户 SaaS 如何不停机换密钥、一键踢全设备
  • Mac 本地部署 AI 生图:从零跑通 FLUX 与 Z-Image 的完整步骤(附完整脚本)
  • NodeJS Koa 后端用户会话管理,JWT, Session,长短Token,本文一次性讲明白
  • node 后端和浏览器前端,有关 RSA 非对称加密的完整实践, 前后端匹配的代码演示
  • Nodejs 实现 Mysql 数据库的全量备份的代码演示
  • 安装和配置 Nginx 和 Mysql —— 一步一步配置 Ubuntu Server 的 NodeJS 服务器详细实录6

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询