METABYTE
К списку статей

Как построить мультиарендный SaaS на Prisma и Postgres

Пошаговая архитектура мультиарендного SaaS на Prisma + Postgres: схемы, RLS, индексы, middleware, миграции, прод-подводные камни и стоимость запуска.

17 мая 202611 мин чтенияAI-research draft
Как построить мультиарендный SaaS на Prisma и Postgres

Ошибки в мультиарендности дорого стоят: одна неверная политика или забытый фильтр — и данные клиентов протекут между арендаторами. Дороже только миграция с «наивной» модели на правильную на живом продукте. Ниже — практическая схема, как собрать мультиарендный SaaS на Prisma и Postgres так, чтобы не переписывать все через полгода.

Если вам нужен короткий ответ: в большинстве случаев берите общую базу со столбцом tenant_id, включайте Row Level Security (RLS) в Postgres, прокладывайте tenant_id из аутентификации в сессию БД через SET LOCAL, а на уровне Prisma добавляйте middleware для подстраховки. Схемы/базы на арендатора годятся для редких кейсов (регуляторика, крупные VIP-аккаунты с терабайтами данных) и ощутимо дороже в сопровождении.

Варианты мультиарендности и как они бьются с Prisma

Чтобы не спорить вкусовщиной, смотрим на три классики: общий набор таблиц с tenant_id, отдельные схемы, отдельные базы. Ниже — сравнение с учетом Prisma, миграций и прод-операций.

МодельПлюсыМинусыСовместимость с PrismaКогда брать
Общие таблицы + tenant_idПростой код, одна миграция, общие индексы и кэшНужна строгая изоляция на уровне БД (RLS), риск «забыть фильтр»Идеально: одна схема, единый клиент, Prisma Migrate «из коробки»80% SaaS, быстрый старт и масштаб
Отдельная схема на арендатораПсевдо-изоляция, можно разные расширения per-tenantМиграции N раз, рост схем, боль для DWH/репортингаЧастично: Prisma поддерживает multi-schema, но манифест разрастаетсяСредние проекты с четкой сегментацией и умеренным ростом
Отдельная база на арендатораМаксимальная изоляция, независимые бэкапы/лимитыНаибольшие затраты: миграции, DevOps, кросс-аналитикаХудшее: много клиентов/строк подключения, отдельные миграцииЖесткая регуляторика, крупные enterprise-контракты

В нашей практике как билдеров SaaS общая таблица + RLS дает лучший баланс скорости и безопасности. Если сомневаетесь — начинайте с tenant_id. Перейти на схемы/базы позже сложно, но возможно через репликацию и dual-write; наоборот — больнее.

Архитектура: минимальная, но готовая к росту

Компоненты, которые закрывают 90% задач для мультиарендного SaaS:

  • Вход: Cloudflare или Nginx для TLS/статического кэша и защиты L7;
  • Backend: Node.js (NestJS/Express/Fastify) + Prisma Client;
  • БД: Postgres 14/15+ (Managed: RDS/Aurora, Cloud SQL, Supabase, Neon);
  • Кэш: Redis для сессий и быстрых счетчиков;
  • Файлы: S3-совместимое хранилище с пряниками (pre-signed URLs);
  • Очереди и фоновые задачи: BullMQ или Temporal;
  • Платежи: Stripe (планы/лимиты пер-тенант);
  • Аналитика: ClickHouse или BigQuery (асинхронная выгрузка из OLTP);
  • Мониторинг: Grafana + Prometheus, Sentry, pgbouncer + pg_stat_statements.

Поток запроса:

  1. JWT (или cookie) приходит с tenantId и userId. 2) Бэкенд поднимает контекст запроса (AsyncLocalStorage), 3) Открывает транзакцию Prisma и делает SET LOCAL app.current_tenant_id = ..., 4) Все чтения/записи идут под RLS и доп. фильтрами в middleware, 5) Ответ сериализуется по лимитам плана.

Если нужен мульти-регион и DR, учитывайте ограничения кросс-регионных ссылок и секретов. У нас есть отдельная заметка про такие нюансы в AWS CloudFormation/CDK кросс-регион — логика применима и для Terraform.

Схема Prisma: индексы, уникальность и тенант-контекст

Главное правило моделирования: все арендаторо-зависимые сущности содержат обязательный tenantId. Пер-арендорные уникальности описываются составными индексами.

// schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Tenant {
  id        String   @id @default(uuid()) @db.Uuid
  name      String
  plan      String   // basic/pro/enterprise
  createdAt DateTime @default(now())
  users     User[]
  projects  Project[]
}

model User {
  id        String   @id @default(uuid()) @db.Uuid
  email     String
  role      String   // owner, admin, member
  tenantId  String   @db.Uuid
  tenant    Tenant   @relation(fields: [tenantId], references: [id])
  createdAt DateTime @default(now())

  @@unique([tenantId, email])
  @@index([tenantId])
}

model Project {
  id        String   @id @default(uuid()) @db.Uuid
  tenantId  String   @db.Uuid
  tenant    Tenant   @relation(fields: [tenantId], references: [id])
  slug      String
  name      String
  createdAt DateTime @default(now())

  @@unique([tenantId, slug])
  @@index([tenantId])
}

Простые правила дисциплины:

  • Любая связь — через tenantId; избегайте кросс-арендорных FK.
  • Все «уникальности» — в виде @@unique([tenantId, field]).
  • Идемпотентные операции опираются на пару (tenantId, externalId).

Изоляция через Postgres RLS + Prisma

RLS защищает даже от случайно забытых фильтров в коде. Мы используем сессионный параметр app.current_tenant_id и политику на таблицах.

-- Пример для таблицы project
ALTER TABLE "Project" ENABLE ROW LEVEL SECURITY;

-- Только строки своего арендатора видны и модифицируемы
CREATE POLICY project_tenant_isolation_select ON "Project"
  FOR SELECT
  USING (tenantId = current_setting('app.current_tenant_id', true)::uuid);

CREATE POLICY project_tenant_isolation_write ON "Project"
  FOR INSERT, UPDATE, DELETE
  USING (tenantId = current_setting('app.current_tenant_id', true)::uuid)
  WITH CHECK (tenantId = current_setting('app.current_tenant_id', true)::uuid);

Важно: политика должна закрывать и SELECT, и INSERT/UPDATE/DELETE. Для всех арендаторо-зависимых таблиц — аналогичные политики.

В приложении tenantId нужно ставить на сессию БД в рамках транзакции. С Prisma это делается так:

// context.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export const requestContext = new AsyncLocalStorage<{ tenantId: string; userId?: string }>();

export function withRequestContext<T>(ctx: { tenantId: string; userId?: string }, fn: () => Promise<T>) {
  return requestContext.run(ctx, fn);
}
// prisma-helpers.ts
import { prisma } from './prisma';
import { requestContext } from './context';

export async function withTenant<T>(fn: (tx: typeof prisma) => Promise<T>) {
  const ctx = requestContext.getStore();
  if (!ctx?.tenantId) throw new Error('Tenant context is missing');

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`SET LOCAL app.current_tenant_id = ${ctx.tenantId}::uuid`;
    return fn(tx);
  });
}

Дополнительная подстраховка — middleware в Prisma, которое автоматически добавляет tenantId в where для массовых операций. Оно не заменяет RLS, но снижает шум от «не те данные в UI».

// prisma-middleware.ts
import { Prisma } from '@prisma/client';
import { requestContext } from './context';

const MODELS_WITH_TENANT = new Set(['User', 'Project']);

export function tenantGuardMiddleware(): Prisma.Middleware {
  return async (params, next) => {
    const ctx = requestContext.getStore();
    const tenantId = ctx?.tenantId;

    if (tenantId && params.model && MODELS_WITH_TENANT.has(params.model)) {
      const massOps = new Set(['findMany', 'updateMany', 'deleteMany', 'count']);

      if (massOps.has(params.action)) {
        params.args ||= {};
        params.args.where = { ...(params.args.where || {}), tenantId };
      }

      if (params.action === 'create') {
        params.args ||= {};
        params.args.data = { ...(params.args.data || {}), tenantId };
      }

      // Для findUnique нельзя добавить доп. фильтр — полагаемся на RLS
    }

    return next(params);
  };
}

Подводные камни:

  • pgbouncer в режиме transaction pooling не держит сессионные параметры между транзакциями. Используйте session pooling или ставьте SET LOCAL внутри каждой транзакции, как выше.
  • Любые запросы «в обход» RLS (суперпользователь, миграции) должны выполняться отдельной ролью. В приложении — роль без BYPASS RLS.

Аутентификация и границы арендатора

