Legan Studio
Все статьи
~ 6 мин чтения

Multi-tenant архитектура для SaaS

Как изолировать данные арендаторов в SaaS: shared DB с tenant_id, schema-per-tenant, DB-per-tenant, миграции, безопасность и Postgres Row Level Security.

  • веб
  • saas
  • архитектура

В SaaS вы продаёте один и тот же продукт сотням клиентов. Каждый клиент должен видеть только свои данные, никогда не видеть чужие — но при этом вы хотите один кодбейс, одну инсталляцию и одну операционку. Это и есть multi-tenancy. От правильной архитектуры на старте зависит, сможете ли вы дорасти до 10 000 арендаторов или утонете в данных и багах безопасности.

Три модели изоляции

МодельИзоляцияСтоимостьКогда
Shared DB, shared schema (tenant_id)логическаянизкаястандартный SaaS до 10 тыс. тенантов
Shared DB, schema-per-tenantсредняясредняяenterprise с регуляторкой
DB-per-tenantфизическаявысокаябанки, медицина, госсектор

Рассмотрим каждую.

Shared DB с tenant_id

Самая распространённая. Все таблицы имеют колонку tenant_id, все запросы фильтруются по ней.

CREATE TABLE tenants (
  id          BIGSERIAL PRIMARY KEY,
  name        TEXT NOT NULL,
  slug        TEXT NOT NULL UNIQUE,
  plan        TEXT NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE projects (
  id          BIGSERIAL PRIMARY KEY,
  tenant_id   BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON projects(tenant_id);

Плюсы: одна БД, простые миграции, дешёвый хостинг, легко делать кросс-тенантные отчёты для себя.

Минусы: одна ошибка в фильтре WHERE tenant_id = ? — утечка данных. Шумный сосед: тяжёлый запрос одного тенанта тормозит остальных. Для VIP-клиентов с регуляторными требованиями недостаточно.

Postgres Row Level Security

RLS — встроенная защита от человеческой ошибки в фильтрах. Вы один раз описываете политику, и Postgres сам отрезает чужие строки.

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.tenant_id')::bigint);

В приложении в начале каждой транзакции:

await db.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId.toString()}, true)`;
// дальше любые SELECT/UPDATE/DELETE — только данные этого тенанта

Если разработчик забудет фильтр, RLS просто не вернёт чужие строки. Это спасает от 95% багов утечки.

Schema-per-tenant

Каждый тенант — отдельная Postgres-схема. Таблицы те же, но в разных namespace.

CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.projects (...);

CREATE SCHEMA tenant_globex;
CREATE TABLE tenant_globex.projects (...);

Подключение задаёт search_path:

await db.$executeRaw`SET search_path TO ${schema}`;

Плюсы: изоляция строже, можно бэкапить тенанта отдельно, разные тенанты — разные миграции в принципе можно (хотя обычно все одинаковые).

Минусы: миграции усложняются — нужно прогонять на каждой схеме (1000 тенантов × 5 секунд = час миграции). Connection pooling сложнее. Postgres начинает тормозить на > 1000-5000 схем.

DB-per-tenant

Каждый тенант — своя физическая БД, иногда даже свой сервер.

Плюсы: полная изоляция, легко удалить или переехать одного клиента, легко договариваться о SLA на конкретные данные, регуляторика РФ (152-ФЗ) удовлетворяется без вопросов.

Минусы: дорого. 1000 тенантов = 1000 БД, каждая с миграциями, бэкапами, мониторингом. Кросс-тенантные отчёты — отдельный процесс ETL.

Вариант: enterprise-клиенты на DB-per-tenant, остальные — на shared DB. Cell-based architecture.

Идентификация тенанта

Откуда сервер знает, кто пришёл? Варианты:

  1. Поддомен: acme.app.example.ru, globex.app.example.ru. Понятно, красиво, но требует wildcard-сертификата и DNS.
  2. Префикс пути: app.example.ru/acme/.... Проще, но URL уродливее.
  3. JWT в заголовке: токен содержит tenant_id, никаких поддоменов. Чище для API, но API-only.
  4. Custom domain: клиент привязывает app.acme.com → CNAME на ваш сервер. Premium-фича, требует SaaS-инфры (Caddy on-demand TLS, Cloudflare for SaaS).

Часто комбинируют: поддомен + JWT внутри.

// middleware.ts
import { NextResponse } from "next/server";

export function middleware(req) {
  const host = req.headers.get("host") ?? "";
  const subdomain = host.split(".")[0];
  if (subdomain && subdomain !== "www" && subdomain !== "app") {
    req.headers.set("x-tenant-slug", subdomain);
  }
  return NextResponse.next();
}

Аутентификация

Юзер живёт внутри тенанта или может быть в нескольких? От этого зависит модель:

-- юзер только в одном тенанте
CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  tenant_id BIGINT NOT NULL REFERENCES tenants(id),
  email TEXT NOT NULL,
  UNIQUE (tenant_id, email)
);

-- юзер может быть в нескольких тенантах (как в Slack — workspaces)
CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT
);
CREATE TABLE memberships (
  user_id BIGINT REFERENCES users(id),
  tenant_id BIGINT REFERENCES tenants(id),
  role TEXT NOT NULL,  -- owner/admin/member
  PRIMARY KEY (user_id, tenant_id)
);

Вторая модель сложнее, но даёт «один email — много рабочих пространств», как в большинстве современных SaaS.

Миграции

Shared DB — обычные prisma migrate deploy или drizzle-kit push, одна команда.

Schema-per-tenant — нужно прогнать миграцию на каждой схеме. Скрипт:

const tenants = await db.tenant.findMany();
for (const t of tenants) {
  await db.$executeRaw`SET search_path TO ${`tenant_${t.slug}`}`;
  await runMigrations();
}

DB-per-tenant — то же самое, но с переключением connection string. На больших объёмах — параллельная очередь миграций, иначе релиз растянется.

Backwards-compatible миграции обязательны: вы не можете «остановить всех» на 5 минут, пока обновляете схему. Стандарт: новая колонка nullable, потом deploy кода, потом backfill, потом NOT NULL — четыре релиза.

Лимиты и квоты

В shared DB один шумный сосед может уронить всех. Защита:

  • Rate limit на API per-tenant (не только per-IP).
  • Лимит на количество запросов в минуту, на размер запроса, на длительность.
  • Postgres statement_timeout для пользовательских запросов (например, в SQL-редакторе SaaS-аналитики).
  • Connection pool разделён по тенантам или с приоритетами.

Хороший знак: новый клиент с петабайтом данных не убивает SLA остальных.

Бэкапы и восстановление

Shared DB: дамп базы — все тенанты разом. Восстановить одного — extract его строк из дампа, отдельный скрипт. Сложно.

Schema-per-tenant: pg_dump -n schema_name — дамп одного тенанта. Просто.

DB-per-tenant: нативно по одному — каждая БД отдельно.

Если ваш SLA говорит «восстановим конкретного клиента за 1 час» — вторая или третья модель.

Удаление тенанта

При уходе клиента нужно удалить ВСЕ его данные — это требование 152-ФЗ.

Shared DB: DELETE FROM ... WHERE tenant_id = ? на каждой таблице. ON DELETE CASCADE помогает, но забудете одну табличку — остатки данных. Хорошая практика — soft-delete с TTL 30 дней (для возможности восстановить), потом hard-delete по cron.

Schema-per-tenant: DROP SCHEMA tenant_xxx CASCADE. Атомарно, ничего не забыто.

DB-per-tenant: DROP DATABASE. Чище некуда.

Мониторинг

Метрики — обязательно с tag tenant_id. Иначе вы видите общую картину, но не понимаете, кому именно плохо. В Grafana дашборд: top-10 тенантов по latency, top-10 по 5xx, top-10 по объёму данных.

Итого

Для большинства новых SaaS — shared DB с tenant_id и Postgres RLS. Это покрывает 90% сценариев, дёшево в эксплуатации, быстро в разработке. Schema-per-tenant и DB-per-tenant — когда есть конкретные регуляторные или enterprise-требования. Не выбирайте сложную модель «на вырост», вы себя похороните операциями. Лучше потом мигрировать enterprise-клиентов в отдельные БД, чем сразу всех тащить через DB-per-tenant.

Частые вопросы

Postgres RLS — это медленно?

Накладные расходы 5-15% к обычному SELECT. На правильно проиндексированных таблицах разницу заметно только в нагрузочных тестах. Это не повод отказываться — спокойствие за безопасность данных важнее. Альтернатива (ручной фильтр в каждом запросе) — рано или поздно даст утечку из-за человеческой ошибки.

Сколько тенантов выдержит shared DB на Postgres?

Зависит от объёма данных. До 10 000 тенантов с разумным объёмом (десятки тысяч строк на тенанта) — Postgres держит спокойно на одной машине с 16-32 ГБ RAM. После — нужно или партиционирование таблиц по tenant_id, или переход на DB-per-tenant для крупных клиентов. Самые большие SaaS на shared DB — миллионы тенантов с шардированием.

Как тестировать изоляцию данных?

Регрессы: автотесты создают двух тенантов, делают данные в обоих, авторизуются как один и проверяют, что эндпоинты не возвращают данные второго. Плюс fuzzing — рандомный ID в URL, проверка 404/403 (не 200 с чужими данными). Это критическая часть QA, не экономьте.

Что выбрать для SaaS под медицину или банки?

DB-per-tenant. Регуляторка (152-ФЗ + отраслевые стандарты) почти всегда требует физической изоляции данных, отдельных бэкапов, отдельного журнала аудита. Shared DB пройти аудит будет сложно. Стоимость инфры выше, но иначе на этот рынок не попасть.

Custom domain для тенантов — насколько сложно?

Архитектурно — клиент ставит CNAME на ваш сервер, вы выдаёте сертификат. Реализация: Caddy с on-demand TLS из коробки, или Cloudflare for SaaS ($100+/мес), или nginx + acme.sh с автоматизацией. Главное — process для проверки, что клиент действительно владеет доменом (TXT-запись или специальный URL).

Как мигрировать с shared DB на schema-per-tenant?

Пошагово: 1) добавить логику чтения/записи в обе модели одновременно (feature flag). 2) Перенести данные одного тенанта в новую схему, переключить флаг, проверить. 3) Перенести остальных. 4) Удалить старые таблицы. Ожидайте 2-4 месяца на проект средней сложности. Лучше сразу строить правильно, чем мигрировать.

Можно ли менять модель уже после запуска?

Можно, но дорого — переезд занимает месяцы и риск даунтайма. Поэтому модель выбирают до первой коммерческой версии. Изменение «shared → schema-per-tenant» — реальный, делается. «Shared → DB-per-tenant» — почти переписывание инфраструктуры. Думайте на этапе архитектурной фазы, не позже.