Реалізація мультитенантної архітектури SaaS-застосунку (Schema per Tenant)

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

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

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація мультитенантної архітектури SaaS-застосунку (Schema per Tenant)
Складна
~2-4 тижні
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

SaaS мультитенантність: схема на тенанта

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
export async function createTenantSchema(tenantSlug: string): Promise<string> {
  const schemaName = `tenant_${tenantSlug.replace(/-/g, '_')}`;

  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;
}

Prisma: динамічна схема

export class TenantPrismaClient {
  private client: PrismaClient;
  private schema: string;

  constructor(schema: string) {
    this.schema = schema;
    this.client = new PrismaClient();

    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 з динамічною схемою

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();

Міграції на всіх схемах

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 запити (аналітика)

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;

Schema-per-tenant архітектура з міграційним інструментом — 4–7 робочих днів.