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

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

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

пул соединений conn-1 транзакция BEGIN; SET LOCAL COMMIT order_doc — общая таблица acme order-1 acme order-2 globex order-7 app.tenant_id = acmeacmeorder-1acmeorder-2политика RLS отсекла чужую строкувернулись 2 строки acme app.tenant_id = (пусто)COMMIT завершил транзакцию — переменная исчезла вместе с ней,соединение вернулось в пул чистым app.tenant_id = globexglobexorder-7то же соединение, другой тенантвернулась 1 строка globex

Все тенанты лежат в одной таблице, и кто чей — говорит tenant_id. Соединение берётся из пула, транзакция объявляет своего тенанта через SET LOCAL, и политика RLS отдаёт только его строки. На COMMIT переменная исчезает вместе с транзакцией, и то же соединение достаётся следующему тенанту уже чистым.

Обязательно

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

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

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

Свод: чем подходы отличаются

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

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,
    status      text   NOT NULL,
    PRIMARY KEY (tenant_id, id)
);

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

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

«Забыл фильтр» видно прямо в песочнице раздела: роль тенанта там играет seller_id в orders. Так не надо:

живой пример

SELECT seller_id, count(*) AS rows_returned
FROM orders
GROUP BY seller_id
ORDER BY seller_id;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Три строки в ответе — три разных тенанта в одном отчёте. Так надо — фильтр стоит в самом запросе:

живой пример

SELECT id, status, total_amount
FROM orders
WHERE seller_id = 'sel-02'
ORDER BY created_at;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Откуда берётся tenant_id

Прежде чем фильтровать по клиенту, его надо откуда-то узнать, и это первое решение, которое принимают в такой системе. Источников три, и выбор между ними не про удобство, а про доверие.

Из токена доступа. Идентификатор клиента лежит в подписанном токене как отдельное утверждение (tenant_id в JWT), и подделать его нельзя, не подделав подпись. Это единственный источник, которому можно верить без дополнительных проверок, и поэтому основной.

Из поддомена. acme.example.com — удобно для людей и для маршрутизации, но само по себе ничего не доказывает: заголовок узла подделывается тривиально. Поддомен годится, чтобы выбрать оформление входа и подсказать клиента, но после входа проверяют, что клиент из токена совпадает с клиентом из адреса, и при расхождении отвечают отказом.

Из заголовка запроса. Заголовок вида X-Tenant-Id допустим только между своими службами внутри периметра, где отправителю доверяют, и никогда — от браузера.

Дальше важно, что дальше по коду идентификатор передаётся не параметром метода. Параметр забудут передать в одном месте из сорока — и это и будет утечка. Его кладут в контекст запроса (в Java это обычно ThreadLocal или RequestScope-бин, заполняемый фильтром на входе), а слой доступа к данным берёт его оттуда сам — подставляя в каждый запрос или выставляя параметр сессии для политик безопасности строк. Тогда «забыть» становится невозможно технически, а не по дисциплине.

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);

Перед запросами нужно сообщить базе, с каким тенантом работаем — через переменную сессии. Только выставлять её склейкой строки ("SET LOCAL app.tenant_id = " + tenantId) не стоит: SET — это не обычный запрос, параметры он не принимает, и подставлять в него значение приходится руками. У SET есть функция-двойник set_config, и вот она параметры принимает. Третий аргумент true означает «только до конца транзакции» — ровно то же самое, что слово LOCAL:

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

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

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

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

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

Кто обходит RLS

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

Лечится одной строкой на таблицу:

ALTER TABLE order_doc FORCE ROW LEVEL SECURITY;

Проверка простая: от рабочей роли SELECT count(*) FROM order_doc без SET LOCAL обязан упасть с ошибкой — current_setting не найдёт переменную. Ошибка здесь и есть правильный ответ.

Цена политик и как её снизить

Политика безопасности строк дописывается к каждому запросу как дополнительное условие — и планировщик обязан его учесть. Обычная запись вида USING (tenant_id = current_setting('app.tenant_id')::bigint) выглядит безобидно, но функция получения параметра для планировщика непрозрачна: он не знает её значения и оценивает такое условие грубо, а в худшем случае вычисляет её для каждой строки.

Стандартный приём — обернуть получение параметра в подзапрос:

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

Скобки с SELECT превращают вызов в отдельный шаг плана (InitPlan), который выполняется один раз за запрос, а дальше сравнение идёт с готовым значением — то есть так же, как с обычным параметром, и индекс по tenant_id используется нормально. Разница на больших таблицах измеряется разами.

Ещё две вещи про цену. Политики не отменяют необходимости держать tenant_id первой колонкой в индексах — они добавляют условие, а не ускоряют его. И отдельный EXPLAIN стоит смотреть от имени роли приложения (SET ROLE app), потому что от имени владельца таблицы политики не применяются вовсе, и план получается не тот, что в бою.

Кросс-тенантные задачи владельца сервиса

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

