Уявіть: SaaS-платформа для управління проєктами з 500 клієнтами. Кожен клієнт створює проєкти, додає учасників, завантажує файли — все в спільній базі даних. Одна помилка в коді — і користувач бачить чужі дані: баги з перетином tenant_id — часта проблема в системах зі спільними таблицями. Можна рознести клієнтів по окремих базах, але тоді кожна БД потребує власних бекапів, міграцій і моніторингу, що швидко стає некерованим. Компроміс — Schema-per-Tenant: кожна схема в спільній базі PostgreSQL, але з повною ізоляцією namespace. У цій статті ми ділимося досвідом реалізації такого підходу з Prisma та Kysely.
PostgreSQL схеми працюють як простори імен: таблиці, індекси, функції в різних схемах не перетинаються. PostgreSQL documentation підтверджує: одна база може містити до 10 000 схем — цього достатньо для більшості B2B SaaS-продуктів. При цьому адміністрування залишається єдиним: один бекап, одна команда міграції, один моніторинг. Розберемо, як організувати ізоляцію даних, не жертвуючи гнучкістю.
Як працює ізоляція через схеми
PostgreSQL база:
schema: public → спільні таблиці (tenants, plans)
schema: tenant_acme → дані клієнта Acme
schema: tenant_globex → дані клієнта Globex
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. Наприклад, щоб користувач бачив тільки свої проєкти. Однак це має сенс тільки якщо в одній схемі працюють кілька користувачів з різними правами. Для схеми на одного тенанта ізоляція схемою достатня.
Як вибрати між Schema-per-Tenant та іншими підходами?
Для вибору підходу зверніть увагу на кількість клієнтів: до 50 — shared table, 50-2000 — Schema-per-Tenant, понад 2000 — Database-per-Tenant.
| Критерій | 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.
Типові проблеми та рішення
| Проблема | Рішення |
|---|---|
| Помилка створення схеми для нового клієнта | Використовувати transaction isolation serializable в адміністративному з'єднанні; відкочувати схему при збої |
| Необхідність перемикання схеми в Prisma | Middleware з встановленням search_path перед кожним запитом |
| Низька продуктивність cross-tenant запитів | Використовувати PL/pgSQL функцію з динамічним SQL, індексувати спільні поля за допомогою partial indexes |
Скільки коштує впровадження мультитенантності?
Ми реалізуємо мультитенантну архітектуру за 4–7 робочих днів. Впровадження для вашої системи коштує від 5000 до 10000 грн залежно від складності. Економія на інфраструктурі після впровадження досягає 40%.
Що входить у роботу
- Архітектурна документація (діаграми, опис схем)
- Вихідний код модуля оренди БД та клієнта БД
- Міграційні скрипти та інструкції з розгортання
- Доступи до репозиторію та CI/CD
- 1 місяць базової підтримки після здачі
Чому обирають нас
Ми маємо 7+ років комерційного досвіду з PostgreSQL та SaaS-продуктами. Більше 15 реалізованих проєктів з мультитенантною архітектурою. Гарантуємо конфіденційність даних — підписуємо NDA. Надаємо прозорий тайм-трекінг та щотижневі звіти.
Пропонуємо реалізацію під ключ – оцініть ваш проєкт безкоштовно, пишіть нам!







