Представьте: SaaS-платформа для управления проектами с 500 клиентами. Каждый клиент создаёт проекты, добавляет участников, загружает файлы — все в общей базе данных. Одна ошибка в коде — и пользователь видит чужие данные: баги с пересечением tenant_id — частая проблема в системах с общими таблицами. Можно разнести клиентов по отдельным базам, но тогда каждая БД требует собственных бэкапов, миграций и мониторинга, что быстро становится неуправляемым. Компромисс — Schema-per-Tenant: каждая схема в общей базе PostgreSQL, но с полной изоляцией namespace. В этой статье мы делимся опытом реализации такого подхода с Prisma и Kysely.
PostgreSQL схемы работают как пространства имён: таблицы, индексы, функции в разных схемах не пересекаются. Одна база может содержать до 10 000 схем — этого достаточно для большинства B2B SaaS-продуктов. При этом администрирование остаётся единым: один бэкап, одна команда миграции, один мониторинг. Разберём, как организовать изоляцию данных, не жертвуя гибкостью.
Типовой процесс регистрации клиента: создаётся запись в общей таблице tenants, затем динамически создаётся новая схема и применяется начальная схема данных. Для этого нужно административное подключение с правами на DDL. Рассмотрим код и типичные подводные камни.
Как работает изоляция через схемы
PostgreSQL база:
schema: public → общие таблицы (tenants, plans)
schema: tenant_acme → данные клиента Acme
schema: tenant_globex → данные клиента Gloбех
schema: tenant_initech → данные клиента Initech
PostgreSQL поддерживает до 10 000 схем в одной базе данных, что достаточно для большинства SaaS-продуктов. Каждая схема — отдельное пространство имён: таблицы, индексы, функции не пересекаются. При этом бэкап и миграции — одна операция на всю базу.
Как обеспечить изоляцию данных?
Создание схемы при регистрации
Типовой флоу: новый клиент → POST /api/tenants → создаём запись в public.tenants → выполняем DDL для новой схемы. Код на TypeScript:
// 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}"`);
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;
}
Важно: DDL выполняется через административное соединение с правами на создание схем. Транзакция гарантирует атомарность — если что-то пошло не так, схема не появится.
Prisma: обход ограничений ORM
Prisma не умеет работать с несколькими схемами из коробки. Решение — динамический search_path через middleware. Создаём класс TenantPrismaClient, который перед каждым запросом переключает контекст на нужную схему:
// lib/tenant-client.ts
import { PrismaClient } from '@prisma/client';
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;
}
Middleware предпочтительнее глобального search_path, потому что в production несколько тенантов обслуживаются одновременно. Каждый TenantPrismaClient держит своё соединение, переключение происходит только в рамках этого соединения — безопасно для других.
Альтернатива: Kysely
ORM с более гибкой поддержкой динамических схем — Kysely. Он позволяет задать search_path непосредственно при создании пула соединений:
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
function createTenantDb(schemaName: string) {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
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();
Kysely легче, чем Prisma, и даёт больше контроля. Но в проектах с уже внедрённой Prisma middleware-подход работает стабильно.
Миграции на все схемы
При изменении структуры таблиц нужно применить DDL ко всем схемам. Пишем скрипт, который проходит по списку тенантов и выполняет миграцию последовательно:
// 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);
`);
Чтобы избежать простоев, миграции выполняются в рабочее окно с минимальной нагрузкой. Используем IF NOT EXISTS/IF EXISTS для идемпотентности. Если одна схема падает, остальные не блокируются.
Cross-tenant запросы для аналитики
Одно из преимуществ Schema-per-Tenant — возможность агрегировать данные по всем клиентам. Пример: подсчёт проектов по всем тенантам:
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;
Для продакшена лучше использовать PL/pgSQL функцию, которая динамически строит запрос на основе активных схем.
Row Level Security (опционально)
Внутри схемы можно дополнительно разграничить доступ пользователей через RLS. Например, чтобы пользователь видел только свои проекты. Однако это имеет смысл только если в одной схеме работают несколько пользователей с разными правами. Для схемы на одного тенанта изоляция схемой достаточна.
Сравнение подходов мультитенантности
| Критерий | Database-per-Tenant | Schema-per-Tenant | Shared Table |
|---|---|---|---|
| Изоляция | Полная | Высокая | Низкая |
| Администрирование | Сложное (N баз) | Среднее (1 база) | Простое |
| Cross-tenant запросы | Невозможны | Возможны | Лёгкие |
| Ограничения PostgreSQL | 4 ГБ макс. баз (на практике ~1000) | ~10 000 схем | Практически нет |
| Сложность миграций | N операций | N операций | 1 операция |
| Риск утечки данных | Минимальный | Низкий (при правильной настройке) | Высокий |
Schema-per-Tenant выбирают при клиентах от 50 до 2000, когда требуется изоляция, но хочется оптимизировать затраты на администрирование. Этот подход сокращает операционные расходы на инфраструктуру до 40% по сравнению с Database-per-Tenant.
Типовые проблемы и решения
| Проблема | Решение |
|---|---|
| Ошибка создания схемы для нового клиента | Использовать транзакцию в административном соединении; откатывать схему при сбое |
| Необходимость переключения схемы в Prisma | Middleware с установкой search_path перед каждым запросом |
| Низкая производительность cross-tenant запросов | Использовать PL/pgSQL функцию с динамическим SQL, индексировать общие поля |
Процесс работы: от идеи до деплоя
Мы реализуем мультитенантную архитектуру за 4–7 рабочих дней. Этапы:
- Аналитика — обсуждаем модель изоляции, логику регистрации, план миграций. Собираем требования к безопасности.
- Проектирование — рисуем ERD, определяем общие таблицы (tenants, планы) и схемы тенантов. Выбираем стек: Prisma или Kysely.
- Реализация — пишем код provisioning, middleware для ORM, скрипты миграций. Тестируем на нескольких тенантах.
- Тестирование — нагрузочные тесты, проверка изоляции, сценарии сбоев. Используем staging-окружение.
- Деплой — миграция существующих клиентов в новую архитектуру (если есть легаси). Запуск production.
Что входит в работу
- Архитектурная документация (диаграммы, описание схем)
- Исходный код модуля аренды и клиента БД
- Миграционные скрипты и инструкции по развёртыванию
- Доступы к репозиторию и CI/CD
- 1 месяц базовой поддержки после сдачи
Почему выбирают нас
Мы имеем 7+ лет коммерческого опыта с PostgreSQL и SaaS-продуктами. Более 15 реализованных проектов с мультитенантной архитектурой. Гарантируем конфиденциальность данных — подписываем NDA. Предоставляем прозрачный тайм-трекинг и еженедельные отчёты. Свяжитесь с нами, чтобы обсудить ваш проект и получить индивидуальное предложение.