Правильная конструкция — отдельная роль с правом обходить политики: ALTER ROLE support_admin BYPASSRLS;. Ею пользуется отдельный, узкий набор кода (админка, отчётный сервис), у неё свой пул соединений и свой журнал обращений. Роль приложения при этом остаётся без BYPASSRLS, и никакая ошибка в коде не может превратить обычный запрос в кросс-тенантный.

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

Удаление данных клиента

Договор расторгнут или клиент потребовал удалить свои данные — и выясняется, что это DELETE по десяткам таблиц и миллионам строк. Работает он долго, плодит мёртвые версии, раздувает таблицы и под нагрузкой заметен всем остальным.

Отсюда практические ходы. Удалять порциями по ключу клиента, с паузами, фоновым заданием — теми же приёмами, что и любой массовый DELETE. Порядок удаления идёт от дочерних таблиц к родительским (или через ON DELETE CASCADE, заранее объявленный в схеме). А если клиенты крупные и уходят регулярно, это тот самый довод за разбиение таблиц по tenant_id: тогда удаление превращается в отцепление и DROP части — мгновенно и без мусора.

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

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

Row-per-tenant без WHERE tenant_id — самый опасный сценарий: запрос без фильтра вернёт данные всех клиентов. Страхует RLS.

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

Политика есть не на всех таблицах — самая вероятная дыра. RLS включают таблица за таблицей, кнопки «на всю базу разом» в PostgreSQL нет. Одна забытая таблица с tenant_id — и через неё видны данные всех клиентов, сколько бы политик ни висело на соседних. Проверять это надо запросом, а не памятью: SELECT relname FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' AND NOT relrowsecurity; — и так после каждой миграции, добавившей таблицу.

Политика создана, а фильтра нет — RLS не действует ни на суперпользователя, ни на владельца таблицы. Нужен FORCE ROW LEVEL SECURITY или роль, которая таблицами не владеет.

Дополнительно: при первом чтении можно пропустить

Глубже: Row-per-tenant в коде и индексахрасширенное

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

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

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

    public TenantAwareDsl(DSLContext dsl, TenantContext ctx) {
        this.dsl = dsl;
        this.ctx = ctx;
    }

    public Result<OrderDocRecord> 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) держит данные одного клиента рядом в индексе первичного ключа: запрос с WHERE tenant_id = ? попадает сразу в нужный участок дерева, не задевая чужие строки. Только на остальные индексы это не распространяется — их придётся строить с tenant_id первой колонкой самостоятельно, как в примере выше. Индекс просто по status будет собирать в кучу заказы всех клиентов.

Глубже: SET LOCAL в коде и режим автофиксациирасширенное

Переменную сессии выставляют из кода параметром через set_config, а не склейкой строки:

// connection.setAutoCommit(false) уже вызван — транзакция открыта
try (PreparedStatement st = connection.prepareStatement(
        "SELECT set_config('app.tenant_id', ?, true)")) {
    st.setString(1, String.valueOf(tenantId));
    st.execute();
}
// далее любые SELECT/UPDATE автоматически отфильтрованы базой
func setTenant(ctx context.Context, tx pgx.Tx, tenantID int64) error {
    _, err := tx.Exec(ctx,
        "SELECT set_config('app.tenant_id', $1, true)",
        strconv.FormatInt(tenantID, 10),
    )
    return err
}
async function setTenant(client: PoolClient, tenantId: bigint): Promise<void> {
    await client.query(
        "SELECT set_config('app.tenant_id', $1, true)",
        [String(tenantId)],
    );
}
async def set_tenant(conn: asyncpg.Connection, tenant_id: int) -> None:
    await conn.execute(
        "SELECT set_config('app.tenant_id', $1, true)",
        str(tenant_id),
    )

И вот тут поджидает самая частая ошибка при внедрении RLS. Слово «текущая транзакция» надо понимать буквально: транзакция должна быть уже открыта. А драйверы по умолчанию работают в режиме автофиксации — в JDBC это autoCommit = true, — где каждый запрос сам себе транзакция. Выставили переменную, запрос закончился, транзакция закрылась, переменной больше нет. SET LOCAL в таком режиме даже не падает с ошибкой: PostgreSQL отвечает предупреждением SET LOCAL can only be used in transaction blocks и молча ничего не делает, а set_config(..., true) не говорит и того. Ошибка вылезет следующим запросом — на current_setting, который не найдёт переменную. Поэтому транзакцию открывают явно: setAutoCommit(false) в JDBC, BEGIN или tx в остальных драйверах.

Механика видна и без базы — переменная живёт на соединении, а не на запросе.

живой пример

import java.util.List;

public class TenantPool {

    private static final List<String> ROWS =
            List.of("acme:order-1", "acme:order-2", "globex:order-7");

    private static String appTenant;

    private static List<String> visibleRows() {
        return ROWS.stream().filter(row -> row.startsWith(appTenant + ":")).toList();
    }

