Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

Мультитенантность в B2B‑сервисах: изоляция данных и производительность без удвоения инфраструктуры

Разработка и технологии30 марта 2026 г.
Если вы продаёте один SaaS многим компаниям, мультитенантность позволяет изолировать данные клиентов, держать высокую скорость и при этом не плодить отдельные кластеры на каждого. Разбираем модели изоляции, схемы БД, RLS в PostgreSQL, индексы, кэш, полнотекстовый поиск, «шумных соседей», бэкапы и миграции без простоя. В конце — минимальный, но готовый к использованию пример с RLS и установкой контекста арендатора из кода.
Мультитенантность в B2B‑сервисах: изоляция данных и производительность без удвоения инфраструктуры

Оглавление

  • Зачем бизнесу мультитенантность
  • Модели изоляции: от одной базы до выделенного кластера
  • Схема данных: tenant_id в ключах, внешних ключах и индексах
  • Изоляция на уровне БД: RLS в PostgreSQL
    • Политики и настройка контекста арендатора
    • Пулы соединений и SET LOCAL
  • Производительность и «шумные соседи»
  • Кэш и поиск без утечек между арендаторами
  • Резервное копирование и восстановление «по арендатору»
  • Безопасность и разграничение прав
  • Наблюдаемость и биллинг по арендаторам
  • Миграция между моделями изоляции без простоя
  • Чек‑лист внедрения
  • Минимальный рабочий пример: RLS + Go

Зачем бизнесу мультитенантность

Один продукт — десятки и сотни клиентов. Каждый хочет, чтобы данные были изолированы, а скорость не падала, когда сосед запустил массовую загрузку. Держать отдельную инфраструктуру под каждого — дорого и медленно масштабируется. Мультитенантность позволяет:

  • изолировать данные и права на уровне приложения и базы;
  • контролировать шумных соседей — распределять ресурсы и ограничивать фоновые работы;
  • прозрачно считать себестоимость и выставлять счета по использованию;
  • проще соответствовать требованиям заказчиков и регуляторов (аудит, удаление, экспорт).

Ключ: выбрать модель изоляции и поддерживать её во всех слоях — схемы БД, индексы, кэш, поиск, фоновые задачи, мониторинг и бэкапы.

Модели изоляции: от одной базы до выделенного кластера

Есть четыре базовые модели. Чем выше изоляция — тем дороже сопровождение.

  1. Одна база, одни таблицы: колонка tenant_id в каждой строке. Минимальные затраты, хороший кеш и шаринг ресурсов. Риски: сложнее резервное восстановление «по арендатору», нужна строгая дисциплина в схемах и индексах.

  2. Одна база, разные схемы (schema-per-tenant): таблицы дублируются в схемах. Проще бэкап/рестор для конкретного арендатора, но усложняются миграции (нужно обновлять много схем), растут системные накладные.

  3. Отдельные базы (database-per-tenant) в одном кластере. Хорошая изоляция, отдельные бэкапы и ресурсы. Но число соединений и накладные на обслуживание быстро растут.

  4. Отдельные кластеры. Максимальная изоляция, понятный биллинг. Дорого, сложнее централизовать обновления, хуже общий кэш.

На старте чаще всего берут модель №1 с жёсткой дисциплиной: tenant_id в ключах, внешних ключах и индексах, плюс RLS для страховки. По мере роста крупных клиентов — гибрид: массовые — в общей базе, «киты» — в отдельной базе или кластере.

Схема данных: tenant_id в ключах, внешних ключах и индексах

Типовые ошибки — отсутствие tenant_id во внешних ключах и неэффективные индексы, из-за чего база делает лишние сканы.

Рекомендации:

  • Добавляйте tenant_id во ВСЕ бизнес‑таблицы.
  • Держите составные первичные ключи (tenant_id, id) или как минимум уникальные индексы на (tenant_id, id).
  • Во внешних ключах всегда ссылайтесь на пару (tenant_id, id), чтобы физически исключить перекрёстные ссылки между арендаторами.
  • Индексы начинайте с tenant_id: (tenant_id, created_at), (tenant_id, status, created_at) — так планировщик будет отрезать «чужие» данные сразу.

Пример DDL:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE tenants (
  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
  name text NOT NULL
);

