Мультитенантна SaaS-архітектура: Schema-per-Tenant на PostgreSQL

Уявіть: SaaS-платформа для управління проєктами з 500 клієнтами. Кожен клієнт створює проєкти, додає учасників, завантажує файли — все в спільній базі даних. Одна помилка в коді — і користувач бачить чужі дані: баги з перетином `tenant_id` — часта проблема в системах зі спільними таблицями. Можна ро

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Послуги, які ми пропонуємо
Показано 1 з 1Усі 2062 послуг
Мультитенантна SaaS-архітектура: Schema-per-Tenant на PostgreSQL
Складний
~2-4 тижні

Наші компетенції:

Часті запитання

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1418
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1286
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    983
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1243
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    983
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Розробка веб-сайту для компанії ФІКСПЕР
    998

Уявіть: 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. Надаємо прозорий тайм-трекінг та щотижневі звіти.

Пропонуємо реалізацію під ключ – оцініть ваш проєкт безкоштовно, пишіть нам!