Skip to main content
backend2025年9月14日4 分钟阅读

如何构建多租户 SaaS 后端

多租户 SaaS 架构实战指南——从数据库隔离策略到认证、计费和功能开关。

saasarchitecturenodejs
如何构建多租户 SaaS 后端

当你决定应用要服务多个组织的那一刻,每一个架构决策都出现了分叉。数据库设计、认证、授权、计费,甚至日志——一切都带着一个问题:是哪个租户的?

我在非常不同的领域交付过多租户后端。在生产环境中存活下来的模式很少是白板上看起来最优雅的那些。这篇文章涵盖了真正重要的决策、没有人警告你的权衡,以及你可以直接使用的具体实现模式。

数据库隔离策略

这是第一个也是最关键的决策。如果做错了,你将面临一次涉及代码库中每个查询的迁移。

有三种主要方法:

策略 隔离性 复杂度 成本 最适合
共享数据库,共享 Schema 最低 早期 SaaS,< 100 租户
共享数据库,每租户 Schema 中等规模,受监管行业
每租户独立数据库 最高 企业客户,严格合规

共享数据库,共享 Schema

每个租户的数据都在同一张表中。你通过 tenant_id 列来区分行。这是大多数 SaaS 产品应该开始的地方。

优点是简单。一条迁移路径,一个连接池,查询简单直接。缺点是如果漏掉一个 WHERE tenant_id = ? 条件就意味着数据泄露。基础设施层面没有安全网——隔离完全依赖你的应用代码。

这种方法一直有效,直到某个大租户的查询开始影响其他所有人的性能,或者直到企业客户的合规团队问起他们的数据如何与竞争对手物理隔离。到那时候,你就需要升级了。

每租户 Schema

每个租户在共享数据库实例中获得自己的 PostgreSQL schema(或等效物)。各 schema 中的表结构相同,但物理上是分离的。你在连接时切换 search path。

这为你提供了真正的隔离,而不需要管理数十个数据库实例。迁移更复杂——你要运行 N 次——但 node-pg-migrate 或 Prisma 的多 schema 支持等工具可以相当好地处理这个问题。

需要注意的是:连接池变得棘手。如果你使用 PgBouncer,你需要谨慎处理 schema 切换与连接池的交互。我见过这导致微妙的 bug,租户 A 短暂地读取了租户 B 的 schema,因为一个连接在事务中途被归还到了连接池。

每租户独立数据库

最大隔离。每个租户拥有一个专用数据库实例(或至少一个专用的逻辑数据库)。你根据租户上下文动态路由连接。

这很昂贵且运维负担重,但有些客户愿意为此付费。医疗保健和金融服务行业通常要求这样做。隔离之外的关键优势是你可以独立地扩展、备份和恢复租户。一个嘈杂的邻居不会拖垮你的共享实例。

在实践中,大多数团队采用混合方案:大多数租户使用共享数据库,为付费的企业账户提供专用实例。

认证与租户解析

每个入站请求需要回答两个问题:这个用户是谁? 以及 他们属于哪个租户?

解析租户上下文有三种常见策略:

基于子域名: acme.yourapp.com 映射到 Acme 租户。清晰、直观,非常适合每个客户获得自己工作区的 B2B 产品。你从 Host 头中提取租户标识。

基于 JWT claim: 租户 ID 在登录时嵌入到访问令牌中。不需要基础设施层面的路由。非常适合子域名路由不实际的移动 App 和 SPA。

基于 Header: 客户端发送 X-Tenant-ID 头。简单但如果不针对认证用户的权限进行验证就很危险。仅用于内部服务间调用。

在大多数系统中,我结合使用子域名解析处理初始登录流程,JWT claims 处理后续 API 调用。中间件看起来像这样:

import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";

export async function tenantMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // 1. Try JWT claim first (authenticated requests)
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (token) {
    const payload = verifyToken(token);
    if (payload?.tenantId) {
      req.tenantId = payload.tenantId;
      return next();
    }
  }

  // 2. Fall back to subdomain resolution
  const host = req.hostname;
  const slug = host.split(".")[0];

  if (slug === "www" || slug === "app") {
    return res.status(400).json({ error: "Tenant not identified" });
  }

  const tenant = await getTenantBySlug(slug);
  if (!tenant || !tenant.isActive) {
    return res.status(404).json({ error: "Tenant not found" });
  }

  req.tenantId = tenant.id;
  next();
}

将这个中间件挂载在路由处理器之前,每个下游函数都可以访问 req.tenantId。应用的其余部分永远不需要关心租户解析如何工作——它只从请求上下文中读取 ID。

为每个数据库查询添加作用域

一旦每个请求都有了租户上下文,你需要保证它被应用到每个查询。漏掉一个作用域就是一次数据泄露。这不是你能靠开发者自觉来保证的事情。

我找到的最佳模式是一个自动强制作用域的仓储层:

import { db } from "../lib/database";

export function scopedQuery(tenantId: string) {
  return {
    async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .select("*");
    },

    async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .first();
    },

    async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
      return db(table)
        .insert({ ...data, tenant_id: tenantId })
        .returning("*")
        .then((rows) => rows[0]);
    },

    async update(
      table: string,
      where: Record<string, unknown>,
      data: Record<string, unknown>
    ): Promise<number> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .update(data);
    },

    async remove(table: string, where: Record<string, unknown>): Promise<number> {
      return db(table)
        .where({ ...where, tenant_id: tenantId })
        .del();
    },
  };
}

在路由处理器中,你永远不构建原始查询。你总是通过带作用域的接口:

