
Один продукт — десятки и сотни клиентов. Каждый хочет, чтобы данные были изолированы, а скорость не падала, когда сосед запустил массовую загрузку. Держать отдельную инфраструктуру под каждого — дорого и медленно масштабируется. Мультитенантность позволяет:
Ключ: выбрать модель изоляции и поддерживать её во всех слоях — схемы БД, индексы, кэш, поиск, фоновые задачи, мониторинг и бэкапы.
Есть четыре базовые модели. Чем выше изоляция — тем дороже сопровождение.
Одна база, одни таблицы: колонка tenant_id в каждой строке. Минимальные затраты, хороший кеш и шаринг ресурсов. Риски: сложнее резервное восстановление «по арендатору», нужна строгая дисциплина в схемах и индексах.
Одна база, разные схемы (schema-per-tenant): таблицы дублируются в схемах. Проще бэкап/рестор для конкретного арендатора, но усложняются миграции (нужно обновлять много схем), растут системные накладные.
Отдельные базы (database-per-tenant) в одном кластере. Хорошая изоляция, отдельные бэкапы и ресурсы. Но число соединений и накладные на обслуживание быстро растут.
Отдельные кластеры. Максимальная изоляция, понятный биллинг. Дорого, сложнее централизовать обновления, хуже общий кэш.
На старте чаще всего берут модель №1 с жёсткой дисциплиной: tenant_id в ключах, внешних ключах и индексах, плюс RLS для страховки. По мере роста крупных клиентов — гибрид: массовые — в общей базе, «киты» — в отдельной базе или кластере.
Типовые ошибки — отсутствие tenant_id во внешних ключах и неэффективные индексы, из-за чего база делает лишние сканы.
Рекомендации:
Пример DDL:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
name text NOT NULL
);
-- Составные ключи по (tenant_id, id)
CREATE TABLE users (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
id uuid NOT NULL DEFAULT uuid_generate_v4(),
email text NOT NULL,
role text NOT NULL,
PRIMARY KEY (tenant_id, id),
UNIQUE (tenant_id, email)
);
CREATE TABLE orders (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
id bigint GENERATED BY DEFAULT AS IDENTITY,
user_tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
amount_cents bigint NOT NULL CHECK (amount_cents >= 0),
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id),
CONSTRAINT fk_orders_user
FOREIGN KEY (tenant_id, user_tenant_id, user_id)
REFERENCES users (tenant_id, tenant_id, id)
DEFERRABLE INITIALLY IMMEDIATE
);
-- Важно: индексы по tenant_id во всех горячих запросах
CREATE INDEX idx_orders_tenant_created
ON orders(tenant_id, created_at DESC);
CREATE INDEX idx_orders_tenant_status_created
ON orders(tenant_id, status, created_at DESC);
Обратите внимание на внешний ключ: он включает tenant_id, поэтому невозможно связать заказ одного арендатора с пользователем другого.
Row Level Security (RLS) — последний рубеж обороны: даже при ошибке в коде запрос не вернёт чужие строки. Но RLS требует аккуратной настройки.
Идея: при открытии транзакции выставляем контекст текущего арендатора через параметр сессии, а в политиках RLS сравниваем tenant_id со значением этого параметра.
-- Системный параметр, где будем хранить текущего арендатора
-- (создавать его не надо, достаточно SET app.current_tenant)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Запрещаем доступ по умолчанию (если нет подходящей политики)
ALTER TABLE users FORCE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- Политики «только свои строки»
CREATE POLICY p_users_tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant', true)::uuid);
CREATE POLICY p_orders_tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant', true)::uuid);
-- Разрешить вставку только «внутрь своего арендатора»
CREATE POLICY p_users_insert ON users FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::uuid);
CREATE POLICY p_orders_insert ON orders FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::uuid);
Теперь любая команда SELECT/INSERT/UPDATE/DELETE без нужного SET app.current_tenant = ... не увидит ни одной строки и не сможет записать «чужого» арендатора.
Проверьте план запросов — Postgres должен рано применять фильтр по tenant_id и использовать составные индексы.
EXPLAIN ANALYZE
SELECT id, status FROM orders
WHERE created_at >= now() - interval '7 days'
ORDER BY created_at DESC
LIMIT 50;
Если в плане сканируется весь индекс без отсечения по tenant_id — добавьте соответствующий индекс с префиксом tenant_id.
Если вы используете pgbouncer с режимом пуллинга на уровне транзакций, сессионные параметры не сохраняются между транзакциями. Решения:
SET LOCAL app.current_tenant = ... в её начале — параметр будет действовать внутри транзакции.Пример жёсткого тайм‑аута на сеанс:
SET LOCAL statement_timeout = '2s';
Кэш:
tenant:{id}:orders:page:1.Пример с Redis:
// кэш списка заказов по арендатору
key := fmt.Sprintf("tenant:%s:orders:list:%s", tenantID, pageKey)
if val, err := rdb.Get(ctx, key).Result(); err == nil {
return decode(val), nil
}
orders := fetchFromDB(ctx, tenantID, page)
_ = rdb.Set(ctx, key, encode(orders), 60*time.Second).Err()
Полнотекстовый поиск:
tsv с тсвектором и составный индекс (tenant_id, tsv).tenant_id и маршрутизацией по нему, либо отдельный индекс на арендатора. До ~тысяч арендаторов с умеренным объёмом проще один индекс с жёстким фильтром.Пример запроса в Elasticsearch:
{
"query": {
"bool": {
"filter": [ { "term": { "tenant_id": "0a3f..." } } ],
"must": [ { "query_string": { "query": "оплата AND статус:успешно" } } ]
}
},
"routing": "0a3f..."
}
--where для каждого tenant_id, плюс инкрементальные изменения по журналу.Пример частичного дампа (для одной таблицы):
pg_dump "$DATABASE_URL" \
-t public.orders \
--column-inserts \
--data-only \
--where="tenant_id = '0a3f5d3d-...-...'" \
> orders_tenant_0a3f.sql
Для полного арендатора придётся перечислить все таблицы и соблюсти порядок внешних ключей (или использовать DISABLE TRIGGER ALL на время импорта в изолированную временную базу, затем точечно переносить данные).
tenant_id и роли.http_requests_total{tenant="...",handler="/orders"} — это позволит быстро увидеть «кого заносит». Старайтесь ограничивать кардинальность: используйте хеш/сжатое представление tenant_id или агрегируйте.Типичный путь: общая база → отдельная база для «китов» → при необходимости отдельный кластер.
Шаги при выносе крупного арендатора:
Ниже — короткий пример на Go с database/sql и pgx как драйвером. Он:
SET LOCAL app.current_tenant,DDL у нас уже есть выше. Добавим триггер, чтобы не забыть tenant_id при вставке:
CREATE OR REPLACE FUNCTION enforce_tenant_on_insert()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.tenant_id IS NULL THEN
NEW.tenant_id := current_setting('app.current_tenant', true)::uuid;
END IF;
IF NEW.tenant_id <> current_setting('app.current_tenant', true)::uuid THEN
RAISE EXCEPTION 'tenant_id mismatch';
END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER trg_orders_enforce_tenant
BEFORE INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION enforce_tenant_on_insert();
Код Go:
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
type Order struct {
ID int64
Status string
CreatedAt time.Time
}
func withTenantTx(ctx context.Context, db *sql.DB, tenantID string, fn func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil { return err }
// Важно: SET LOCAL внутри транзакции, чтобы не зависеть от режима пуллинга
if _, err := tx.ExecContext(ctx, "SET LOCAL app.current_tenant = $1", tenantID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("set tenant: %w", err)
}
if _, err := tx.ExecContext(ctx, "SET LOCAL statement_timeout = '2s'"); err != nil {
_ = tx.Rollback()
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
func listRecentOrders(ctx context.Context, tx *sql.Tx, limit int) ([]Order, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, status, created_at
FROM orders
WHERE created_at >= now() - interval '7 days'
ORDER BY created_at DESC
LIMIT $1
`, limit)
if err != nil { return nil, err }
defer rows.Close()
var out []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.Status, &o.CreatedAt); err != nil { return nil, err }
out = append(out, o)
}
return out, rows.Err()
}
func createOrder(ctx context.Context, tx *sql.Tx, userTenantID, userID string, amount int64) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `
INSERT INTO orders (user_tenant_id, user_id, amount_cents, status)
VALUES ($1::uuid, $2::uuid, $3, 'new')
RETURNING id
`, userTenantID, userID, amount).Scan(&id)
return id, err
}
func main() {
dsn := "postgres://app:app@localhost:5432/appdb?sslmode=disable"
db, err := sql.Open("pgx", dsn)
if err != nil { log.Fatal(err) }
defer db.Close()
ctx := context.Background()
tenant := "0a3f5d3d-0000-4000-8000-000000000001"
// Создать заказ и прочитать последние
err = withTenantTx(ctx, db, tenant, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, "SET LOCAL application_name = 'orders-api'"); err != nil { return err }
newID, err := createOrder(ctx, tx, tenant, "0a3f5d3d-0000-4000-8000-0000000000ab", 19900)
if err != nil { return err }
log.Println("created order", newID)
orders, err := listRecentOrders(ctx, tx, 10)
if err != nil { return err }
for _, o := range orders { log.Printf("order id=%d status=%s", o.ID, o.Status) }
return nil
})
if err != nil { log.Fatal(err) }
}
Проверьте, что при отсутствии SET LOCAL app.current_tenant список заказов пуст, а при попытке вставить заказ с чужим tenant_id триггер выдаст ошибку.
Правильно выстроенная мультитенантность даёт бизнесу три вещи: предсказуемые издержки, быструю масштабируемость и спокойный сон службы безопасности. Начните с дисциплины в схеме и индексации, добавьте RLS как страховку, разграничьте ресурсы на уровне фоновых задач и пулов, и заранее продумайте бэкапы «по арендатору» и путь миграции крупных клиентов. Это окупается уже на десятке платящих компаний.