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

先定路线:为什么选共享库共享表
多租户隔离通常有三条路线,先说清楚我为什么选第三条:
| 方案 | 做法 | 隔离强度 | 运维/成本 | 我的取舍 |
|---|---|---|---|---|
| 独立数据库 | 每租户一套库 | 物理级最强 | 运维爆炸、成本爆炸 | 中小团队不现实 |
| 独立 Schema | 同库多 schema | 较强 | 迁移、备份复杂 | 仍偏重 |
共享库共享表 + tenantId 列 | 所有表带 tenantId | 逻辑隔离(靠代码保证) | 一份库、一份部署 | 选它,用代码纪律补强 |
我的判断是:在 Workers + D1 这种边缘架构下,独立库/schema 的运维成本根本扛不住,而共享表 + tenantId 配合得当,逻辑隔离足够稳。剩下的事,就是用代码把"每句话都带上 tenantId"变成铁律。
第一道防线:Schema 层把 tenantId 焊死在表上
隔离的第一关不是代码,是表结构。我这里每一个业务表都有 tenantId 列,且大部分是 notNull(平台级共享表除外,后面边界里讲):

export const leads = 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.ts
export const tenantMiddleware: MiddlewareHandler<AppBindings> = async (c, next) => {
const user = c.get("user");
if (!user) throw err("AUTH_FORBIDDEN");
const db = getDb(c.env);
await checkTenantStatus(db, user.tenantId); // 状态门控
c.set("tid", user.tenantId ?? ""); // 注入 tid 给后续路由用
await next();
};
注意 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) => {
const tid = requireTid(c); // 平台超管无 tid 直接 TENANT_FORBIDDEN,逼它走独立端点
const db = getDb(c.env);
// 所有查询的共同底座:租户 + 未删除
const base = [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 优化校阅,转发请注明首发地址,谢谢大家!
281

被折叠的 条评论
为什么被折叠?