Откуда брать tenantId:

  • Поддомен acme.example.com → маппинг acme → tenantId в Redis/Postgres, кэш на 5–15 минут.
  • Параметр в JWT (Auth0/Clerk/Своя OAuth): tenant_id как custom claim. JWT проверяется на edge/бэкенде.
  • SSO (SAML/OIDC) — маппинг email-домена или групп к tenantId.

Правила безопасности:

  • Пользователь может быть в нескольких арендаторах — контекст обязателен на каждый запрос, а не «берем первый попавшийся».
  • Фоновые задачи (BullMQ/Temporal) передают tenantId явно; воркеры открывают транзакцию с SET LOCAL.
  • Экспорт/импорт данных — всегда фильтруется и подписывается tenantId.

Биллинг и лимиты (Stripe):

  • План → пер-арендорные квоты (число пользователей, проектов, запросов в API), проверяются в middleware/воркерах.
  • Идентификаторы в Stripe: metadata.tenantId для быстрого траблшутинга и дашбордов.

Если вы добавляете AI-функции (резюме, автокомплит), посмотрите наш обзор прод-моделей OpenAI GPT‑5 vs Claude vs DeepSeek — полезно при выборе поставщика и ограничений по данным арендатора.

Миграции, онбординг и удаление данных

С общей схемой миграции просты: Prisma Migrate генерирует SQL и прогоняется один раз. Рекомендации:

  • Миграции только через CI (не руками в проде). Блокировка prisma migrate deploy с явным подтверждением.
  • Семплы/дефолты для нового арендатора — отдельный скрипт seed внутри транзакции withTenant.
  • Удаление арендатора: soft-delete + фоновое каскадное удаление пакетами по 1–5k строк, лог-аудит. RLS на время удаления — через сервисную роль с BYPASS RLS, но с ограничением по tenant_id в WHERE (двойная страховка). Полное физическое удаление — по требованию GDPR.

Для подхода «схема на арендатора» придётся:

  • Поддерживать генерацию/применение миграций для каждой схемы.
  • Управлять поиском по множеству схем (search_path) и поддержкой в Prisma (multi-schema конфиг).
  • Думать о лимитах числа объектов в Postgres и времени деплоя.

Если ощущение, что миграции стали хрупкими — это не ощущение.

Производительность: индексы, кардинальность и лимиты

Профиль типичного SaaS: много мелких селектов, периодические агрегации и массовые апдейты флагов. Что важно:

  • Индексы: все внешние ключи и tenantId в начале составного индекса (@@index([tenantId, createdAt])). Для поиска по slug@@unique([tenantId, slug]).
  • Кардинальность: tenantId должен быть селективным. Если один «монолитный» арендатор — подумайте о партиционировании по tenantId или дате (hash/list partitioning).
  • EXPLAIN ANALYZE: проверяйте, что планировщик использует нужные индексы. Часто спасает WHERE tenant_id = $1 AND created_at > now() - interval '30 days' вместо сканирования годовых таблиц.
  • Кэш: Redis на дорогие агрегаты (лидерборды, usage counters) с TTL и инвалидацией по событию.
  • Ограничение связок в Prisma: внимательно с include — можно сломать N+1. Иногда лучше два точечных запроса, чем один с вложенными массивами и сортировками.
  • Пул соединений: 50–100 коннектов обычно достаточно для начала. Серверлесс — Prisma Accelerate/Proxy или максимум 10–20 коннектов + pgbouncer. Не забывайте, что каждый воркер очереди — тоже потребитель пула.

Небольшая инженерная ирония: лучший оптимизатор — это индекс, который вы добавили еще до того, как метрика просела.

Что ломается в продакшене

  • Утечки контекста через pgbouncer: transaction pooling и SET не дружат. Решение — session pooling или SET LOCAL внутри каждой транзакции.
  • Забытый фильтр в одном из updateMany → массовый апдейт по всем арендаторам. Лечится RLS + middleware.
  • Медленные count(*) на больших таблицах: используйте частичные денормализации, материализованные вьюхи на аналитику или приблизительную оценку (HyperLogLog/складывать счетчики).
  • Миграции с блокирующими DDL: ALTER TABLE ... ADD COLUMN NOT NULL DEFAULT ... может держать блокировки. Делайте через «двухшаговую» схему: добавить nullable, бэкфилл пакетами, затем SET NOT NULL в окно низкой нагрузки.
  • Фоновая задача без tenantId в контексте: воркер зацепит «первый попавшийся». Жёсткая валидация входа + контракт очереди: data: { tenantId, ... }.
  • Мульти-регион: кросс-регионные реплики отстают; нельзя делать кросс-регионные транзакции. Фичи, требующие глобальной согласованности, централизуйте или введите «leader region».

