Опирается на правила:
PG-MT-001…PG-MT-085из PostgreSQL Style Guide → раздел Multi-tenancy.
Важно знать
- 3 паттерна: row-per-tenant (default), schema-per-tenant, db-per-tenant.
- Row-per-tenant — default, масштабируется до тысяч.
- Schema-per-tenant — enterprise с регуляторными (HIPAA, banking) или custom-логикой.
- DB-per-tenant — ≤ десятков энтерпрайз с собственным контрактом.
- Каждый запрос с
WHERE tenant_id = ?— иначе утечка данных.- Row-Level Security (RLS) — defense in depth, серверный фильтр.
SET LOCAL app.tenant_idобязателен — без LOCAL тенант протечёт в pool.pg_classраспухает на 1000+ схем → schema-per-tenant не масштабируется.- Гибрид — самое частое: free row-per-tenant + enterprise отдельная БД.
- Партиции по tenant_id — только для ≤ десятков крупных.
Multi-tenant — продукт на одной инфре. Три классических паттерна, каждый компромисс изоляции/миграций/нагрузки.
Три паттерна
PG-MT-001..003:
| Row-per-tenant | Schema-per-tenant | DB-per-tenant | |
|---|---|---|---|
| Данные | общие + tenant_id | отдельные схемы | отдельные БД |
| Изоляция | логическая (+RLS) | физическая в кластере | физическая |
| Миграции | один раз для всех | по тенантам | по тенантам |
| Backup per-tenant | сложно | средне (pg_dump --schema=) | просто |
| Производительность | хорошая | средняя | низкая (overhead БД) |
| Custom-логика на тенанта | нет | через сторонние таблицы | свобода |
Default — row-per-tenant
Просто, эффективно, масштабируемо до тысяч тенантов.
Schema-per-tenant
Для enterprise с регуляторными требованиями (HIPAA, banking) или custom-логикой.
DB-per-tenant
Только когда тенанты — полноценные клиенты с собственным контрактом (≤ десятки): отдельные креды, backup-стратегия, отдельный хост.
Row-per-tenant
PG-MT-010..013:
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 = ?. Иначе утечка данных = critical security vulnerability.
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();
}
}
Композитные PK с tenant_id первой колонкой — все индексы автоматически partition'ятся по тенанту.
Row-Level Security (RLS)
PG-MT-020..024: defense in depth.
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
Даже если разработчик забыл WHERE tenant_id — PG не отдаст чужие данные.
SET LOCAL обязателен
PG-MT-023: без LOCAL GUC живёт всю сессию.
-- ✗ — тенант протечёт при возврате connection в pool
SET app.tenant_id = 42;
-- ✓ — только в текущей транзакции
SET LOCAL app.tenant_id = 42;
RLS не работает для superuser
PG-MT-024: для backup/admin — отдельный role без RLS bypass.
Schema-per-tenant
PG-MT-030..034:
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
CREATE TABLE tenant_acme.order_doc (...);
CREATE TABLE tenant_globex.order_doc (...);
-- запрос с явной схемой:
SELECT * FROM tenant_acme.order_doc;
-- или через search_path:
SET LOCAL search_path = tenant_acme, public;
SELECT * FROM order_doc;
search_path из тенант-контекста:
jdbc.execute("SET LOCAL search_path = " + tenantSchema + ", public");
Миграция на каждую схему отдельно. Liquibase/Flyway имеют schema iteration. На 100 схемах миграция в N раз длиннее.
Connection pool — общий, но каждое соединение SET search_path перед использованием. Требует дисциплины.
PG-MT-034: не масштабируется до тысяч. На 1000+ схем pg_class распухает, autovacuum не справляется, cache misses растут.
DB-per-tenant
PG-MT-040..043:
@Component
class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.current();
}
}
Подходит для ≤ десятков энтерпрайз с отдельным SLA/compliance/backup.
Не подходит для SaaS со свободной регистрацией, тысяч мелких.
Самый дорогой по операциям: rollout миграции, monitoring, backup-job — на каждого тенанта.
Гибрид — самое частое
PG-MT-050:
- Маленькие/free — row-per-tenant в общей БД.
- Платящие enterprise — отдельная БД (или отдельный кластер).
Решение на бизнес-плане, не на технике.
Партиционирование по tenant_id
PG-MT-060..061: спорный.
Плюсы:
- Физическая локальность данных тенанта.
DROP PARTITIONмгновенно удаляет всех данных тенанта (GDPR).
Минусы:
- 1000 тенантов = 1000 партиций → планировщик тормозит.
- Неравномерная нагрузка (один тенант = 80% данных) — bias.
Только если:
- ≤ десятки тенантов.
- Каждый крупного объёма (GB+).
- GDPR «удалить всё за тенанта одной операцией».
Подробнее — Партиционирование.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Row-per-tenant без WHERE tenant_id | PG-MT-080 | проверка везде |
| Row-per-tenant без RLS | PG-MT-081 | defense in depth |
| Schema-per-tenant на тысячах | PG-MT-082 | row-per-tenant + RLS |
| DB-per-tenant для всех SaaS | PG-MT-083 | гибрид |
| Партиции по tenant_id на тысячах | PG-MT-084 | row-per-tenant |
SET search_path без LOCAL в transaction-pool | PG-MT-085 | SET LOCAL |
RLS без SET LOCAL для tenant_id | PG-MT-023 | LOCAL обязателен |
| Superuser в production-роли | PG-MT-024 | отдельный role без RLS bypass |
Куда дальше
- PG → Multi-tenancy — нормативные формулировки.
- Партиционирование — partition по tenant_id.
- Composite-индексы —
tenant_idпервой колонкой. - HikariCP — routing DataSource.
- Auth → ABAC — tenant_id в JWT.