所有 SaaS 应用都会面临同一个核心问题:如何在单个数据库中为多个租户安全高效地存储数据?解答将直接影响安全性、性能、运维复杂度与扩展能力。本文涵盖三种主流架构设计,并给出 PostgreSQL、NestJS 与 Prisma ORM 的具体实战方案。
三种主流架构模型
1. Shared Tables (共享表 / Pool 模型)
所有 Tenants (租户) 共享相同的数据库表,通过 tenant_id 字段进行隔离。
flowchart TD
subgraph Database ["共享数据库 (saas_db)"]
subgraph Tables ["共享表 (按 tenant_id 过滤)"]
Users["表: users (id, tenant_id, name, email)"]
Orders["表: orders (id, tenant_id, user_id, amount)"]
end
end
TenantAcme["客户 / 租户: acme"] -->|WHERE tenant_id = 'acme'| Users
TenantGlobex["客户 / 租户: globex"] -->|WHERE tenant_id = 'globex'| Users
2. Separate Schemas (独立 Schema / Bridge 模型)
每个 Tenant 在同一个 PostgreSQL Database 中拥有独立的 Schema。
flowchart TD
subgraph DB ["Database: saas_db"]
subgraph SchemaAcme ["Schema: acme"]
UsersA["users"]
OrdersA["orders"]
end
subgraph SchemaGlobex ["Schema: globex"]
UsersG["users"]
OrdersG["orders"]
end
end
TenantAcme["租户: acme"] -->|search_path = acme| SchemaAcme
TenantGlobex["租户: globex"] -->|search_path = globex| SchemaGlobex
3. Separate Databases (独立数据库 / Silo 模型)
每个 Tenant 拥有完全独立的 PostgreSQL Database。
flowchart TD
subgraph Server ["PostgreSQL 实例"]
subgraph DBAcme ["Database: tenant_acme"]
UsersA["users"]
OrdersA["orders"]
end
subgraph DBGlobex ["Database: tenant_globex"]
UsersG["users"]
OrdersG["orders"]
end
end
TenantAcme["租户: acme"] --> DBAcme
TenantGlobex["租户: globex"] --> DBGlobex
方案 1: Shared Tables (共享表)
PostgreSQL DDL 与 RLS 实现
-- 创建包含 tenant_id 的表
CREATE TABLE tenants (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
CREATE TABLE users (
id SERIAL PRIMARY KEY,
tenant_id VARCHAR(50) NOT NULL REFERENCES tenants(id),
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT now(),
UNIQUE(tenant_id, id),
UNIQUE(tenant_id, email) -- 按租户唯一的邮箱
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
tenant_id VARCHAR(50) NOT NULL REFERENCES tenants(id),
user_id INTEGER NOT NULL,
amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT now(),
FOREIGN KEY (tenant_id, user_id) REFERENCES users(tenant_id, id)
);
-- 创建包含 tenant_id 的联合索引
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
CREATE INDEX idx_orders_tenant_user ON orders(tenant_id, user_id);
-- 开启行级安全策略 (Row Level Security / RLS)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 创建应用角色与 RLS 策略
CREATE ROLE app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant'))
WITH CHECK (tenant_id = current_setting('app.current_tenant'));
CREATE POLICY tenant_isolation_orders ON orders
USING (tenant_id = current_setting('app.current_tenant'))
WITH CHECK (tenant_id = current_setting('app.current_tenant'));
NestJS 与 Prisma 的 RLS 集成
要在 Prisma 中生效 RLS,需要在每次查询执行前运行 SET LOCAL app.current_tenant。在 NestJS 中,可以通过 AsyncLocalStorage 结合 Prisma 扩展 ($extends) 实现:
1. 基于 AsyncLocalStorage 的租户上下文 (tenant.context.ts)
import { AsyncLocalStorage } from 'async_hooks';
export const tenantStorage = new AsyncLocalStorage<string>();
2. NestJS 中间件 (tenant.middleware.ts)
import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { tenantStorage } from './tenant.context';
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
throw new BadRequestException('请求头中缺乏必需的 X-Tenant-ID。');
}
tenantStorage.run(tenantId, () => {
next();
});
}
}
3. 支持 RLS 的 Prisma 扩展服务 (prisma.service.ts)
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { tenantStorage } from './tenant.context';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
// 根据当前租户上下文动态返回扩展客户端
get tenantClient() {
const tenantId = tenantStorage.getStore();
if (!tenantId) {
throw new Error('未设置当前请求的租户上下文。');
}
return this.$extends({
query: {
$allModels: {
async $allOperations({ args, query }) {
// 在事务中通过 set_config 注入租户变量
const [, result] = await PrismaService.prototype.$transaction.call(this, [
this.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, TRUE)`,
query(args),
]);
return result;
},
},
},
});
}
}
4. 业务服务中使用 (users.service.ts)
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async findAllUsers() {
// 得益于 PostgreSQL 的 RLS,此处查询会自动过滤
return this.prisma.tenantClient.user.findMany();
}
async createUser(data: { name: string; email: string }) {
const tenantId = tenantStorage.getStore();
return this.prisma.tenantClient.user.create({
data: {
...data,
tenantId,
},
});
}
}
优缺点分析
优点:
-
部署与维护简单。
-
便于执行跨租户查询与统计分析。
-
连接池利用效率高。
-
无需为每个租户处理 Schema 迁移的复杂性。
缺点:
-
存在“喧闹邻居 (Noisy Neighbor)”风险(某个租户的查询会影响其他租户)。
-
每次查询都会产生 RLS 开销。
-
难以针对单个租户提供特定的定制化。
-
存在单点故障。
方案 2: Separate Schemas (独立 Schema)
PostgreSQL 实现
-- 为每个租户创建新 Schema 的函数
CREATE OR REPLACE FUNCTION create_tenant_schema(tenant_name TEXT)
RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', tenant_name);
EXECUTE format('
CREATE TABLE %I.users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT now()
)', tenant_name);
EXECUTE format('
CREATE TABLE %I.orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES %I.users(id),
amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT now()
)', tenant_name, tenant_name);
EXECUTE format('CREATE INDEX ON %I.users(email)', tenant_name);
EXECUTE format('CREATE INDEX ON %I.orders(user_id)', tenant_name);
END;
$$ LANGUAGE plpgsql;
SELECT create_tenant_schema('acme');
SELECT create_tenant_schema('globex');
NestJS 与 Prisma 集成(动态 Schema 处理)
Prisma 允许在 PostgreSQL 连接 URL 中配置 ?schema= 参数。为了处理动态 Schema,我们创建一个带缓存的 Prisma 客户端管理器(每个 Schema 对应一个连接池)。
1. 按 Schema 管理的 Prisma 客户端 (tenant-prisma-factory.service.ts)
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class TenantPrismaFactoryService implements OnModuleDestroy {
private clients: Map<string, PrismaClient> = new Map();
getPrismaClientForTenant(tenantSchema: string): PrismaClient {
if (!this.clients.has(tenantSchema)) {
const baseUrl = process.env.DATABASE_URL; // 例如:postgresql://user:pass@localhost:5432/saas_db
const tenantUrl = `${baseUrl}?schema=${tenantSchema}`;
const client = new PrismaClient({
datasources: {
db: {
url: tenantUrl,
},
},
});
this.clients.set(tenantSchema, client);
}
return this.clients.get(tenantSchema)!;
}
async onModuleDestroy() {
for (const client of this.clients.values()) {
await client.$disconnect();
}
}
}
2. NestJS 中的控制器与服务 (orders.service.ts)
import { Injectable, Inject, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { TenantPrismaFactoryService } from './tenant-prisma-factory.service';
@Injectable({ scope: Scope.REQUEST })
export class OrdersService {
constructor(
@Inject(REQUEST) private readonly request: Request,
private readonly prismaFactory: TenantPrismaFactoryService,
) {}
private get prisma() {
const tenantSchema = this.request.headers['x-tenant-id'] as string;
return this.prismaFactory.getPrismaClientForTenant(tenantSchema);
}
async getOrders() {
return this.prisma.order.findMany();
}
}
使用 Prisma 对多个 Schema 执行迁移
要在每个租户 Schema 中应用 Prisma 迁移,可以在 NestJS 项目中创建一个可执行脚本:
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
async function migrateAllSchemas() {
const mainPrisma = new PrismaClient();
// 获取所有已创建的租户 Schema
const schemas: { schema_name: string }[] = await mainPrisma.$queryRaw`
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('public', 'pg_catalog', 'information_schema')
`;
for (const { schema_name } of schemas) {
console.log(`正在为 Schema 执行迁移: ${schema_name}`);
const tenantUrl = `${process.env.DATABASE_URL}?schema=${schema_name}`;
execSync(`npx prisma migrate deploy`, {
env: { ...process.env, DATABASE_URL: tenantUrl },
stdio: 'inherit',
});
}
await mainPrisma.$disconnect();
}
migrateAllSchemas().catch(console.error);
优缺点分析
优点:
-
在共享数据库内实现了良好的租户隔离。
-
更容易针对单个租户进行定制化。
-
可通过逻辑备份导出和还原单个 Schema。
-
相比共享表方案,能更好地避免喧闹邻居问题。
缺点:
-
Schema 迁移较为复杂(必须更新所有 Schema)。
-
连接池管理更具挑战性。
-
跨租户查询需要使用 Schema 前缀。
-
随着租户数量增长,监控与维护难度加大。
方案 3: Separate Databases (独立数据库)
实现
# 为每个租户创建数据库
createdb -O app_user tenant_acme
createdb -O app_user tenant_globex
# 使用 Prisma CLI 应用 Schema
DATABASE_URL="postgresql://app_user:pass@localhost:5432/tenant_acme" npx prisma migrate deploy
DATABASE_URL="postgresql://app_user:pass@localhost:5432/tenant_globex" npx prisma migrate deploy
NestJS 与 Prisma 中的连接路由
在 NestJS 中,我们创建一个服务,负责管理每个独立数据库的连接池。
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class MultiDatabaseService implements OnModuleDestroy {
private tenantClients: Map<string, PrismaClient> = new Map();
getDatabaseClient(tenantId: string): PrismaClient {
if (!this.tenantClients.has(tenantId)) {
const dbHost = process.env.DB_HOST || 'localhost';
const dbPort = process.env.DB_PORT || '5432';
const dbUser = process.env.DB_USER || 'postgres';
const dbPass = process.env.DB_PASS || 'secret';
const dsn = `postgresql://${dbUser}:${dbPass}@${dbHost}:${dbPort}/tenant_${tenantId}?schema=public`;
const client = new PrismaClient({
datasources: {
db: { url: dsn },
},
});
this.tenantClients.set(tenantId, client);
}
return this.tenantClients.get(tenantId)!;
}
async onModuleDestroy() {
for (const client of this.tenantClients.values()) {
await client.$disconnect();
}
}
}
优缺点分析
优点:
-
隔离性最强(安全性与性能)。
-
便于按租户进行备份与还原。
-
可以将高价值租户部署在专用硬件上。
-
心智模型最简单。
缺点:
-
运维复杂度最高。
-
需要为每个数据库单独管理连接池。
-
跨租户分析需要额外的基础设施。
-
资源开销更高。
如何选择合适的方案
决策矩阵
| 衡量因素 | Shared Tables | Separate Schemas | Separate Databases |
|---|---|---|---|
| 租户数量 | 1000+ | 100–1000 | 10–100 |
| 隔离需求 | 低 | 中 | 高 |
| 定制化能力 | 无 | 部分 | 完全 |
| 运维复杂度 | 低 | 中 | 高 |
| 跨租户查询 | 容易 | 中等 | 困难 |
| 备份粒度 | 数据库 | Schema(逻辑备份) | 按租户 |
建议
在以下情况下选择 Shared Tables:
-
拥有大量小型租户(B2C SaaS)。
-
各租户的 Schema 完全相同。
-
需要便捷的跨租户分析。
-
以运维简单为首要目标。
在以下情况下选择 Separate Schemas:
-
租户数量适中。
-
租户可能需要少量定制化。
-
需要比共享表更好的隔离性。
-
需要支持单个租户的数据还原。
在以下情况下选择 Separate Databases:
-
租户有严格的合规要求。
-
拥有少量高价值的企业客户。
-
租户需要完全隔离。
-
需要为每个租户提供性能保障。
混合方案
许多 SaaS 应用采用混合模型:
flowchart TD
subgraph SharedDB ["共享数据库 (Shared Tables + RLS)"]
SmallCo1["tenant_id: small_co_1"]
SmallCo2["tenant_id: small_co_2"]
SmallCo3["tenant_id: small_co_3"]
end
subgraph DB_Enterprise1 ["数据库: enterprise_acme(专用)"]
AcmeData["Acme 数据"]
end
subgraph DB_Enterprise2 ["数据库: enterprise_mega(专用)"]
MegaData["Mega 数据"]
end
使用 NestJS 与 Prisma 根据租户的订阅计划进行路由:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
export interface TenantInfo {
id: string;
plan: 'free' | 'pro' | 'enterprise';
customDbUrl?: string;
}
@Injectable()
export class HybridTenantService {
private dedicatedClients: Map<string, PrismaClient> = new Map();
private sharedClient: PrismaClient;
constructor() {
this.sharedClient = new PrismaClient({
datasources: { db: { url: process.env.SHARED_DATABASE_URL } },
});
}
getPrismaClient(tenant: TenantInfo): PrismaClient {
if (tenant.plan === 'enterprise' && tenant.customDbUrl) {
if (!this.dedicatedClients.has(tenant.id)) {
const client = new PrismaClient({
datasources: { db: { url: tenant.customDbUrl } },
});
this.dedicatedClients.set(tenant.id, client);
}
return this.dedicatedClients.get(tenant.id)!;
}
// free 与 pro 计划使用共享数据库
return this.sharedClient;
}
}
这种方式可以为小型租户优化成本,同时为愿意付费的企业客户提供高级别的隔离。
总结
合适的多租户架构取决于具体需求:
-
从 共享表 开始,以获得简单性与可扩展性。
-
引入 行级安全 (Row Level Security) 来强制执行租户隔离。
-
当需要针对租户进行定制化时,考虑 独立 Schema。
-
对于高隔离需求或企业级套餐,使用 独立数据库。
无论选择哪种方案,都必须确保应用代码始终如一地执行租户边界控制。仅仅缺少一个 WHERE 子句或错误处理上下文,就可能将一个租户的数据暴露给另一个租户。