← назад к разделу

Представьте: вы делаете сервис управления задачами, которым пользуются сразу несколько компаний. Данные компании А не должны видеть сотрудники компании Б. Как хранить всё это в одной PostgreSQL?

Это задача multi-tenancy — работа с несколькими «жильцами» (tenants) на одной инфраструктуре. Есть три классических подхода, и выбор между ними — это всегда компромисс между простотой, изоляцией и стоимостью.

Три подхода и чем они отличаются

Row-per-tenantSchema-per-tenantDB-per-tenant
Данныеобщие таблицы + tenant_idотдельные схемыотдельные базы
Изоляциялогическая (через RLS)физическая в кластереполная физическая
Миграцииодин раз для всехотдельно для каждого тенантаотдельно для каждого тенанта
Резервные копии per-tenantсложносредне (pg_dump --schema=)просто
Масштабтысячи тенантовдо сотенединицы — десятки

Самый распространённый вариант на практике — гибрид: маленькие и бесплатные клиенты хранятся в общей базе, крупные enterprise-клиенты получают отдельную базу.

Row-per-tenant — простой и масштабируемый вариант

Все клиенты живут в одних и тех же таблицах. К каждой строке добавляется колонка tenant_id, которая указывает, кому она принадлежит.

CREATE TABLE tenant (
    id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name text NOT NULL UNIQUE
);

CREATE TABLE order_doc (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    tenant_id   bigint NOT NULL REFERENCES tenant(id),
    customer_id bigint NOT NULL,
    PRIMARY KEY (tenant_id, id)
);

-- tenant_id первой колонкой в индексе — запросы работают только по данным тенанта
CREATE INDEX ix_order_tenant_status ON order_doc (tenant_id, status);

Главное правило: каждый запрос обязан включать WHERE tenant_id = ?. Без этого один клиент увидит данные другого — это критическая уязвимость безопасности.

Пример правильного запроса:

import org.jooq.DSLContext;
import static com.example.generated.tables.OrderDoc.ORDER_DOC;

public class TenantAwareDsl {
    private final DSLContext dsl;
    private final TenantContext ctx;

    public Result<Record> selectOrders() {
        return dsl.selectFrom(ORDER_DOC)
            .where(ORDER_DOC.TENANT_ID.eq(ctx.currentTenantId()))
            .fetch();
    }
}
func selectOrders(ctx context.Context, pool *pgxpool.Pool, tenantID int64) (pgx.Rows, error) {
    return pool.Query(ctx,
        "SELECT * FROM order_doc WHERE tenant_id = $1",
        tenantID,
    )
}
async function selectOrders(pool: Pool, tenantId: bigint): Promise<QueryResult> {
    return pool.query(
        'SELECT * FROM order_doc WHERE tenant_id = $1',
        [tenantId],
    );
}
async def select_orders(conn: asyncpg.Connection, tenant_id: int) -> list[asyncpg.Record]:
    return await conn.fetch(
        "SELECT * FROM order_doc WHERE tenant_id = $1",
        tenant_id,
    )

Композитный первичный ключ (tenant_id, id) гарантирует, что все индексы таблицы автоматически сегментированы по тенанту — запросы одного клиента не просматривают данные другого.

Row-Level Security — страховка от ошибок в коде

Фильтрация через WHERE tenant_id = ? — первая линия защиты. Но если разработчик забудет добавить условие в один из запросов, данные утекут.

Row-Level Security (RLS) — механизм PostgreSQL, который добавляет фильтрацию прямо на уровне базы. Даже если приложение отправит SELECT * FROM order_doc без условий, база вернёт только строки текущего тенанта.

ALTER TABLE order_doc ENABLE ROW LEVEL SECURITY;

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

Перед запросами нужно сообщить базе, с каким тенантом работаем — через переменную сессии:

// внутри транзакции, до выполнения запросов
connection.createStatement().execute(
    "SET LOCAL app.tenant_id = " + tenantId
);
// далее любые SELECT/UPDATE автоматически отфильтрованы базой
func setTenant(ctx context.Context, tx pgx.Tx, tenantID int64) error {
    _, err := tx.Exec(ctx, fmt.Sprintf("SET LOCAL app.tenant_id = %d", tenantID))
    return err
}
async function setTenant(client: PoolClient, tenantId: bigint): Promise<void> {
    await client.query(`SET LOCAL app.tenant_id = ${tenantId}`);
}
async def set_tenant(conn: asyncpg.Connection, tenant_id: int) -> None:
    await conn.execute(f"SET LOCAL app.tenant_id = {tenant_id}")

Почему именно SET LOCAL, а не SET

Это важный нюанс, который легко пропустить.

-- Опасно: переменная живёт всю сессию соединения
SET app.tenant_id = 42;

-- Правильно: переменная сбрасывается при завершении транзакции
SET LOCAL app.tenant_id = 42;

Приложения используют пул соединений — одно и то же соединение с базой последовательно обслуживает разных пользователей. Если установить переменную через SET без LOCAL, она останется после завершения запроса. Следующий пользователь, получивший это соединение из пула, окажется «под личиной» предыдущего тенанта.

SET LOCAL работает только в рамках текущей транзакции и автоматически сбрасывается при COMMIT или ROLLBACK.

RLS не действует на суперпользователя

Политики RLS применяются только к обычным ролям. Суперпользователь PostgreSQL видит все строки без ограничений. Поэтому в production приложение должно подключаться от обычной роли, а суперпользователь — только для административных задач.

Schema-per-tenant — для корпоративных требований

Каждый тенант получает собственную схему в одной базе данных: таблицы называются одинаково, но физически разделены.

CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;

CREATE TABLE tenant_acme.order_doc (...);
CREATE TABLE tenant_globex.order_doc (...);

