SaaS 多租户架构实战:数据隔离策略、行级安全与成本优化全指南

深入解析 SaaS 多租户架构的三种数据隔离模式,手把手实现 PostgreSQL 行级安全(RLS),对比共享表、Schema 隔离与独立数据库的性能、安全与成本,附完整 TypeScript 代码示例与生产避坑指南。

数据库 2026-05-30 18 分钟

据 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

📚 相关文章