Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Мульти‑тенант SaaS на PostgreSQL и Django: как расти на одной платформе без утечек и лишних серверов

Разработка и технологии17 декабря 2025 г.
Покажу, как выстроить мульти‑тенант архитектуру: обслуживать сотни компаний на одной кодовой базе и базе данных, не смешивая данные и не раздувая расходы. Разберём варианты изоляции, Row Level Security в PostgreSQL, паттерны в Django, тесты и типовые ловушки — от кэша до фоновых задач.
Мульти‑тенант SaaS на PostgreSQL и Django: как расти на одной платформе без утечек и лишних серверов

Оглавление

  • Зачем бизнесу мульти‑тенант и когда он окупается
  • Варианты изоляции: отдельные инстансы, базы, схемы, строки
    • Отдельный инстанс на клиента
    • Отдельная база на клиента
    • Отдельная схема per tenant в PostgreSQL
    • Общая схема, изоляция по строкам (tenant_id)
  • Базовый дизайн на PostgreSQL c RLS: изоляция на уровне строк
    • Схема и политики RLS
  • Паттерн в Django: как прокинуть tenant во все запросы
    • Middleware: определяем арендатора и настраиваем сессию БД
    • Модели и менеджер: защита от «забыли фильтр»
    • Контроллеры: не доверяйте tenant из тела запроса
  • Тесты на изоляцию и безопасность
  • Производительность: индексы, партиционирование, кэш
  • Бэкапы, экспорт данных и «право на переносимость»
  • Доступы внутри арендатора: роли и права
  • Фоновые задачи и интеграции: как не перепутать клиентов
  • Переезд с одиночной модели к мульти‑тенант: пошагово
  • Чек‑лист перед запуском в прод
  • Заключение

Зачем бизнесу мульти‑тенант и когда он окупается

Мульти‑тенант (multi‑tenant) — это когда одна платформа обслуживает много компаний («арендаторов») с полной изоляцией их данных. Для бизнеса это:

  • меньшие постоянные расходы: одна инфраструктура вместо десятков инстансов;
  • более быстрые релизы: одна кодовая база, единый цикл обновлений;
  • предсказуемость поддержки: меньше «зоопарка» конфигураций;
  • готовность к энтерпрайз‑продажам: изоляция, аудит, экспорт данных.

Сигналы, что пора переходить:

  • становится дорого держать по серверу на клиента;
  • релизы у разных клиентов расходятся, саппорт горит;
  • клиенты просят единые SSO, роли и аудит‑лог;
  • растёт нагрузка и нужны общие оптимизации, а не костыли на каждом инстансе.

Варианты изоляции: отдельные инстансы, базы, схемы, строки

Есть четыре популярных варианта. Кратко про плюсы/минусы и когда брать какой.

Отдельный инстанс на клиента

  • Плюсы: железобетонная изоляция, удобно для очень больших и «регуляторных» клиентов.
  • Минусы: дорого и медленно в поддержке, релизы растягиваются.
  • Когда: 1–10 крупных клиентов, особые требования по комплаенсу.

Отдельная база на клиента

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

Отдельная схема per tenant в PostgreSQL

  • Плюсы: компромисс — одна база, отдельные схемы, можно использовать search_path.
  • Минусы: миграции множатся по схемам, увеличивается сложность администрирования.
  • Когда: десятки–сотни клиентов, немного повышенные требования к изоляции.

Общая схема, изоляция по строкам (tenant_id)

  • Плюсы: простая эксплуатация, одна схема и миграции, минимальные затраты.
  • Минусы: ответственность на приложении и политике безопасности в БД.
  • Когда: большинство B2B SaaS с сотнями–тысячами клиентов. Дальше можно добавить Row Level Security (RLS) в PostgreSQL, чтобы изоляция стала «железной».

Ниже разберём практичный путь: общая схема + RLS. Это хорошо масштабируется, просто поддерживается и даёт сильные гарантии изоляции.

Базовый дизайн на PostgreSQL c RLS: изоляция на уровне строк

