SaaS 多租户架构
何时使用此技能
- 用户正在构建多个客户共享同一数据库的 SaaS 应用
- 用户询问租户隔离、行级安全或数据泄露防护
- 用户需要将每条数据库查询限定到特定租户,而不手动编写 WHERE 子句
- 用户询问共享 schema vs 每租户 schema vs 每租户数据库的权衡取舍
- 用户正在实现需要跨租户访问数据的管理端点
- 用户需要在现有的单租户应用中添加
tenant_id列 - 用户询问 PostgreSQL RLS 策略用于租户隔离
- 用户正在 Express、Fastify 或 Next.js API 路由中构建租户感知中间件
不适用场景:
- 用户构建的是没有共享基础设施的单用户应用
- 用户只询问认证而不涉及租户作用域(应使用认证技能)
- 用户需要通用数据库 schema 设计,无多租户需求
核心工作流
确定租户模型。询问用户的规模预期和隔离需求。对于 1000 个租户以下的大多数 SaaS 应用,在每张表上加
tenant_id列的共享 schema 是正确的默认方案。每租户 schema 会增加运维开销(迁移要执行 N 次)。每租户数据库只有在租户有合规性数据驻留要求时才合理。在每张租户作用域表中添加
tenant_id。该列必须为NOT NULL,类型为UUID或TEXT,并纳入每个复合索引。绝不允许缺少此列的租户作用域表存在——缺失的tenant_id就是潜在的数据泄露。配置 PostgreSQL 行级安全(RLS)。在每张租户作用域表上创建策略,通过
current_setting('app.current_tenant_id')过滤行。这充当数据库级别的安全网——即使应用代码遗漏了 WHERE 子句,RLS 也会阻止跨租户读取。构建租户感知中间件。在每个请求开始时,从已认证的会话或 JWT claims 中提取
tenant_id。通过事务内的SET LOCAL app.current_tenant_id = '...'设置到数据库连接上。该请求中后续的所有查询自动继承租户作用域。对所有 ORM 查询进行租户作用域限定。使用 Prisma 时,应用全局中间件向每个
findMany、findFirst、update和delete调用注入where: { tenantId }。使用 Drizzle 时,创建包含租户过滤器的基础查询构建器。绝不要依赖开发人员手动添加过滤器。处理租户感知迁移。每个新表迁移都必须包含
tenant_id列。编写 lint 规则或 CI 检查,拒绝任何创建表时缺少tenant_id的迁移,除非该表被显式标记为全局表(如plans、feature_flags)。单独构建跨租户管理路由。跨租户聚合数据的管理端点必须通过
SET LOCAL role = 'admin_bypass'或专用数据库角色显式绕过 RLS。这些路由必须受独立的管理认证流程保护——绝不要复用租户用户会话进行管理访问。实现租户配置。新客户注册时,创建租户记录、填充默认数据(角色、设置、引导状态)并分配创始用户。将此操作包装在数据库事务中,避免部分配置留下孤立记录。
示例
示例 1:用于租户隔离的 PostgreSQL RLS 策略
-- Enable RLS on the table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
-- Policy: users can only see rows where tenant_id matches the session variable
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Policy for INSERT: new rows must match the current tenant
CREATE POLICY tenant_insert ON projects
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
示例 2:按请求设置租户上下文的 Express 中间件
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function tenantMiddleware(req, res, next) {
const tenantId = req.auth?.tenantId; // extracted from JWT during auth
if (!tenantId) return res.status(403).json({ error: "No tenant context" });
const client = await pool.connect();
try {
await client.query("BEGIN");
// Use set_config — SET LOCAL does not accept bind placeholders ($1)
await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [tenantId]);
req.db = client;
req.tenantId = tenantId;
// Cleanup on response finish — guarantees release even if handler skips next()
res.on("finish", async () => {
try { await client.query("COMMIT"); } catch { await client.query("ROLLBACK"); }
client.release();
});
next();
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
client.release();
next(err);
}
}
示例 3:自动租户作用域的 Prisma 中间件
import { PrismaClient } from "@prisma/client";
// Tables that do NOT have tenant_id (global tables)
const GLOBAL_TABLES = new Set(["Plan", "FeatureFlag", "SystemConfig"]);
function createTenantPrisma(tenantId: string): PrismaClient {
const prisma = new PrismaClient();
prisma.$use(async (params, next) => {
if (GLOBAL_TABLES.has(params.model ?? "")) return next(params);
// Initialize args.where — Prisma passes undefined args for calls like findMany()
params.args = params.args ?? {};
params.args.where = params.args.where ?? {};
// Inject tenant filter on reads (skip findUnique — it only accepts unique-field selectors)
if (["findMany", "findFirst", "count", "aggregate"].includes(params.action)) {
params.args.where = { ...params.args.where, tenantId };
}
// Inject tenant_id on creates
if (["create", "createMany"].includes(params.action)) {
params.args.data = params.args.data ?? {};
if (params.action === "createMany") {
params.args.data = params.args.data.map((d: any) => ({ ...d, tenantId }));
} else {
params.args.data = { ...params.args.data, tenantId };
}
}
// Scope updates and deletes
if (["update", "updateMany", "delete", "deleteMany"].includes(params.action)) {
params.args.where = { ...params.args.where, tenantId };
}
return next(params);
});
return prisma;
}
绝对禁止
查询租户作用域表时绝不能缺少
tenant_id过滤器。 即使 ORM 中间件会处理,原生 SQL 查询会完全绕过中间件。每条原生查询都必须包含WHERE tenant_id = $1或依赖 RLS。一条不带作用域的SELECT * FROM invoices就会泄露所有客户的账单数据。绝不能仅在应用会话中存储
tenant_id而不在数据库层强制执行。 应用层过滤只是建议,RLS 才是强制执行。如果中间件中的 bug 跳过了租户过滤器,只有 RLS 能阻止数据泄露。两层都要启用。绝不能对租户作用域资源使用自增整数 ID。 顺序 ID(如
invoice #1042)允许攻击者通过递增 ID 枚举其他租户的资源。所有租户作用域主键使用 UUID。整数 ID 仅保留给内部表。绝不能让租户用户访问管理聚合端点。 像
GET /admin/metrics这样跨所有租户查询的路由,绝不能用普通租户 JWT 访问。跨租户路由使用独立的认证机制(API key、不同签发者的管理员角色声明)。绝不能在迁移连接上启用 RLS 运行迁移。 迁移用户需要创建表、添加列和修改策略。如果迁移连接上 RLS 处于激活状态,
ALTER TABLE命令可能静默失败或仅影响"当前租户"的视图。迁移使用专用超级用户或bypassrls角色。使用
SET LOCAL时绝不能跨租户共享连接池。 在事务中使用SET LOCAL app.current_tenant_id时,该设置仅作用于事务范围。但如果前一个请求的事务未正确提交或回滚,连接会带着过期的租户上下文返回连接池。清理路径中务必执行RESET app.current_tenant_id。
边界情况
租户删除与数据保留。 租户取消订阅时,不能简单执行
DELETE FROM tenants WHERE id = $1。外键级联在大数据集上可能超时。应软删除租户(设置deleted_at)、撤销所有用户会话,然后运行后台任务在数小时或数天内批量删除租户数据。GDPR/合规的租户数据导出。 租户请求完整数据导出时,需要查询每张租户作用域表中该
tenant_id的数据并打包。构建所有租户作用域表的注册表(解析迁移文件或维护清单),确保导出任务不会遗漏在导出功能构建后新增的表。租户间共享资源。 部分功能需要共享状态——例如市场场景中租户 A 的产品对租户 B 的用户可见。这些表需要不同的 RLS 策略:读取访问公开(无租户过滤器),但写入访问仍限定到所属租户。将这些建模为
owner_tenant_id而非tenant_id。租户感知后台任务。 cron 任务或队列 worker 处理任务时,没有 HTTP 请求可供提取
tenant_id。任务载荷必须包含tenant_id,worker 在处理前必须设置数据库会话变量。绝不要在没有租户上下文的情况下运行后台任务——它们要么因 RLS 失败,要么完全绕过 RLS。每租户 schema 的连接池耗尽。 如果每个租户使用独立的 PostgreSQL schema,且每个 schema 需要独立的连接池,500 个租户意味着 500 个连接池。这会快速耗尽
max_connections。使用 PgBouncer 等连接池器的事务模式,或在触及此瓶颈前切换到共享 schema。
最佳实践
创建
tenants表作为唯一数据源。 每张表中的tenant_id外键都指向tenants.id。包含name、slug(用于子域路由)、plan_id、created_at和deleted_at列。这张表是整个数据模型的根。将
tenant_id作为每个复合索引的第一列。 PostgreSQL 使用复合索引的最左前缀匹配。(tenant_id, created_at)索引同时服务于"租户 X 的所有条目"和"租户 X 按日期排序的条目"。(created_at, tenant_id)索引仅帮助跨所有租户的日期范围查询。使用子域名或路径前缀进行租户路由。
acme.yourapp.com或yourapp.com/org/acme都可以。在边缘层(中间件或反向代理)将子域名或路径映射到tenant_id查询。此查询应被缓存(Redis 或带 60 秒 TTL 的内存缓存),因为每个请求都会执行。显式区分租户作用域表和全局表。 维护一个列表(代码常量或数据库表),标明哪些表是全局表(无
tenant_id)、哪些是租户作用域表。在 ORM 中间件、迁移 linter 和数据导出任务中使用此列表。如果某张表不在任何一个列表中,CI 检查应该失败。种子数据中至少用 3 个租户测试。 开发中单个租户会隐藏所有多租户 bug。两个租户会隐藏第一个租户数据泄露到第二个但反向不会的 bug。三个租户才能捕获只在多个对等体之间出现的排序和过滤 bug。
按租户限流和配额,而非全局。 全局限流 1000 请求/分钟意味着一个活跃租户可以耗尽所有人的配额。使用 Redis 键模式
ratelimit:{tenant_id}:{endpoint}配合滑动窗口计数器实现每租户限流。
限制
- 仅在任务明确匹配上述范围时使用此技能。
- 不要将输出视为环境特定验证、测试或专家评审的替代品。
- 如果缺少必要输入、权限、安全边界或成功标准,请停下来请求澄清。