资讯动态

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

发布时间:2026/8/14 11:11:13 来源:尧图企业网站定制
Node 后端实战 · 多租户 SaaS 的数据隔离让 A 租户永远看不到 B 租户的一行数据各位看官多租户 SaaS 最怕什么不是宕机不是慢是数据串了。A 公司的销售在系统里翻线索手指一滑翻到了 B 公司客户的手机号——这种事要是发生轻则丢客户信任重则上数据泄露的新闻。我这个后端跑在 Cloudflare Workers D1 上从第一天起就把租户隔离当成生死线来设计。今天不聊花活就聊这套隔离是怎么一层一层焊死的Schema、中间件、查询三层防线外加几个容易翻车的边界。先定路线为什么选共享库共享表多租户隔离通常有三条路线先说清楚我为什么选第三条方案做法隔离强度运维/成本我的取舍独立数据库每租户一套库物理级最强运维爆炸、成本爆炸中小团队不现实独立 Schema同库多 schema较强迁移、备份复杂仍偏重共享库共享表 tenantId列所有表带tenantId逻辑隔离靠代码保证一份库、一份部署选它用代码纪律补强我的判断是在 Workers D1 这种边缘架构下独立库/schema 的运维成本根本扛不住而共享表 tenantId配合得当逻辑隔离足够稳。剩下的事就是用代码把每句话都带上tenantId变成铁律。第一道防线Schema 层把tenantId焊死在表上隔离的第一关不是代码是表结构。我这里每一个业务表都有tenantId列且大部分是notNull平台级共享表除外后面边界里讲exportconstleadssqliteTable(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:MiddlewareHandlerAppBindingsasync(c,next){constuserc.get(user);if(!user)throwerr(AUTH_FORBIDDEN);constdbgetDb(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){consttidrequireTid(c);// 平台超管无 tid 直接 TENANT_FORBIDDEN逼它走独立端点constdbgetDb(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 优化校阅转发请注明首发地址谢谢大家

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价