Идея: у каждой бизнес‑строки есть колонка tenant_id. База сама не отдаст ничего, кроме строк текущего арендатора — благодаря RLS‑политике.

Схема и политики RLS

-- 1) Пространство имён для служебных объектов
CREATE SCHEMA IF NOT EXISTS app;

-- 2) Таблица арендаторов
CREATE TABLE IF NOT EXISTS app.tenants (
  id          uuid PRIMARY KEY,
  name        text NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  active      boolean NOT NULL DEFAULT true
);

-- 3) Утилита: получить текущего арендатора из параметра сессии
CREATE OR REPLACE FUNCTION app.current_tenant() RETURNS uuid
LANGUAGE sql STABLE AS $$
  SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid
$$;

-- 4) Пример бизнес-таблицы
CREATE TABLE IF NOT EXISTS public.projects (
  id          uuid PRIMARY KEY,
  tenant_id   uuid NOT NULL REFERENCES app.tenants(id),
  slug        text NOT NULL,
  title       text NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- 5) Уникальность внутри арендатора
CREATE UNIQUE INDEX IF NOT EXISTS projects_tenant_slug_uidx
ON public.projects(tenant_id, slug);

-- 6) Включаем RLS и задаём политику
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY IF NOT EXISTS projects_isolation ON public.projects
USING (tenant_id = app.current_tenant())
WITH CHECK (tenant_id = app.current_tenant());

-- 7) Защита от "забыли проставить tenant_id"
CREATE OR REPLACE FUNCTION app.fill_tenant_id() RETURNS trigger AS $$
BEGIN
  IF NEW.tenant_id IS NULL THEN
    NEW.tenant_id := app.current_tenant();
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_projects_fill_tenant ON public.projects;
CREATE TRIGGER trg_projects_fill_tenant
BEFORE INSERT ON public.projects
FOR EACH ROW EXECUTE FUNCTION app.fill_tenant_id();

Что это даёт:

  • Любой SELECT/UPDATE/DELETE увидит только строки текущего арендатора.
  • Вставка без tenant_id автоматически проставит его из сессии.
  • Уникальные индексы и внешние ключи учитывают tenant_id.

Важно: приложение должно устанавливать параметр сессии app.tenant_id при каждом запросе к БД.

Паттерн в Django: как прокинуть tenant во все запросы

Мы считаем, что арендатор определяется по домену вида customer.example.com или по заголовку X-Tenant-Id (только от вашего обратного прокси), а не приходит «честным словом» из браузера.

Middleware: определяем арендатора и настраиваем сессию БД

# middleware.py
import uuid
from contextvars import ContextVar
from django.db import connection
from django.http import HttpResponseForbidden
from django.utils.deprecation import MiddlewareMixin

_current_tenant: ContextVar[str | None] = ContextVar("current_tenant", default=None)

TRUSTED_HEADER = "HTTP_X_TENANT_ID"  # приходит от вашего Nginx/Traefik, а не из JS

class TenantMiddleware(MiddlewareMixin):
    def process_request(self, request):
        # 1) Берём tenant из поддомена или доверенного заголовка
        host = request.get_host().split(":")[0]
        sub = host.split(".")[0]
        tenant_id = None

        if sub and sub not in ("www", "app"):  # простой разбор поддомена
            # В проде тут обычно маппинг поддомена к tenant_id в памяти/кэше/БД
            # Для примера считаем, что поддомен — это UUID
            try:
                uuid.UUID(sub)
                tenant_id = sub
            except Exception:
                pass

        if not tenant_id:
            tenant_id = request.META.get(TRUSTED_HEADER)

        if not tenant_id:
            return HttpResponseForbidden("Tenant is required")

        # 2) Сохраняем в контекст запроса
        request.tenant_id = tenant_id
        _current_tenant.set(tenant_id)

        # 3) Настраиваем параметр сессии Postgres для RLS
        with connection.cursor() as cur:
            cur.execute("SELECT set_config('app.tenant_id', %s, false)", [tenant_id])

    def process_response(self, request, response):
        _current_tenant.set(None)
        return response