    public static void main(String[] args) {
        appTenant = "acme";
        System.out.println("SET       acme   -> " + visibleRows());
        System.out.println("COMMIT, в пул вернулось app.tenant_id = " + appTenant);

        System.out.println("globex взял то же соединение -> " + visibleRows());

        appTenant = "globex";
        System.out.println("SET LOCAL globex -> " + visibleRows());
        appTenant = null;
        System.out.println("COMMIT, в пул вернулось app.tenant_id = " + appTenant);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Второй запрос — та самая утечка: globex ничего не выставлял, а строки получил чужие. После SET LOCAL переменная исчезает на COMMIT, и соединение уходит в пул пустым.

Тенанты и пул соединений

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

SET LOCAL app.tenant_id живёт внутри транзакции, поэтому с PgBouncer в режиме transaction он работает корректно: соединение возвращается в пул после коммита, а параметр к этому моменту уже сброшен. А вот SET без LOCAL (на сессию) в этом режиме — прямая утечка между клиентами: соединение с чужим tenant_id уедет следующему. В режиме statement не работает и SET LOCAL, потому что транзакции как единицы там нет. Вывод простой: только SET LOCAL, только в явной транзакции, и режим пула — transaction.

База на клиента упирается в другое: соединения не делятся. Сто клиентов по пулу в десять соединений — это тысяча серверных процессов, а max_connections на обычном сервере в разы меньше. Поэтому такой вариант либо ограничивают десятками крупных клиентов, либо ставят PgBouncer с маленьким пулом на каждую базу, либо держат ленивые пулы, которые открывают соединения только для активных клиентов и закрывают простаивающие. Схема на клиента этой проблемы не имеет — там соединение одно на всех, меняется только search_path.

Глубже: 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.

Как именно применяют миграцию к сотне схем — вопрос, который решают до того, как схем станет сто.

Механика простая: цикл по списку схем, для каждой — SET search_path TO <схема> и накат. Liquibase умеет это через --liquibase-schema-name и --default-schema-name, Flyway — через flyway.schemas и отдельный запуск на схему; у обоих таблица истории миграций своя в каждой схеме, и это правильно: схемы могут оказаться на разных версиях.

Главный вопрос — что делать, когда на восемьдесят седьмой из трёхсот схем миграция упала. Ответ состоит из трёх частей, и его надо иметь заранее. Первое: кластер теперь частично мигрирован, и это штатное состояние, а не авария — код обязан работать с обеими версиями схемы (та же совместимость N-1, что и в обычных миграциях). Второе: прогон должен быть перезапускаемым, то есть после починки повторный запуск пропускает уже обновлённые схемы и доделывает остальные — именно поэтому историю держат в каждой схеме. Третье: нужен отчёт о том, какие схемы на какой версии, иначе через неделю никто не сможет ответить на этот вопрос:

SELECT table_schema, max(installed_rank) AS last_migration
FROM information_schema.tables t
JOIN flyway_schema_history h ON true
GROUP BY table_schema;

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

Глубже: 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расширенное

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

Мгновенная она, правда, только для себя самой. Заодно команда берёт ACCESS EXCLUSIVE на родительскую таблицу — то есть на время удаления встаёт поперёк всех запросов к ней, включая чужих тенантов. Это та же очередь, про которую отдельно предупреждает статья про миграции: делать такое надо с lock_timeout и в спокойный час, а не «оно же мгновенно».

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

Коротко

  • Row-per-tenant: все в одних таблицах, tenant_id в каждой строке. Простой, масштабируется до тысяч. Каждый запрос — WHERE tenant_id = ?.
  • RLS — дополнительный барьер на уровне базы: даже если приложение забыло условие, PostgreSQL не отдаст чужие данные.
  • SET LOCAL app.tenant_id — обязательно LOCAL, иначе переменная «протечёт» через пул соединений к следующему пользователю.
  • Schema-per-tenant: отдельная схема на клиента — больше изоляции, сложнее миграции, не масштабируется на тысячи схем.
  • DB-per-tenant: полная изоляция для единиц крупных клиентов с отдельными SLA. Дорого в сопровождении.
  • RLS обходят суперпользователь и владелец таблицы — на владельца политика начинает действовать только после FORCE ROW LEVEL SECURITY.
  • tenant_id берут из подписанного токена, а не из поддомена или заголовка, и передают через контекст запроса, а не параметром метода.
  • В политике безопасности строк параметр оборачивают в (SELECT current_setting(…)), иначе планировщик оценивает условие вслепую; для админки и отчётов заводят отдельную роль с BYPASSRLS.
  • SET LOCAL совместим только с режимом пула transaction; база на клиента упирается в max_connections, а схема на клиента — в окно миграции и частично мигрированный кластер.
  • Удаление клиента — это массовый DELETE порциями (или отцепление части при разбиении по tenant_id), а переезд в отдельную базу — двойная запись, перелив, сверка и флаг маршрутизации.

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