Запросы к нужной схеме делаются через явное указание или через search_path:

-- явное указание схемы
SELECT * FROM tenant_acme.order_doc;

-- или через search_path — устанавливается в начале транзакции
SET LOCAL search_path = tenant_acme, public;
SELECT * FROM order_doc;
// tenantSchema — проверенная строка из реестра тенантов, не из пользовательского ввода
connection.createStatement().execute(
    "SET LOCAL search_path = " + tenantSchema + ", public"
);
func setSearchPath(ctx context.Context, tx pgx.Tx, tenantSchema string) error {
    _, err := tx.Exec(ctx, fmt.Sprintf("SET LOCAL search_path = %s, public", tenantSchema))
    return err
}
async function setSearchPath(client: PoolClient, tenantSchema: string): Promise<void> {
    await client.query(`SET LOCAL search_path = ${tenantSchema}, public`);
}
async def set_search_path(conn: asyncpg.Connection, tenant_schema: str) -> None:
    await conn.execute(f"SET LOCAL search_path = {tenant_schema}, public")

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

Ограничение масштаба: на 1000 и более схемах PostgreSQL начинает работать медленнее. Системные каталоги (pg_class и другие) разрастаются, планировщик запросов тратит больше времени, автоочистка перегружается. Schema-per-tenant хорошо работает до нескольких сотен тенантов, но не подходит для свободной регистрации с тысячами клиентов.

Стоимость миграций: при изменении структуры таблиц миграцию нужно применить к каждой схеме отдельно. На 100 схемах — это в 100 раз дольше, чем в row-per-tenant.

DB-per-tenant — полная изоляция для крупных клиентов

Каждый тенант получает отдельную базу данных, а иногда и отдельный сервер PostgreSQL. Приложение хранит список соединений и направляет запросы в нужную базу:

class TenantRouter {
    private final Map<String, HikariDataSource> pools = new ConcurrentHashMap<>();

    HikariDataSource poolFor(String tenantId) {
        return pools.computeIfAbsent(tenantId, id -> {
            HikariConfig cfg = new HikariConfig();
            cfg.setJdbcUrl("jdbc:postgresql://host/" + id);
            return new HikariDataSource(cfg);
        });
    }
}
type TenantRouter struct {
    pools map[string]*pgxpool.Pool
}

func (r *TenantRouter) query(ctx context.Context, tenantID string, sql string, args ...any) (pgx.Rows, error) {
    return r.pools[tenantID].Query(ctx, sql, args...)
}
class TenantRouter {
    private pools: Map<string, Pool>;

    async query(tenantId: string, sql: string, values?: unknown[]): Promise<QueryResult> {
        const pool = this.pools.get(tenantId);
        if (!pool) throw new Error(`No pool for tenant: ${tenantId}`);
        return pool.query(sql, values);
    }
}
class TenantRouter:
    def __init__(self, pools: dict[str, asyncpg.Pool]) -> None:
        self._pools = pools

    async def fetch(self, tenant_id: str, query: str, *args: Any) -> list[asyncpg.Record]:
        return await self._pools[tenant_id].fetch(query, *args)

Этот вариант подходит для небольшого числа крупных корпоративных клиентов с отдельными соглашениями об уровне сервиса, собственными требованиями к резервному копированию и аудиту.

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

Партиционирование по tenant_id

Иногда предлагают партиционировать таблицы по tenant_id — создать для каждого тенанта отдельную секцию в одной таблице. Это даёт физическую локальность данных и позволяет удалить все данные тенанта одной командой (DROP PARTITION — мгновенно, против DELETE по миллионам строк).

Но у этого подхода есть серьёзное ограничение: 1000 тенантов — это 1000 секций, и PostgreSQL начинает тормозить уже при планировании запросов. Партиционирование по tenant_id оправдано только для небольшого числа крупных тенантов (единицы — десятки), каждый из которых хранит гигабайты данных.

Частые ошибки

Row-per-tenant без WHERE tenant_id — самый опасный сценарий. Запрос без фильтра вернёт данные всех клиентов. Решение: проверять каждый запрос и включать RLS как дополнительный барьер.

SET без LOCAL для переменных сессии — тенант «протечёт» через пул соединений к следующему пользователю. Всегда использовать SET LOCAL.

Schema-per-tenant при тысячах клиентов — подход перестаёт масштабироваться из-за роста системных каталогов. Для большого числа тенантов выбирать row-per-tenant.

DB-per-tenant для всего SaaS — каждая новая регистрация создаёт отдельную базу. Операционная нагрузка растёт линейно с числом клиентов. Обычно правильнее гибрид.

Суперпользователь как production-роль — RLS не защищает от суперпользователя. Приложение подключается от обычной роли.

Коротко

  • Row-per-tenant: все в одних таблицах, tenant_id в каждой строке. Простой, масштабируется до тысяч. Каждый запрос — WHERE tenant_id = ?.
  • RLS — дополнительный барьер на уровне базы: даже если приложение забыло условие, PostgreSQL не отдаст чужие данные.
  • SET LOCAL app.tenant_id — обязательно LOCAL, иначе переменная «протечёт» через пул соединений к следующему пользователю.
  • Schema-per-tenant: отдельная схема на клиента — больше изоляции, сложнее миграции, не масштабируется на тысячи схем.
  • DB-per-tenant: полная изоляция для единиц крупных клиентов с отдельными SLA. Дорого в сопровождении.
  • Гибрид — самое распространённое решение: мелкие в общей базе, крупные — в отдельной.
  • Суперпользователь в production — не использовать: он обходит RLS.

Что почитать дальше

  • Партиционирование — когда и как делить таблицы на секции.
  • Составные индексы — почему tenant_id ставят первой колонкой.
  • Connection pool — пулы соединений и маршрутизация.