# Хелпер для фоновых задач
def get_current_tenant() -> str | None:
    return _current_tenant.get()

Модели и менеджер: защита от «забыли фильтр»

# models.py
import uuid
from django.db import models
from django.db.models import Manager
from .middleware import get_current_tenant

class TenantQuerySet(models.QuerySet):
    def _for_tenant(self):
        tenant = get_current_tenant()
        if tenant is None:
            return self
        return self.filter(tenant_id=tenant)

    # Автоматически добавляем фильтр к самым частым операциям
    def all(self):
        return super().all()._for_tenant()

    def filter(self, *args, **kwargs):
        qs = super().filter(*args, **kwargs)
        return qs._for_tenant()

class TenantManager(Manager.from_queryset(TenantQuerySet)):
    pass

class Project(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    tenant_id = models.UUIDField()
    slug = models.SlugField()
    title = models.CharField(max_length=255)
    created_at = models.DateTimeField(auto_now_add=True)

    objects = TenantManager()

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["tenant_id", "slug"], name="projects_tenant_slug_uidx_py"),
        ]

Здесь RLS — главный «охранник», а менеджер — подстраховка, чтобы при обычных вызовах ORM мы не забыли про фильтр.

Контроллеры: не доверяйте tenant из тела запроса

# views.py
from django.http import JsonResponse, HttpResponseBadRequest
from .models import Project
from .middleware import get_current_tenant

def create_project(request):
    if request.method != "POST":
        return HttpResponseBadRequest("POST only")

    tenant_id = get_current_tenant()
    title = request.POST.get("title")
    slug = request.POST.get("slug")

    project = Project.objects.create(tenant_id=tenant_id, title=title, slug=slug)
    return JsonResponse({"id": str(project.id), "slug": project.slug})

Ключевая идея: сервер сам определяет текущего арендатора и проставляет tenant_id, не верит данным из браузера.

Тесты на изоляцию и безопасность

Минимальный набор проверок: чужие данные не видны, нельзя записать c чужим tenant_id, фоновые задачи не «забывают» контекст.

# tests/test_tenant_isolation.py
import uuid
import pytest
from django.db import connection
from app.models import Project

@pytest.mark.django_db
def test_rls_isolation(client):
    t1, t2 = str(uuid.uuid4()), str(uuid.uuid4())

    # Имитируем два запроса от разных арендаторов
    def set_tenant(t):
        with connection.cursor() as cur:
            cur.execute("SELECT set_config('app.tenant_id', %s, false)", [t])

    set_tenant(t1)
    p1 = Project.objects.create(tenant_id=t1, title="A", slug="a")

    set_tenant(t2)
    p2 = Project.objects.create(tenant_id=t2, title="B", slug="b")

    # В сессии t2 мы не видим записи t1
    assert list(Project.objects.filter(slug="a")) == []

    # Возврат в t1 — видим свою запись
    set_tenant(t1)
    assert Project.objects.get(slug="a").id == p1.id

    # Попытка записать "чужой" tenant сквозь RLS провалится
    with pytest.raises(Exception):
        Project.objects.create(tenant_id=t2, title="hack", slug="h")

Производительность: индексы, партиционирование, кэш

  • Индексы всегда начинайте c tenant_id: (tenant_id, slug), (tenant_id, created_at), (tenant_id, status).
  • Если таблицы быстро растут, партиционируйте по времени, а внутри каждой партиции учитывайте tenant_id. Это ускорит запросы и упростит удаление старых данных.
  • Кэш и Redis: ключи всегда с префиксом арендатора. Пример: cache
    f"{tenant_id}:project:{project_id}". Иначе будет «перетекание» данных между клиентами.
  • Пул соединений: каждое соединение должно получать свой set_config(app.tenant_id, ...). Если используете пулер (pgBouncer), проверьте режим (session vs transaction) и устанавливайте контекст на каждом запросе/транзакции.

