Multi-tenant — продукт, обслуживающий несколько изолированных клиентов (тенантов) на одной инфраструктуре. Три классических паттерна: row-per-tenant, schema-per-tenant, db-per-tenant. Каждый — компромисс между изоляцией, миграциями, нагрузкой.

Эта статья — как выбрать. Правила пронумерованы кодами PG-MT-NNN.

1. Три паттерна — обзор

Row-per-tenantSchema-per-tenantDB-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-023SET 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-031search_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-085SET 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.