据 Okta 2025 年的企业 SaaS 报告,全球 B2B SaaS 应用中超过 85% 采用某种形式的多租户架构,但其中近 40% 的团队在第一年就遇到了数据泄露或性能雪崩问题。多租户(Multi-Tenancy)不是简单地在每张表加个 tenant_id 字段——它涉及数据隔离、查询安全、Schema 演进、成本控制等一系列工程决策。本文将从零开始,系统拆解三种主流隔离策略的实现细节、性能特征和适用场景,帮你做出正确的架构选择。
🏗️ 一、三种数据隔离模式:从共享到独立的权衡
多租户架构的核心问题是:不同租户的数据如何存储和隔离? 这个选择直接影响安全性、运维复杂度、成本和可扩展性。主流方案有三种,每种都有明确的适用边界。
1.1 方案对比总览
| 维度 | 共享表 + RLS | Schema 隔离 | 独立数据库 |
|---|---|---|---|
| 数据隔离强度 | ⭐⭐⭐ 中等 | ⭐⭐⭐⭐ 较高 | ⭐⭐⭐⭐⭐ 最高 |
| 运维复杂度 | ⭐ 最低 | ⭐⭐⭐ 中等 | ⭐⭐⭐⭐⭐ 最高 |
| 租户级备份恢复 | ❌ 困难 | ✅ 容易 | ✅ 最灵活 |
| Schema 定制化 | ❌ 不支持 | ✅ 支持 | ✅ 完全自由 |
| 连接池压力 | ⭐ 最低 | ⭐⭐ 中等 | ⭐⭐⭐⭐ 高 |
| 单库租户上限 | ~10,000+ | ~500 | 1(每库一个) |
| 月成本(100 租户) | ~$50 | ~$100 | ~$500+ |
| 月成本(10,000 租户) | ~$200 | ~$800 | 不现实 |
| 典型用户 | 中小 SaaS | B2B SaaS | 金融/医疗合规 |
⚠️ 警告: 不要为了「听起来更安全」而盲目选择独立数据库方案。10,000 个租户意味着 10,000 个数据库连接池,运维成本会指数级增长。大多数 SaaS 应用用「共享表 + RLS」就能满足安全需求。
1.2 共享表 + 行级安全(RLS)
这是最简单也最经济的方案:所有租户的数据存在同一张表中,通过 tenant_id 列和 PostgreSQL 的 RLS 策略实现隔离。应用层完全不需要写 WHERE tenant_id = ? 的过滤逻辑——数据库层面自动完成。
适用场景: 租户数量多(100+)、Schema 完全一致、无合规要求强制物理隔离的 SaaS 产品。
1.3 Schema 隔离
每个租户一个独立的 Schema(PostgreSQL 的 namespace),共享同一个数据库实例。每个 Schema 内的表结构可以独立演进,适合需要租户级定制的 B2B 产品。
适用场景: 租户数量中等(10-500)、需要定制化字段或插件、有中等安全要求。
1.4 独立数据库
每个租户一个独立的数据库实例,物理完全隔离。安全性最高,但运维成本也最高。
适用场景: 金融、医疗等强合规行业、租户数量极少(<50)、单租户数据量极大。
🔐 二、PostgreSQL RLS 实战:共享表方案的完整实现
RLS(Row-Level Security)是 PostgreSQL 原生支持的行级访问控制机制。它在数据库引擎层面拦截每一条 SQL,自动追加租户过滤条件,即使应用代码写错了 WHERE 条件,数据也不会泄露。
2.1 数据库 Schema 设计
先创建核心的租户和数据表结构:
-- 租户表:每个独立客户
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL, -- URL 友好的标识符
plan VARCHAR(50) DEFAULT 'free', -- free / pro / enterprise
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 租户成员表:用户与租户的关联
CREATE TABLE tenant_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
role VARCHAR(50) DEFAULT 'member', -- owner / admin / member
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, user_id)
);
-- 业务表示例:项目表
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 为所有业务表创建 tenant_id 索引(性能关键!)
CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);
📌 记住:
tenant_id列必须建索引。没有索引的 RLS 策略在数据量大时会导致全表扫描,查询性能断崖式下降。
2.2 启用 RLS 策略
-- 启用 projects 表的行级安全
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 策略:用户只能看到自己所属租户的数据
CREATE POLICY tenant_isolation_policy ON projects
FOR ALL -- 对所有操作生效(SELECT/INSERT/UPDATE/DELETE)
USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- USING 子句:控制 SELECT / UPDATE / DELETE 能看到哪些行
-- WITH CHECK 子句:控制 INSERT / UPDATE 能写入哪些行
-- 两者配合确保读写都不会越界
-- 对 tenant_members 表也启用 RLS
ALTER TABLE tenant_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON tenant_members
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);
💡 提示:
current_setting('app.current_tenant_id')读取的是 PostgreSQL 会话级别的自定义变量。这个变量通过SET命令在每个请求开始时设置,请求结束后自动清除。这意味着即使应用代码忘记加WHERE条件,RLS 也会兜底。
2.3 应用层集成(Node.js + TypeScript)
// tenant-context.ts — 多租户数据库连接管理
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // 连接池大小
idleTimeoutMillis: 30000,
});
/**
* 在租户上下文中执行数据库操作
* 核心:每个请求开始时设置 tenant_id,结束后清除
*/
export async function withTenantContext<T>(
tenantId: string,
fn: (client: any) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
// 设置当前租户 ID — RLS 策略会自动读取这个值
await client.query(`SET app.current_tenant_id = '${tenantId}'`);
// 执行业务逻辑
const result = await fn(client);
return result;
} finally {
// 清除租户上下文,防止连接复用时泄露
await client.query('RESET app.current_tenant_id');
client.release();
}
}
// 使用示例 — Express 中间件
export function tenantMiddleware(req: any, res: any, next: any) {
// 从 JWT 或 session 中获取租户 ID
const tenantId = req.auth?.tenantId;
if (!tenantId) {
return res.status(401).json({ error: 'Missing tenant context' });
}
// 将租户上下文绑定到请求
req.tenantContext = {
run: <T>(fn: (client: any) => Promise<T>) =>
withTenantContext(tenantId, fn),
};
next();
}
// 路由中使用 — 无需手动加 WHERE tenant_id = ?
async function listProjects(req: any, res: any) {
const projects = await req.tenantContext.run(async (client: any) => {
// RLS 自动过滤,只返回当前租户的项目
const result = await client.query(
'SELECT * FROM projects ORDER BY created_at DESC LIMIT 50'
);
return result.rows;
});
res.json({ data: projects });
}
async function createProject(req: any, res: any) {
const { name, description } = req.body;
const project = await req.tenantContext.run(async (client: any) => {
// INSERT 时 RLS 自动检查 tenant_id 是否匹配
// 如果传入的 tenant_id 与会话不一致,直接报错
const result = await client.query(
`INSERT INTO projects (tenant_id, name, description)
VALUES (current_setting('app.current_tenant_id')::UUID, $1, $2)
RETURNING *`,
[name, description]
);
return result.rows[0];
});
res.status(201).json({ data: project });
}
2.4 RLS 的「隐藏陷阱」
RLS 并非万能,有几个关键陷阱需要注意:
❌ 错误做法: 在应用层自己拼接 tenant_id 过滤
-- 应用代码中手写 WHERE — 容易遗漏,安全不可靠
SELECT * FROM projects WHERE tenant_id = 'xxx' AND status = 'active';
✅ 正确做法: 依赖 RLS 自动过滤,应用代码只关注业务逻辑
-- 只写业务条件,tenant_id 过滤交给 RLS
SELECT * FROM projects WHERE status = 'active';
⚠️ 警告: RLS 对表的所有者(通常是超级用户)默认不生效!如果你的应用使用超级用户连接数据库,RLS 策略会被绕过。务必使用普通用户角色连接:
-- 创建专用的应用数据库角色
CREATE ROLE app_user LOGIN PASSWORD 'secure_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- 关键:即使表的所有者也要受 RLS 约束
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
🚀 三、进阶方案:Schema 隔离与混合架构
当共享表方案无法满足需求时(比如租户需要自定义字段、独立备份、或合规要求物理隔离),可以考虑 Schema 隔离或混合架构。
3.1 动态 Schema 管理
// schema-manager.ts — 动态创建和管理租户 Schema
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
/**
* 为新租户创建独立 Schema
* 包含完整的表结构、索引和初始数据
*/
export async function createTenantSchema(tenantSlug: string): Promise<void> {
const schemaName = `tenant_${tenantSlug.replace(/[^a-z0-9_]/g, '')}`;
const client = await pool.connect();
try {
await client.query('BEGIN');
// 创建 Schema
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// 设置搜索路径
await client.query(`SET search_path TO "${schemaName}", public`);
// 创建业务表(与共享表方案结构相同)
await client.query(`
CREATE TABLE "${schemaName}".projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await client.query(`
CREATE TABLE "${schemaName}".tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES "${schemaName}".projects(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
assignee_id UUID,
priority INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
// 创建索引
await client.query(`
CREATE INDEX idx_${schemaName}_tasks_project
ON "${schemaName}".tasks(project_id)
`);
await client.query('COMMIT');
console.log(`✅ Schema "${schemaName}" created successfully`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
/**
* 在租户 Schema 上下文中执行查询
*/
export async function withTenantSchema<T>(
tenantSlug: string,
fn: (client: any) => Promise<T>
): Promise<T> {
const schemaName = `tenant_${tenantSlug.replace(/[^a-z0-9_]/g, '')}`;
const client = await pool.connect();
try {
// 切换到租户的 Schema
await client.query(`SET search_path TO "${schemaName}", public`);
return await fn(client);
} finally {
// 恢复默认搜索路径
await client.query('RESET search_path');
client.release();
}
}
3.2 混合架构:共享表 + 租户级 Schema
实际项目中,最常见的不是纯粹的某一种方案,而是混合架构:
- 高频、小数据量的表(如用户配置、通知)用共享表 + RLS
- 大数据量、需要独立备份的表(如审计日志、文件存储)用 Schema 隔离
- 超级大客户(Enterprise)可以迁移到独立数据库
// hybrid-tenant-router.ts — 混合架构路由
interface TenantConfig {
id: string;
slug: string;
isolationLevel: 'shared' | 'schema' | 'database';
databaseUrl?: string; // 仅 database 级别使用
}
const tenantConfigs = new Map<string, TenantConfig>();
export async function executeInTenantContext<T>(
tenantId: string,
table: string,
fn: (client: any) => Promise<T>
): Promise<T> {
const config = tenantConfigs.get(tenantId);
if (!config) throw new Error(`Tenant ${tenantId} not found`);
switch (config.isolationLevel) {
case 'shared':
// 共享表 + RLS
return withTenantContext(tenantId, fn);
case 'schema':
// Schema 隔离
return withTenantSchema(config.slug, fn);
case 'database':
// 独立数据库 — 使用该租户专属的连接池
const tenantPool = getTenantPool(config.databaseUrl!);
const client = await tenantPool.connect();
try {
return await fn(client);
} finally {
client.release();
}
}
}
// 连接池缓存(独立数据库方案)
const poolCache = new Map<string, Pool>();
function getTenantPool(databaseUrl: string): Pool {
if (!poolCache.has(databaseUrl)) {
poolCache.set(databaseUrl, new Pool({
connectionString: databaseUrl,
max: 5, // 每个租户独立数据库用小连接池
}));
}
return poolCache.get(databaseUrl)!;
}
💰 四、成本优化与性能调优
多租户架构的成本控制是一个容易被忽视的问题。以下是经过生产验证的优化策略。
4.1 查询性能优化
在共享表方案中,随着租户和数据量增长,查询性能会逐步下降。关键优化点:
-- ✅ 复合索引:tenant_id + 业务查询字段
CREATE INDEX idx_projects_tenant_status ON projects(tenant_id, status);
CREATE INDEX idx_projects_tenant_created ON projects(tenant_id, created_at DESC);
-- ✅ 分区表:按租户分组分区(适合超大租户)
-- 注意:仅在单租户数据量 > 1000 万行时才考虑
CREATE TABLE projects (
id UUID DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY HASH (tenant_id);
CREATE TABLE projects_p0 PARTITION OF projects FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE projects_p1 PARTITION OF projects FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE projects_p2 PARTITION OF projects FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE projects_p3 PARTITION OF projects FOR VALUES WITH (MODULUS 4, REMAINDER 3);
4.2 连接池管理
| 方案 | 连接数公式 | 100 租户 | 10,000 租户 |
|---|---|---|---|
| 共享表 + RLS | 应用连接池大小 | 20 | 20 |
| Schema 隔离 | 应用连接池大小 | 20 | 20 |
| 独立数据库 | 租户数 × 每库连接数 | 500 | ❌ 不可行 |
⚡ 关键结论: 共享表方案的连接池大小与租户数量无关,这是它在大规模场景下的核心优势。独立数据库方案在租户数超过 200 时,连接管理就变成了噩梦。
4.3 租户级资源限制
防止「噪音邻居」(Noisy Neighbor)问题——某个租户的大量查询拖垮整个系统:
// rate-limiter.ts — 租户级查询限流
import { RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
// 按租户 Plan 设置不同的查询限制
const planLimits = {
free: { points: 100, duration: 60 }, // 100 次/分钟
pro: { points: 1000, duration: 60 }, // 1000 次/分钟
enterprise: { points: 5000, duration: 60 }, // 5000 次/分钟
};
const limiters = new Map<string, RateLimiterRedis>();
function getLimiter(plan: string): RateLimiterRedis {
if (!limiters.has(plan)) {
const config = planLimits[plan as keyof typeof planLimits] || planLimits.free;
limiters.set(plan, new RateLimiterRedis({
storeClient: redis,
keyPrefix: `rl_${plan}`,
points: config.points,
duration: config.duration,
}));
}
return limiters.get(plan)!;
}
export async function checkTenantRateLimit(
tenantId: string,
plan: string
): Promise<{ allowed: boolean; remaining: number }> {
const limiter = getLimiter(plan);
try {
const result = await limiter.consume(tenantId);
return { allowed: true, remaining: result.remainingPoints };
} catch (rejRes: any) {
return { allowed: false, remaining: rejRes.remainingPoints || 0 };
}
}
📋 五、生产避坑清单
基于多个多租户 SaaS 项目的生产经验,以下是最高频的坑点:
✅ 推荐做法:
- 使用 RLS 而非应用层过滤,确保数据库层面的强制隔离
- 为
tenant_id创建复合索引(tenant_id+ 高频查询字段) - 使用数据库角色(非超级用户)连接,确保 RLS 生效
- 实现租户级限流,防止噪音邻居问题
- 使用
FORCE ROW LEVEL SECURITY确保表所有者也受限 - 定期审计 RLS 策略是否覆盖所有业务表
❌ 避免做法:
- 不要在应用代码中手动拼接
WHERE tenant_id = ?(容易遗漏) - 不要使用超级用户角色连接数据库(RLS 会被绕过)
- 不要在共享表中存储大量二进制数据(用对象存储替代)
- 不要在 Schema 隔离方案中创建超过 500 个 Schema(性能下降明显)
- 不要忽略
tenant_id的索引(会导致全表扫描)
⚠️ 注意事项:
- RLS 策略不会阻止
TRUNCATE TABLE(需要额外的权限控制) - Schema 迁移时必须同步更新所有租户 Schema(建议用 Flyway/Liquibase 管理)
- 数据库备份恢复时,共享表方案无法恢复单个租户的数据
🎯 总结
多租户架构没有银弹,选择哪种方案取决于你的业务阶段和需求:
- 初创阶段(< 100 租户):共享表 + RLS,简单高效,快速上线
- 成长阶段(100-500 租户):共享表 + RLS,辅以分区表和连接池优化
- 成熟阶段(B2B 大客户):混合架构,核心大客户用 Schema 隔离或独立数据库
- 合规行业(金融/医疗):独立数据库,满足数据物理隔离的合规要求
⚡ 关键结论: 90% 的 SaaS 应用用「共享表 + RLS」就足够了。不要过早优化,也不要为了「听起来更高级」而选择复杂方案。从最简单的方案开始,根据实际痛点逐步演进。
🔧 相关工具推荐:
- PostgreSQL RLS — 原生行级安全策略
- Flyway / Liquibase — 多租户 Schema 迁移管理
- pgBouncer — 连接池管理,降低数据库连接开销
- Prisma / Drizzle ORM — 支持多租户的 TypeScript ORM
- PostgREST — 自动生成带 RLS 支持的 REST API