Стоимость и бизнес-контекст

По опыту студии:

  • MVP (аутентификация, мультиарендность, 3–5 основных CRUD, биллинг, ролевая модель, базовая аналитика): 6–10 недель, команда из 2–3 инженеров. Бюджет ориентиром: 25–60k USD, в зависимости от дизайна/интеграций.
  • Хостинг на старте: Postgres managed (100–300 USD/мес), Redis (15–50), S3 + CDN (10–100), Cloudflare (20+), мониторинг (20–100). С ростом трафика БД станет основным расходом.
  • Где экономить нельзя: миграции/бэкапы, мониторинг, политика RLS. Где можно отложить: сложный поиск, многоуровневые роли, кастомные домены (если нет enterprise-сделок).
  • Дорогостоящие ошибки: смена модели мультиарендности постфактум; отсутствие аудита данных; ручные миграции; попытки «сделать без RLS и без тестов».

ROI мультиарендности в лоб: единая кодовая база, единая инфраструктура, чёткая сегментация тарифов. Сильная изоляция на уровне БД снижает юридические риски — аргумент для enterprise-продаж.

Пошаговый план внедрения

  1. Определите модель: начинаем с общей схемы и tenant_id.
  2. Пропишите Prisma-модели: tenantId в каждой сущности, составные уникальности, индексы.
  3. Включите RLS и создайте политики для всех таблиц.
  4. Проложите контекст запроса (AsyncLocalStorage) и withTenant-транзакции с SET LOCAL.
  5. Добавьте Prisma middleware для массовых операций и автозаполнения tenantId.
  6. Интегрируйте аутентификацию (JWT/SSO) с claim tenant_id и проверкой владения.
  7. Настройте миграции через CI, бэкапы и алерты по долгим транзакциям/блокировкам.
  8. Напишите тесты на изоляцию: попытка читать/писать чужой tenantId должна проваливаться на уровне БД.

FAQ

Какой подход изоляции выбрать для старта?

В 80% случаев — общая схема с tenant_id и RLS. Это даёт простые миграции, низкие косты и достаточную безопасность.

Совместима ли Prisma с RLS Postgres?

Да. RLS применяется в БД прозрачно. Важно выставлять SET LOCAL app.current_tenant_id в транзакции и не использовать роли с BYPASS RLS в приложении.

Что с pgbouncer и Prisma?

Используйте session pooling или обеспечьте, что каждый запрос проходит внутри транзакции с SET LOCAL. В transaction pooling сессионные параметры теряются между запросами.

Когда нужны отдельные схемы или базы на арендатора?

Когда регуляторика или объём данных/нагрузка одного арендатора выбивается из всех метрик остальных, либо нужен изолированный SLA/бэкапы. Цена — сложность миграций и DevOps.

Как тестировать мультиарендность?

Интеграционные тесты с двумя арендаторами: операции в tenantA не должны видеть данные tenantB. Тестируйте и позитивные, и негативные сценарии, включая фоновые джобы.

Подойдёт ли серверлесс (Vercel/Cloudflare Workers) для Prisma?

Да, но следите за количеством соединений. Используйте Prisma Accelerate/Proxy или pgbouncer, и держите все обращения к БД короткими и батчируйте запросы.

Ключевые выводы

  • Общая схема + tenant_id + RLS — базовый паттерн для 80% SaaS.
  • Прокладывайте tenantId из аутентификации до БД через SET LOCAL в транзакции.
  • Prisma middleware — подстраховка, но RLS в Postgres остаётся источником правды.
  • Следите за pgbouncer и режимами пула; не теряйте сессионные параметры.
  • Миграции только через CI; никаких блокирующих DDL в часы пик.
  • Индексы и кардинальность решают 90% проблем производительности раньше, чем вы это заметите.

Если вы строите мультиарендный SaaS и хотите, чтобы архитектура выдержала рост и аудит, команда MTBYTE спроектирует и реализует его под ключ. Напишите нам: /contact.

СЛЕДУЮЩИЙ ШАГ

Понравилось как мыслим?

Применяем те же принципы в клиентских проектах: AI, автоматизации, продукты, которые не умирают после релиза.