Multi-tenancy — архитектура, где одно приложение обслуживает несколько изолированных клиентов (тенантов). В PostgreSQL есть три основных паттерна изоляции.
Паттерн 1: Отдельная база данных на тенанта
tenant_acme → database: db_acme
tenant_globex → database: db_globex
tenant_initech → database: db_initech
# Django: динамически выбирать БД
def get_db_for_tenant(tenant_slug):
return {
'ENGINE': 'django.db.backends.postgresql',
'NAME': f'db_{tenant_slug}',
'HOST': 'localhost',
...
}
# Или через django-tenants / django-db-multitenant
Плюсы:
- Максимальная изоляция данных
- Независимые миграции и бэкапы
- Отдельный
pg_dumpна клиента
Минусы:
- Тысячи БД = тысячи подключений к PostgreSQL
- Сложнее мониторинг и операционное обслуживание
- Кросс-тенантная аналитика требует
UNION ALLпо всем БД или ETL
Когда: строгие требования изоляции (медицина, финансы, SLA), небольшое число крупных клиентов (< 100).
Паттерн 2: Отдельная схема на тенанта
-- Создать схему для тенанта
CREATE SCHEMA tenant_acme;
-- Создать таблицы в схеме
CREATE TABLE tenant_acme.users (
id SERIAL PRIMARY KEY,
email TEXT,
...
);
CREATE TABLE tenant_acme.orders (...);
-- Установить search_path для соединения тенанта
SET search_path = tenant_acme;
-- Теперь SELECT * FROM users → читает из tenant_acme.users
# Django: middleware устанавливает схему при каждом запросе
class TenantMiddleware:
def process_request(self, request):
tenant = get_tenant_from_request(request)
with connection.cursor() as cursor:
cursor.execute(f"SET search_path = tenant_{tenant.slug}")
Плюсы:
- Одна БД = один пул соединений
- Простая изоляция через
search_path - Каждый тенант может иметь свою схему таблиц (гибкость)
Минусы:
- Ограничение PostgreSQL: ~10 000 схем на БД (практически)
- Миграции нужно применять к каждой схеме отдельно
- Кросс-тенантные запросы через
schema.table
Когда: SaaS с сотнями тенантов, каждый тенант может иметь кастомные поля.
Паттерн 3: Общие таблицы с tenant_id (Shared Database)
-- Все тенанты в одних таблицах
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
name TEXT,
...
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
user_id BIGINT NOT NULL,
amount NUMERIC,
...
);
-- Составной уникальный индекс: email уникален внутри тенанта
CREATE UNIQUE INDEX ON users (tenant_id, email);
-- Индекс для быстрой фильтрации
CREATE INDEX ON orders (tenant_id, created_at DESC);
CREATE INDEX ON users (tenant_id);
Плюсы:
- Простейшая операционная модель
- Кросс-тенантная аналитика тривиальна
- Один набор миграций
Минусы:
- Утечка данных при ошибке в WHERE (
tenant_idзабыли) - При миллионах строк нужно партиционирование по
tenant_id
Когда: стартапы, B2C приложения с тысячами небольших клиентов.
Row Level Security (RLS) — безопасность на уровне строк
RLS — встроенный механизм PostgreSQL для автоматической фильтрации строк:
-- Включить RLS на таблице
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Политика: пользователь видит только строки своего тенанта
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant_id')::INTEGER);
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::INTEGER);
-- Суперпользователь обходит RLS (для миграций и аналитики)
-- Обычный пользователь приложения подчиняется политике
# В приложении: установить tenant_id перед каждым запросом
def set_tenant(cursor, tenant_id):
cursor.execute("SET app.current_tenant_id = %s", [tenant_id])
# Django middleware
class RLSMiddleware:
def __call__(self, request):
tenant_id = request.tenant.id
with connection.cursor() as cursor:
cursor.execute("SET LOCAL app.current_tenant_id = %s", [tenant_id])
return self.get_response(request)
-- Проверить: пользователь видит только свои данные
SET app.current_tenant_id = 42;
SELECT * FROM users; -- только tenant_id = 42
SET app.current_tenant_id = 99;
SELECT * FROM users; -- только tenant_id = 99
-- Сбросить (вернуть доступ ко всему для admin)
RESET app.current_tenant_id;
Производительность: партиционирование по tenant_id
-- Партиционированная таблица (для больших тенантов)
CREATE TABLE orders (
id BIGSERIAL,
tenant_id INTEGER NOT NULL,
user_id BIGINT NOT NULL,
amount NUMERIC,
created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY HASH (tenant_id);
-- 8 партиций (хорошо для 100-1000 тенантов)
CREATE TABLE orders_p0 PARTITION OF orders FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE orders_p1 PARTITION OF orders FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- ... и т.д.
-- Запросы с tenant_id используют partition pruning
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- → читает только нужную партицию
Сравнение паттернов
| Отдельная БД | Отдельная схема | Shared table | |
|---|---|---|---|
| Изоляция | Максимальная | Высокая | Средняя (RLS) |
| Масштаб | < 100 тенантов | < 10 000 | Миллионы |
| Кросс-тенантный запрос | Сложно | Через schema.table | Тривиально |
| Операционная сложность | Высокая | Средняя | Низкая |
| Кастомные схемы | ✅ | ✅ | ❌ |
| Отдельный бэкап | ✅ | Можно | ❌ |
Гибридный подход
Многие SaaS используют гибрид:
Enterprise клиенты → отдельная схема (изоляция, кастомизация)
SMB клиенты → shared tables с RLS (эффективность)
-- Определить тип тенанта
SELECT isolation_type FROM tenants WHERE id = 42;
-- 'schema' или 'shared'
Практический чеклист
- Всегда добавляйте
tenant_idв индексы:(tenant_id, ...)— не(...)отдельно - Добавьте
tenant_idкак первый столбец составных индексов - Используйте RLS как защитный слой даже при shared approach
- Тестируйте: проверяйте что запросы не возвращают данные чужих тенантов
- Логируйте попытки доступа к данным других тенантов
Multi-tenancy — фундаментальное архитектурное решение. Выбирайте паттерн исходя из числа тенантов, требований изоляции и операционных возможностей команды.