-- Составные ключи по (tenant_id, id)
CREATE TABLE users (
  tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  id uuid NOT NULL DEFAULT uuid_generate_v4(),
  email text NOT NULL,
  role text NOT NULL,
  PRIMARY KEY (tenant_id, id),
  UNIQUE (tenant_id, email)
);

CREATE TABLE orders (
  tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  id bigint GENERATED BY DEFAULT AS IDENTITY,
  user_tenant_id uuid NOT NULL,
  user_id uuid NOT NULL,
  amount_cents bigint NOT NULL CHECK (amount_cents >= 0),
  status text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, id),
  CONSTRAINT fk_orders_user
    FOREIGN KEY (tenant_id, user_tenant_id, user_id)
    REFERENCES users (tenant_id, tenant_id, id)
      DEFERRABLE INITIALLY IMMEDIATE
);

-- Важно: индексы по tenant_id во всех горячих запросах
CREATE INDEX idx_orders_tenant_created
  ON orders(tenant_id, created_at DESC);

CREATE INDEX idx_orders_tenant_status_created
  ON orders(tenant_id, status, created_at DESC);

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

Изоляция на уровне БД: RLS в PostgreSQL

Row Level Security (RLS) — последний рубеж обороны: даже при ошибке в коде запрос не вернёт чужие строки. Но RLS требует аккуратной настройки.

Политики и настройка контекста арендатора

Идея: при открытии транзакции выставляем контекст текущего арендатора через параметр сессии, а в политиках RLS сравниваем tenant_id со значением этого параметра.

-- Системный параметр, где будем хранить текущего арендатора
-- (создавать его не надо, достаточно SET app.current_tenant)

ALTER TABLE users  ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Запрещаем доступ по умолчанию (если нет подходящей политики)
ALTER TABLE users  FORCE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

-- Политики «только свои строки»
CREATE POLICY p_users_tenant_isolation ON users
  USING (tenant_id = current_setting('app.current_tenant', true)::uuid);

CREATE POLICY p_orders_tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant', true)::uuid);

-- Разрешить вставку только «внутрь своего арендатора»
CREATE POLICY p_users_insert ON users FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::uuid);

CREATE POLICY p_orders_insert ON orders FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::uuid);

Теперь любая команда SELECT/INSERT/UPDATE/DELETE без нужного SET app.current_tenant = ... не увидит ни одной строки и не сможет записать «чужого» арендатора.

Проверьте план запросов — Postgres должен рано применять фильтр по tenant_id и использовать составные индексы.

EXPLAIN ANALYZE
SELECT id, status FROM orders
WHERE created_at >= now() - interval '7 days'
ORDER BY created_at DESC
LIMIT 50;

Если в плане сканируется весь индекс без отсечения по tenant_id — добавьте соответствующий индекс с префиксом tenant_id.

Пулы соединений и SET LOCAL

Если вы используете pgbouncer с режимом пуллинга на уровне транзакций, сессионные параметры не сохраняются между транзакциями. Решения:

  • используйте пуллинг на уровне сессии для сервисов, которые полагаются на сессионные параметры;
  • или всегда открывайте транзакцию и делайте SET LOCAL app.current_tenant = ... в её начале — параметр будет действовать внутри транзакции.

Производительность и «шумные соседи»

  • Отдельные очереди фоновых задач по арендаторам, квоты и планировщик: не давайте одному арендатору занять все воркеры.
  • Лимиты на длительность запросов (statement_timeout) и на количество параллельных задач на арендатора.
  • Предсказуемые индексы: все горячие запросы должны начинаться с tenant_id.
  • Поддерживайте «горячие» материализованные представления или витрины по арендаторам, если отчёты тяжёлые.
  • Следите за количеством соединений: для модели «база на всех» выгоднее держать меньше долгих соединений и больше коротких, а pgbouncer разгрузит сервер.

Пример жёсткого тайм‑аута на сеанс:

SET LOCAL statement_timeout = '2s';

Кэш и поиск без утечек между арендаторами

Кэш:

  • Всегда кодируйте ключи с префиксом арендатора: tenant:{id}:orders:page:1.
  • Настройте справедливое выселение: большой арендатор не должен вытеснить кэш других.
  • Проверяйте, что глобальные кэши (например, конфигурация) не содержат клиентские данные.

Пример с Redis:

// кэш списка заказов по арендатору
key := fmt.Sprintf("tenant:%s:orders:list:%s", tenantID, pageKey)

