PostgreSQL 多租户架构实践:基于 NestJS 与 Prisma

所有 SaaS 应用都会面临同一个核心问题:如何在单个数据库中为多个租户安全高效地存储数据?解答将直接影响安全性、性能、运维复杂度与扩展能力。本文涵盖三种主流架构设计,并给出 PostgreSQL、NestJS 与 Prisma ORM 的具体实战方案。

PostgreSQL 多租户架构实践:基于 NestJS 与 Prisma
8 分钟阅读
本页内容

所有 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 TablesSeparate SchemasSeparate Databases
租户数量1000+100–100010–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 子句或错误处理上下文,就可能将一个租户的数据暴露给另一个租户。

拖动可移动图示。按 + 和 − 缩放,方向键移动,0 重置视图。

一起合作

软件工程师、高级全栈开发工程师,常驻玻利维亚Santa Cruz,远程办公。欢迎新的工作机会。