app.get("/api/projects", async (req, res) => {
  const query = scopedQuery(req.tenantId);
  const projects = await query.findMany("projects", { archived: false });
  res.json(projects);
});

如果你使用像 Prisma 这样的 ORM,你可以通过中间件实现同样的效果,在每个 findManyfindFirstupdatedelete 调用中注入 tenant_id 过滤器。重点是让不安全的路径比安全的路径更难走。

租户内的基于角色的访问控制

多租户引入了两层授权模型。第一层:这个用户属于这个租户吗?第二层:他们在租户内能做什么?

我使用 tenant_memberships 表来保持简单:

CREATE TABLE tenant_memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  role VARCHAR(50) NOT NULL DEFAULT 'member',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, tenant_id)
);

角色通常是 owneradminmember,有时是 viewer 或领域特定的角色。除非你的产品真正需要,否则避免构建完整的权限矩阵——大多数 B2B SaaS 用 3-4 个角色就够了。

授权检查在租户解析之后发生:

export function requireRole(...roles: string[]) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const membership = await db("tenant_memberships")
      .where({ user_id: req.userId, tenant_id: req.tenantId })
      .first();

    if (!membership || !roles.includes(membership.role)) {
      return res.status(403).json({ error: "Insufficient permissions" });
    }

    req.userRole = membership.role;
    next();
  };
}

// Usage
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
  // only owners and admins reach this handler
});

一个能避免后续麻烦的设计选择:始终允许用户属于多个租户。即使你的产品今天是单工作区的,一旦你添加了代理账户、白标转售商或顾问模式,你就需要这个功能了。

每租户功能开关

不是每个租户都应该看到每个功能。有时你按计划层级进行门控。有时你在做渐进式发布。有时企业客户为定制功能付费。

我使用简单的 tenant_features 表而不是引入第三方功能开关服务。对于大多数 SaaS 产品,这已经足够了:

// Cache-backed feature flag check
const featureCache = new Map<string, Set<string>>();

export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
  if (!featureCache.has(tenantId)) {
    const rows = await db("tenant_features")
      .where({ tenant_id: tenantId, enabled: true })
      .select("feature_key");

    featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
  }

  return featureCache.get(tenantId)!.has(feature);
}

// Invalidate on plan change or manual toggle
export function invalidateFeatureCache(tenantId: string) {
  featureCache.delete(tenantId);
}

在路由处理器中:

app.post("/api/analytics/export", async (req, res) => {
  if (!(await hasFeature(req.tenantId, "analytics_export"))) {
    return res.status(403).json({
      error: "This feature is not available on your current plan",
      upgrade: true,
    });
  }
  // proceed with export
});

当租户升级计划时,你翻转相关的功能行并失效缓存。简单、可审计、无外部依赖。

计费集成

Stripe 是这个领域的标准,理由充分。映射很自然:每个租户一个 Stripe Customer,每个租户一个 Subscription,计划层级映射到 Products 和 Prices。

关键设计规则:将 Stripe Customer ID 存储在租户记录上,而不是用户记录上。 计费属于组织,而不是输入信用卡的那个人。

Webhook 流程如下:

  1. checkout.session.completed ——将 Stripe Customer ID 关联到租户,激活订阅。
  2. invoice.paid ——延长计费周期,更新 tenant.paidUntil
  3. invoice.payment_failed ——将租户标记为逾期,发送催收邮件。
  4. customer.subscription.updated ——同步计划层级变更,相应更新功能开关。
  5. customer.subscription.deleted ——降级到免费层或暂停。

关键是幂等性。Stripe 可以并且会多次发送同一个 Webhook。每个处理器都应该对同一事件负载安全地运行两次。

数据隔离与安全

除了查询作用域之外,有几件不那么明显的事情你应该尽早做好:

行级安全(RLS): 如果你使用 PostgreSQL,启用 RLS 作为第二道防线。即使你的应用代码有 bug,数据库本身也会拒绝返回不匹配当前租户上下文的行。在连接时设置 app.current_tenant 并针对它编写策略。

日志上下文: 每一行日志都应该包含 tenantId。当凌晨 3 点出问题时,你需要知道哪个租户受到影响,而不需要在请求追踪中翻找。结构化日志中内置租户字段到日志上下文中让这变得自动化。

备份和数据删除: 租户会流失,有些会援引 GDPR 或数据删除权利。如果所有数据在共享表中,清除单个租户意味着跨每张表精心构建 DELETE 语句。使用 schema 或数据库隔离时,只需 DROP SCHEMADROP DATABASE。从第一天就为此做好规划。

每租户速率限制: 全局速率限制不够。一个租户的批量导入不应该吃掉另一个租户的速率额度。按 tenantId 设定速率限制作用域,而不仅仅是按 IP 或用户。

静态加密: 对于敏感行业,使用每租户密钥对静态数据进行加密。这增加了密钥管理的复杂性,但这意味着撤销租户的访问就像销毁他们的密钥一样简单。离场后磁盘上不会留下可读的数据。

最后的思考

多租户不是你安装的一个库。它是一组触及你技术栈每一层的架构决策,从数据库到 CDN。好消息是这些模式已经被充分理解。棘手的部分是根据你的特定市场和阶段应用正确级别的隔离。

从共享一切加上一个严格的作用域层开始。在客户需要时增加隔离边界。不要为你还没有的合规要求过度工程化,但确保你的架构可以在不重写的情况下向更严格的隔离演进。

我构建过从餐饮平台到医疗 SaaS 的多租户系统——每个都有自己的隔离和扩展要求。这篇文章中的模式是在所有这些系统中都经受住考验的。

DU

Danil Ulmashev

Full Stack Developer

有兴趣一起合作吗?