if val, err := rdb.Get(ctx, key).Result(); err == nil {
    return decode(val), nil
}

orders := fetchFromDB(ctx, tenantID, page)
_ = rdb.Set(ctx, key, encode(orders), 60*time.Second).Err()

Полнотекстовый поиск:

  • PostgreSQL: добавьте колонку tsv с тсвектором и составный индекс (tenant_id, tsv).
  • Elasticsearch/OpenSearch: два подхода — один индекс со строкой фильтра по tenant_id и маршрутизацией по нему, либо отдельный индекс на арендатора. До ~тысяч арендаторов с умеренным объёмом проще один индекс с жёстким фильтром.

Пример запроса в Elasticsearch:

{
  "query": {
    "bool": {
      "filter": [ { "term": { "tenant_id": "0a3f..." } } ],
      "must":   [ { "query_string": { "query": "оплата AND статус:успешно" } } ]
    }
  },
  "routing": "0a3f..."
}

Резервное копирование и восстановление «по арендатору»

  • Если у вас отдельные базы/схемы — восстановление конкретного арендатора тривиально: рестор базы/схемы из бэкапа.
  • Если общая схема с tenant_id — продумайте экспорт/импорт «среза арендатора». Можно:
    • хранить журнал изменений (CDC) и уметь собирать состояние арендатора на момент времени;
    • раз в сутки делать логический дамп по списку таблиц с --where для каждого tenant_id, плюс инкрементальные изменения по журналу.

Пример частичного дампа (для одной таблицы):

pg_dump "$DATABASE_URL" \
  -t public.orders \
  --column-inserts \
  --data-only \
  --where="tenant_id = '0a3f5d3d-...-...'" \
  > orders_tenant_0a3f.sql

Для полного арендатора придётся перечислить все таблицы и соблюсти порядок внешних ключей (или использовать DISABLE TRIGGER ALL на время импорта в изолированную временную базу, затем точечно переносить данные).

Безопасность и разграничение прав

  • Вход в приложение — аутентификация пользователя привязана к конкретному арендатору; в токен добавляйте tenant_id и роли.
  • В БД — RLS и роли на уровне схем/таблиц. Отключите прямой доступ к таблицам из‑под технических ролей без RLS.
  • Шифрование чувствительных полей (например, номера карт токенизируются, e‑mail — через envelope‑шифрование с ключом, зависящим от арендатора). Это упростит удаление/экспорт по запросу.
  • Не раскрывайте tenant_id в автонумерации URL, используйте UUID/ULID.

Наблюдаемость и биллинг по арендаторам

  • Помечайте метрики лейблом tenant: http_requests_total{tenant="...",handler="/orders"} — это позволит быстро увидеть «кого заносит». Старайтесь ограничивать кардинальность: используйте хеш/сжатое представление tenant_id или агрегируйте.
  • Логи — добавляйте tenant_id в контекст логгера; маскируйте чувствительные поля.
  • Трассировки — прокидывайте tenant_id как атрибут спана, это ускоряет разбор инцидентов.
  • Учёт затрат — снимайте потребление CPU/памяти/запросов по арендаторам и привязывайте к тарифам.

Миграция между моделями изоляции без простоя

Типичный путь: общая база → отдельная база для «китов» → при необходимости отдельный кластер.

Шаги при выносе крупного арендатора:

  1. Поднимите новую базу/кластер, примените ту же схему.
  2. Настройте логическую репликацию/CDC из общей базы, фильтруя по tenant_id.
  3. Сделайте начальный бэкап «среза арендатора», импортируйте в новую базу.
  4. Догоните изменения по журналу до нулевого лага.
  5. Короткий стоп‑the‑world для конкретного арендатора: переведите его в режим только чтение на минуту, переключите приложение на новую базу, снимите блокировку.
  6. Оставьте старую репликацию в обратную сторону на время отката (если нужно), затем выключите.

Чек‑лист внедрения

  • tenant_id есть во всех бизнес‑таблицах и внешних ключах
  • составные индексы начинаются с tenant_id
  • включён RLS, политики покрывают SELECT/INSERT/UPDATE/DELETE
  • приложение делает SET LOCAL app.current_tenant в начале транзакции
  • кэш‑ключи префиксованы tenant_id
  • поиск фильтрует по tenant_id и использует маршрутизацию
  • фоновые очереди и квоты изолированы по арендаторам
  • метрики/логи/трейсы промаркированы tenant_id
  • продуман экспорт/импорт «среза арендатора» (бэкапы)
  • план миграции крупных арендаторов в отдельные базы согласован

