Multi-tenant — продукт, обслуживающий несколько изолированных клиентов (тенантов) на одной инфраструктуре. Три классических паттерна: row-per-tenant, schema-per-tenant, db-per-tenant. Каждый — компромисс между изоляцией, миграциями, нагрузкой.
Эта статья — как выбрать. Правила пронумерованы кодами PG-MT-NNN.
1. Три паттерна — обзор
| Row-per-tenant | Schema-per-tenant | DB-per-tenant | |
|---|---|---|---|
| Где живут данные | в общих таблицах с tenant_id | в отдельных схемах PG | в отдельных БД |
| Изоляция данных | логическая (RLS опционально) | физическая в рамках одного кластера | физическая, отдельные БД |
| Миграции | один раз для всех | по тенантам (или скриптом для всех) | по тенантам |
| Backup per-tenant | сложно (нужен фильтр) | средне (pg_dump --schema=...) | просто |
| Производительность | хорошая (один пул, один cache) | средняя (много схем) | низкая (overhead на БД) |
| Шардинг тенантов | трудно | можно (тенанты по разным кластерам) | легко |
| Custom-логика на тенанта | нет | через сторонние таблицы в схеме | свобода |
PG-MT-001 — Default выбор — row-per-tenant с tenant_id колонкой
Просто, эффективно, масштабируемо до тысяч тенантов.
PG-MT-002 — Schema-per-tenant — для энтерпрайз-клиентов с регуляторными требованиями к изоляции
(HIPAA, banking) или с custom-логикой на каждого тенанта.
PG-MT-003 — DB-per-tenant — только когда тенанты — это полноценные клиенты с собственным контрактом (≤ десятки)
, и нужна полная изоляция (отдельные креды, backup-стратегия, иногда отдельный хост).
2. Row-per-tenant — детали
PG-MT-010 — Каждая бизнес-таблица имеет колонку tenant_id. PK — (tenant_id, id) или просто id с FK на tenant
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);
PG-MT-011 — Каждый запрос обязан включать WHERE tenant_id = ?
Иначе утечка данных между тенантами — критическая security-уязвимость.
PG-MT-012 — Для гарантии — Row-Level Security (RLS, см. §3) или фильтр в коде
В Spring/jOOQ — interceptor или явный helper:
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();
}
}
PG-MT-013 — Композитные PK с tenant_id первой колонкой
— все индексы автоматически partition'ятся по тенанту, partition pruning работает на бизнес-уровне.
3. Row-Level Security (RLS)
PG-MT-020 — RLS — серверная защита: PG сам добавляет фильтр в каждый запрос
ALTER TABLE order_doc ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON order_doc
USING (tenant_id = current_setting('app.tenant_id')::bigint);
В коде перед каждым запросом:
jdbc.execute("SET LOCAL app.tenant_id = ?", tenantId);
// далее любые SELECT/UPDATE автоматически отфильтрованы PG
PG-MT-021 — RLS — defense in depth
Даже если разработчик забыл WHERE tenant_id, PG не отдаст чужие данные.
PG-MT-022 — RLS имеет небольшой overhead
(PG проверяет policy на каждой строке), но обычно незаметный. Включай для multi-tenant с любой security-чувствительностью.
PG-MT-023 — SET LOCAL обязателен
— без LOCAL GUC живёт всю сессию, тенант «протечёт» при возврате connection в pool.
PG-MT-024 — RLS не работает для superuser
Для backup/admin — отдельный role без RLS bypass.
4. Schema-per-tenant — детали
PG-MT-030 — Каждый тенант — своя PG schema с одинаковой структурой
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
-- одинаковая DDL в каждой схеме
CREATE TABLE tenant_acme.order_doc (...);
CREATE TABLE tenant_globex.order_doc (...);
-- запрос с явной схемой:
SELECT * FROM tenant_acme.order_doc;
-- или через search_path:
SET search_path = tenant_acme, public;
SELECT * FROM order_doc;
PG-MT-031 — search_path обычно ставится из тенант-контекста:
jdbc.execute("SET LOCAL search_path = " + tenantSchema + ", public");
PG-MT-032 — Миграция — на каждую схему отдельно
Liquibase / Flyway имеют поддержку schema iteration. На 100 схемах миграция будет N×длиннее.
PG-MT-033 — Connection pool — общий, но каждое соединение SET search_path
перед использованием. Реально работает, но требует дисциплины.
PG-MT-034 — Не масштабируется до тысяч тенантов
На 1000+ схем pg_class распухает, autovacuum не справляется, cache misses растут.
5. DB-per-tenant — детали
PG-MT-040 — Каждый тенант — отдельная БД
Можно на одном кластере PG, можно на разных хостах.
// routing DataSource по тенанту
@Component
class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.current();
}
}
PG-MT-041 — Подходит для:
≤ десятков энтерпрайз-клиентов, которые платят за отдельные SLA / compliance / backup.
PG-MT-042 — Не подходит для:
SaaS со свободной регистрацией, тысячи мелких тенантов.
PG-MT-043 — DB-per-tenant самый дорогой по операциям
— каждый rollout миграции, каждый monitoring setup, каждый backup-job на тенанта.
6. Гибрид — самое частое в реальности
PG-MT-050 — Большинство SaaS используют гибрид:
- Маленькие/free тенанты — row-per-tenant в общей БД.
- Платящие enterprise — отдельная БД (или отдельный кластер).
Решение принимается на бизнес-плане, не на технике.
7. Партиционирование по тенанту
PG-MT-060 — Партиционирование по tenant_id (LIST или HASH) — спорный паттерн
Плюсы:
- Физическая локальность данных тенанта.
DROP PARTITIONмгновенно удаляет всех данных тенанта.
Минусы:
- 1000 тенантов = 1000 партиций → планировщик тормозит, autovacuum утомлён.
- При неравномерной нагрузке (один тенант = 80% данных) — bias.
PG-MT-061 — Партиционируй по тенанту только если:
- ≤ десятки тенантов.
- У каждого крупного объёма данных (GB+).
- GDPR-требование «удалить всё за тенанта одной операцией».
Для тысяч мелких — обычная row-per-tenant без партиций.
См. Партиционирование.
8. Антипаттерны
PG-MT-080 — Row-per-tenant без WHERE tenant_id хотя бы в одном запросе
— security-уязвимость, утечка данных.
PG-MT-081 — Row-per-tenant без RLS
при том, что защита только в коде — легко забыть в новом методе.
PG-MT-082 — Schema-per-tenant на тысячах тенантов
— pg_class распухает, кластер тормозит.
PG-MT-083 — DB-per-tenant для всех тенантов
SaaS — операционный кошмар.
PG-MT-084 — Партиционирование по tenant_id на сотнях/тысячах тенантов
— больше партиций, чем планировщик любит.
PG-MT-085 — SET search_path без LOCAL
в transaction-pool mode — search_path остаётся в сессии, следующий тенант видит чужую схему.
Чек-лист на проектирование
- [ ] Выбран паттерн под бизнес-модель: row-per-tenant (default), schema-per-tenant (compliance), db-per-tenant (enterprise).
- [ ]
tenant_idколонка во всех бизнес-таблицах (для row-per-tenant). - [ ]
tenant_idпервой колонкой в композитных индексах. - [ ]
WHERE tenant_id = ?в каждом запросе или через RLS. - [ ] RLS включён для defense in depth.
- [ ]
SET LOCAL(неSET) для tenant-context в transaction-pooled connections. - [ ] Миграции учитывают multi-schema (для schema-per-tenant).
- [ ] Backup-стратегия per-tenant определена (если требуется).
Связанные
- Партиционирование —
PARTITION BY LIST (tenant_id)и почему обычно не стоит. - Connection pool — routing DataSource для DB-per-tenant.
- Naming — префиксы схем в schema-per-tenant.