Опирается на правила: PG-MT-001PG-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-tenantSchema-per-tenantDB-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_idPG-MT-080проверка везде
Row-per-tenant без RLSPG-MT-081defense in depth
Schema-per-tenant на тысячахPG-MT-082row-per-tenant + RLS
DB-per-tenant для всех SaaSPG-MT-083гибрид
Партиции по tenant_id на тысячахPG-MT-084row-per-tenant
SET search_path без LOCAL в transaction-poolPG-MT-085SET LOCAL
RLS без SET LOCAL для tenant_idPG-MT-023LOCAL обязателен
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.