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,从机制上降低漏写概率。
几个容易翻车的边界
隔离最难的不是主干,是那些"看起来该共享"的地方:
| 场景 | 处理 | 为什么 |
|---|---|---|
黑名单blocklist | tenantId可为 NULL = 平台级全局共享 | 某些黑名单要对所有租户生效,不能按租户切 |
| 数据导出 R2 文件 | key 用exports/{tenantId}/{taskId}.csv | 文件落在对象存储,隔离靠路径前缀,别让 A 租户下到 B 的文件 |
| 平台超管(PSA) | 无tid,访问直接TENANT_FORBIDDEN | 超管走独立管理端点,显式指定目标租户,绝不混用租户路由 |
审计日志auditLogs | tenantId可 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 优化校阅,转发请注明首发地址,谢谢大家!