Бэкапы, экспорт данных и «право на переносимость»

  • Бэкапы: делайте регулярные снапшоты всей базы + возможность выгрузить данные конкретного арендатора (COPY ... WHERE tenant_id=...). Это важный аргумент в продажах.
  • Экспорт: отдельная кнопка «Выкачать свои данные» — JSON/CSV/Parquet. Хорошая практика — фоновая задача, готовый архив, ссылка с ограниченным сроком.
  • Восстановление: проверьте процедуру точечного восстановления по tenant_id в тестовом стенде. Чётко зафиксируйте RPO/RTO в SLA.

Доступы внутри арендатора: роли и права

Изоляция между арендаторами — это половина дела. Внутри каждого арендатора нужны роли:

  • владелец (управляет тарифом и доступами),
  • администратор (всё внутри арендатора),
  • редактор (создаёт/меняет свои сущности),
  • наблюдатель (только чтение).

Модель RBAC храните в таблицах users, user_roles, role_permissions с tenant_id. Проверки прав — в сервисах/декораторах, кэшируйте пермишены. В CRUD‑слое старайтесь проводить проверку до похода в БД (но не вместо RLS).

Фоновые задачи и интеграции: как не перепутать клиентов

  • Любая задача в очереди обязана нести tenant_id в payload. Обёртка вокруг Celery/RQ должна первым делом делать set_config('app.tenant_id', ...).
  • Вебхуки внешним системам подписывайте по арендаторам: разные секреты, разные конечные точки, метки в логах.
  • Интеграции «один аккаунт на всех» (например, общий SMTP) — добавляйте префикс tenant_id в метаданные и логи. Иначе дебаг превращается в кошмар.

Пример обёртки над задачей:

# tasks.py
from celery import shared_task
from django.db import connection

@shared_task(bind=True)
def tenant_task(self, tenant_id: str, payload: dict):
    # Устанавливаем контекст арендатора для RLS
    with connection.cursor() as cur:
        cur.execute("SELECT set_config('app.tenant_id', %s, false)", [tenant_id])

    # ... дальше ваша логика
    # Все ORM-запросы будут изолированы этим tenant_id

Переезд с одиночной модели к мульти‑тенант: пошагово

  1. Добавьте tenant_id в ключевые таблицы, проставьте значения для текущих данных (временно один «глобальный» арендатор). Создайте индексы (tenant_id, ...).
  2. Включите RLS в режиме «мягкой проверки»: сначала USING без WITH CHECK, затем добавьте WITH CHECK.
  3. Введите определение арендатора по домену/заголовку. Проставляйте set_config в middleware.
  4. Перенесите авторизацию/права на модель с tenant_id.
  5. Прогоните нагрузочные тесты, проверьте отчёты и аналитики: все агрегаты должны группироваться по tenant_id.
  6. Включите экспорт данных и подготовьте инструкции по миграции клиента на отдельную базу/схему — на случай особых условий договора.

Чек‑лист перед запуском в прод

  • RLS включён на всех таблицах с данными клиентов; есть политики USING и WITH CHECK.
  • set_config('app.tenant_id', ...) устанавливается для каждого HTTP‑запроса и фоновой задачи.
  • Индексы начинаются с tenant_id.
  • Кэш/Redis/ключи файлов в хранилище имеют префикс tenant_id.
  • Тесты доказывают, что арендатор А не видит данных арендатора Б.
  • Логи и метрики содержат tenant_id (без персональных данных).
  • Бэкапы и экспорт по арендаторам протестированы.
  • Роли/права внутри арендатора настроены; опасные действия подтверждаются.
  • Документация для саппорта: как найти и восстановить данные конкретного арендатора.

Заключение

Мульти‑тенант — это не про «сэкономить на серверах любой ценой». Это про управляемую масштабируемость: единая кодовая база, строгая изоляция данных и дешёвое сопровождение. PostgreSQL с Row Level Security даёт сильные гарантии на уровне самой базы, а легкие паттерны в Django позволяют аккуратно передавать контекст арендатора в HTTP и фоновые задачи. Добавьте индексы, кэш с префиксами, экспорт данных и роли — и платформа готова к росту без хаоса и утечек.


PostgreSQLSaaSмульти‑тенант