Минимальный рабочий пример: RLS + Go

Ниже — короткий пример на Go с database/sql и pgx как драйвером. Он:

  • открывает транзакцию,
  • устанавливает SET LOCAL app.current_tenant,
  • делает выборку заказов с защитой RLS,
  • создаёт заказ, автоматически подставляя tenant_id из приложения.

DDL у нас уже есть выше. Добавим триггер, чтобы не забыть tenant_id при вставке:

CREATE OR REPLACE FUNCTION enforce_tenant_on_insert()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF NEW.tenant_id IS NULL THEN
    NEW.tenant_id := current_setting('app.current_tenant', true)::uuid;
  END IF;
  IF NEW.tenant_id <> current_setting('app.current_tenant', true)::uuid THEN
    RAISE EXCEPTION 'tenant_id mismatch';
  END IF;
  RETURN NEW;
END; $$;

CREATE TRIGGER trg_orders_enforce_tenant
BEFORE INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION enforce_tenant_on_insert();

Код Go:

package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
)

type Order struct {
    ID        int64
    Status    string
    CreatedAt time.Time
}

func withTenantTx(ctx context.Context, db *sql.DB, tenantID string, fn func(*sql.Tx) error) error {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    if err != nil { return err }
    // Важно: SET LOCAL внутри транзакции, чтобы не зависеть от режима пуллинга
    if _, err := tx.ExecContext(ctx, "SET LOCAL app.current_tenant = $1", tenantID); err != nil {
        _ = tx.Rollback()
        return fmt.Errorf("set tenant: %w", err)
    }
    if _, err := tx.ExecContext(ctx, "SET LOCAL statement_timeout = '2s'"); err != nil {
        _ = tx.Rollback()
        return err
    }
    if err := fn(tx); err != nil {
        _ = tx.Rollback()
        return err
    }
    return tx.Commit()
}

func listRecentOrders(ctx context.Context, tx *sql.Tx, limit int) ([]Order, error) {
    rows, err := tx.QueryContext(ctx, `
        SELECT id, status, created_at
        FROM orders
        WHERE created_at >= now() - interval '7 days'
        ORDER BY created_at DESC
        LIMIT $1
    `, limit)
    if err != nil { return nil, err }
    defer rows.Close()
    var out []Order
    for rows.Next() {
        var o Order
        if err := rows.Scan(&o.ID, &o.Status, &o.CreatedAt); err != nil { return nil, err }
        out = append(out, o)
    }
    return out, rows.Err()
}

func createOrder(ctx context.Context, tx *sql.Tx, userTenantID, userID string, amount int64) (int64, error) {
    var id int64
    err := tx.QueryRowContext(ctx, `
        INSERT INTO orders (user_tenant_id, user_id, amount_cents, status)
        VALUES ($1::uuid, $2::uuid, $3, 'new')
        RETURNING id
    `, userTenantID, userID, amount).Scan(&id)
    return id, err
}

func main() {
    dsn := "postgres://app:app@localhost:5432/appdb?sslmode=disable"
    db, err := sql.Open("pgx", dsn)
    if err != nil { log.Fatal(err) }
    defer db.Close()

    ctx := context.Background()
    tenant := "0a3f5d3d-0000-4000-8000-000000000001"

    // Создать заказ и прочитать последние
    err = withTenantTx(ctx, db, tenant, func(tx *sql.Tx) error {
        if _, err := tx.ExecContext(ctx, "SET LOCAL application_name = 'orders-api'"); err != nil { return err }
        newID, err := createOrder(ctx, tx, tenant, "0a3f5d3d-0000-4000-8000-0000000000ab", 19900)
        if err != nil { return err }
        log.Println("created order", newID)
        orders, err := listRecentOrders(ctx, tx, 10)
        if err != nil { return err }
        for _, o := range orders { log.Printf("order id=%d status=%s", o.ID, o.Status) }
        return nil
    })
    if err != nil { log.Fatal(err) }
}

Проверьте, что при отсутствии SET LOCAL app.current_tenant список заказов пуст, а при попытке вставить заказ с чужим tenant_id триггер выдаст ошибку.


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


PostgreSQLархитектураRLSмультитенантность