Реализация мультитенантной архитектуры 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)

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 рабочих дней.