Представьте: вы делаете сервис управления задачами, которым пользуются сразу несколько компаний. Данные компании А не должны видеть сотрудники компании Б. Как хранить всё это в одной PostgreSQL?
Это задача multi-tenancy — работа с несколькими «жильцами» (tenants) на одной инфраструктуре. Есть три классических подхода, и выбор между ними — это всегда компромисс между простотой, изоляцией и стоимостью.
Все тенанты лежат в одной таблице, и кто чей — говорит tenant_id. Соединение берётся из пула, транзакция объявляет своего тенанта через SET LOCAL, и политика RLS отдаёт только его строки. На COMMIT переменная исчезает вместе с транзакцией, и то же соединение достаётся следующему тенанту уже чистым.
Три подхода и чем они отличаются
Подходов три, и различаются они тем, где проходит граница между клиентами: в строке, когда таблицы общие и у каждой строки есть tenant_id; в схеме, когда у каждого клиента своя схема в одном кластере; или в базе, когда на клиента заводят отдельную базу. Ниже каждый разобран отдельно, а свод по миграциям, копиям и масштабу в конце статьи.
Самый распространённый вариант на практике — гибрид: маленькие и бесплатные клиенты хранятся в общей базе, крупные enterprise-клиенты получают отдельную базу.
Свод: чем подходы отличаются
| Row-per-tenant | Schema-per-tenant | DB-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), а переезд в отдельную базу — двойная запись, перелив, сверка и флаг маршрутизации.
Что почитать дальше
- Партиционирование — когда и как делить таблицы на секции.
- Составные индексы — почему
tenant_idставят первой колонкой. - Connection pool — пулы соединений и маршрутизация.
- Резервное копирование — чем отличается снятие копии схемы от копии всей базы.