SaaS мультитенантность: схема на тенанта (Schema-per-tenant)
Schema-per-tenant — компромисс между общей БД и database-per-tenant. Все тенанты в одной PostgreSQL базе, но каждый в своей схеме (namespace). Хорошая изоляция при меньшем overhead.
Концепция
PostgreSQL база:
schema: public → общие таблицы (tenants, plans)
schema: tenant_acme → данные клиента Acme
schema: tenant_globex → данные клиента Globex
schema: tenant_initech → данные клиента Initech
PostgreSQL позволяет до ~10 000 схем в одной базе.
Создание схемы при регистрации
// lib/tenant-provisioning.ts
import { db, adminDb } from './db';
export async function createTenantSchema(tenantSlug: string): Promise<string> {
const schemaName = `tenant_${tenantSlug.replace(/-/g, '_')}`;
// Транзакция в admin соединении
await adminDb.$transaction(async (tx) => {
// Создаём схему
await tx.$executeRawUnsafe(`CREATE SCHEMA "${schemaName}"`);
// Применяем структуру таблиц (из SQL файла или Prisma миграции)
await tx.$executeRawUnsafe(`
SET search_path TO "${schemaName}";
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ DEFAULT NOW()
);
-- Индексы
CREATE INDEX ON projects (created_at DESC);
CREATE INDEX ON team_members (user_id);
`);
});
return schemaName;
}
Prisma: динамическая схема
// Prisma не поддерживает schema-per-tenant нативно
// Решение: устанавливаем search_path для каждого запроса
export class TenantPrismaClient {
private client: PrismaClient;
private schema: string;
constructor(schema: string) {
this.schema = schema;
this.client = new PrismaClient();
// Middleware: устанавливаем search_path перед каждым запросом
this.client.$use(async (params, next) => {
await this.client.$executeRawUnsafe(
`SET search_path TO "${this.schema}", public`
);
return next(params);
});
}
get db() { return this.client; }
async disconnect() {
await this.client.$disconnect();
}
}
// Фабрика с кэшем
const clients = new Map<string, TenantPrismaClient>();
export async function getTenantClient(tenantId: string): Promise<TenantPrismaClient> {
if (clients.has(tenantId)) {
return clients.get(tenantId)!;
}
const tenant = await masterDb.tenant.findUniqueOrThrow({
where: { id: tenantId },
select: { schemaName: true }
});
const client = new TenantPrismaClient(tenant.schemaName);
clients.set(tenantId, client);
return client;
}
Альтернатива: Kysely с динамической схемой
// Kysely лучше поддерживает динамические схемы
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
function createTenantDb(schemaName: string) {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// Устанавливаем search_path при создании соединения
pool.on('connect', (client) => {
client.query(`SET search_path TO "${schemaName}", public`);
});
return new Kysely({
dialect: new PostgresDialect({ pool }),
});
}
// Использование
const tenantDb = createTenantDb('tenant_acme');
const projects = await tenantDb
.selectFrom('projects')
.selectAll()
.orderBy('created_at', 'desc')
.execute();
Миграции на все схемы
// scripts/migrate-schemas.ts
import { adminDb } from '../lib/db';
async function migrateAllSchemas(migration: string) {
const tenants = await masterDb.tenant.findMany({
select: { schemaName: true, slug: true }
});
for (const tenant of tenants) {
console.log(`Migrating ${tenant.slug}...`);
try {
await adminDb.$executeRawUnsafe(`
SET search_path TO "${tenant.schemaName}";
${migration}
`);
} catch (error) {
console.error(`Failed: ${tenant.slug}`, error);
}
}
}
// Пример: добавить колонку
migrateAllSchemas(`
ALTER TABLE projects ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS projects_archived_at ON projects (archived_at);
`);
Cross-tenant запросы (аналитика)
-- Один из плюсов schema-per-tenant: можно делать cross-schema запросы
-- Аналитика поперёк всех тенантов
SELECT
t.slug as tenant,
COUNT(p.id) as project_count
FROM public.tenants t
CROSS JOIN LATERAL (
SELECT id FROM tenant_acme.projects
UNION ALL
SELECT id FROM tenant_globex.projects
-- ...динамически строится из списка тенантов
) p(id)
GROUP BY t.slug;
-- Через FDW (Foreign Data Wrapper) или динамический SQL в PL/pgSQL
Row Level Security внутри схем
-- Дополнительная защита внутри схемы
-- (опционально, если несколько пользователей работают с одной схемой)
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_access ON projects
USING (
user_id = current_setting('app.current_user_id', true)
OR
EXISTS (
SELECT 1 FROM team_members
WHERE user_id = current_setting('app.current_user_id', true)
AND role IN ('admin', 'owner')
)
);
Schema-per-tenant архитектура с миграционным инструментом — 4–7 рабочих дней.







