Многие команды начинают SaaS с монолита, который «потом разделим на тенанты», и обнаруживают, что ретрофит мультитенантности обходится дороже, чем построить с нуля. В этой статье — архитектурные решения, которые стоит принять заранее, с практическими примерами реализации.
Три модели изоляции данных
Сравнение подходов
| Подход | Изоляция | Стоимость инфры | Сложность | Подходит для | |---|---|---|---|---| | DB per tenant | Максимальная | Высокая | Высокая | Enterprise, регуляторные требования | | Schema per tenant | Высокая | Средняя | Средняя | Mid-market, умеренное число тенантов | | Row-level (RLS) | Умеренная | Низкая | Низкая | SMB, высокое число тенантов, стартап |
DB per tenant
Каждый клиент — отдельная база данных. Полная физическая изоляция, простые бэкапы и compliance.
# Django: динамический роутер баз данных
class TenantDatabaseRouter:
def db_for_read(self, model, **hints):
tenant = get_current_tenant()
return f'tenant_{tenant.slug}' if tenant else 'default'
def db_for_write(self, model, **hints):
return self.db_for_read(model)
def allow_migrate(self, db, app_label, model_name=None, **hints):
return db == 'default' or db.startswith('tenant_')
# Создание новой базы при онбординге
def provision_tenant_database(tenant):
db_name = f'tenant_{tenant.slug}'
with connection.cursor() as cursor:
cursor.execute(f'CREATE DATABASE {db_name}')
# Добавляем в DATABASES настройки Django
settings.DATABASES[db_name] = {
**settings.DATABASES['template_tenant'],
'NAME': db_name,
}
# Применяем миграции
call_command('migrate', '--database', db_name)
Проблема: при 10,000+ тенантах управление тысячами баз данных становится операционным кошмаром. Используйте этот подход только для enterprise-сегмента (десятки/сотни клиентов).
Schema per tenant (PostgreSQL)
Один кластер PostgreSQL, отдельная схема для каждого тенанта. Хороший баланс изоляции и управляемости.
-- Создание схемы при онбординге
CREATE SCHEMA tenant_acme;
SET search_path TO tenant_acme, public;
-- Таблицы создаются в схеме тенанта
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- В коде: устанавливаем search_path перед каждым запросом
// Node.js с pg: middleware для установки search_path
async function tenantMiddleware(req, res, next) {
const tenant = await getTenantFromRequest(req);
req.tenant = tenant;
req.db = await pool.connect();
await req.db.query(`SET search_path TO tenant_${tenant.slug}, public`);
res.on('finish', () => req.db.release());
next();
}
Row-Level Security (RLS)
Все тенанты в одних таблицах, PostgreSQL RLS автоматически фильтрует строки.
-- Включаем RLS на таблице
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Политика: пользователь видит только проекты своего тенанта
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Таблица users
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.tenant_id')::uuid);
// Перед каждым запросом устанавливаем tenant_id
async function withTenant(tenantId, queryFn) {
const client = await pool.connect();
try {
await client.query(`SET app.tenant_id = '${tenantId}'`);
return await queryFn(client);
} finally {
client.release();
}
}
// Использование
const projects = await withTenant(req.tenant.id, async (db) => {
return db.query('SELECT * FROM projects ORDER BY created_at DESC');
});
Предостережение: RLS не защищает от SQL-инъекций через SET параметры. Всегда валидируйте tenant_id как UUID перед вставкой в SET.
Auth и RBAC (Role-Based Access Control)
В мультитенантном SaaS роли всегда контекстуальны: пользователь может быть Admin в тенанте A и Member в тенанте B.
-- Схема данных для мультитенантного RBAC
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'starter',
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE tenant_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (tenant_id, user_id)
);
CREATE TABLE permissions (
role TEXT NOT NULL,
resource TEXT NOT NULL,
action TEXT NOT NULL,
PRIMARY KEY (role, resource, action)
);
-- Заполнение матрицы разрешений
INSERT INTO permissions VALUES
('owner', 'billing', 'manage'),
('owner', 'members', 'manage'),
('admin', 'members', 'manage'),
('admin', 'projects', 'manage'),
('member', 'projects', 'write'),
('viewer', 'projects', 'read');
// Middleware проверки разрешений
async function requirePermission(resource, action) {
return async (req, res, next) => {
const membership = await TenantMember.findOne({
tenantId: req.tenant.id,
userId: req.user.id,
});
if (!membership) return res.status(403).json({ error: 'not_a_member' });
const hasPermission = await Permission.findOne({
role: membership.role,
resource,
action,
});
if (!hasPermission) return res.status(403).json({ error: 'insufficient_permissions' });
req.membership = membership;
next();
};
}
// Использование
router.delete('/projects/:id',
requirePermission('projects', 'manage'),
deleteProjectHandler
);
Биллинг через Stripe
Stripe — стандарт для SaaS биллинга. Ключевые объекты: Customer, Subscription, Price, Invoice.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// Создание Stripe Customer при регистрации тенанта
async function createTenantBilling(tenant, ownerEmail) {
const customer = await stripe.customers.create({
email: ownerEmail,
name: tenant.name,
metadata: { tenant_id: tenant.id },
});
await Tenant.update(tenant.id, { stripeCustomerId: customer.id });
return customer;
}
// Оформление подписки
async function subscribeTenant(tenantId, priceId, paymentMethodId) {
const tenant = await Tenant.findById(tenantId);
await stripe.paymentMethods.attach(paymentMethodId, {
customer: tenant.stripeCustomerId,
});
const subscription = await stripe.subscriptions.create({
customer: tenant.stripeCustomerId,
items: [{ price: priceId }],
default_payment_method: paymentMethodId,
expand: ['latest_invoice.payment_intent'],
});
await Tenant.update(tenantId, {
plan: getPlanFromPrice(priceId),
stripeSubscriptionId: subscription.id,
subscriptionStatus: subscription.status,
});
return subscription;
}
// Webhook для обработки событий Stripe
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET
);
switch (event.type) {
case 'invoice.payment_succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailure(event.data.object);
break;
case 'customer.subscription.deleted':
await downgradeToFree(event.data.object);
break;
}
res.json({ received: true });
});
Feature Flags
Feature flags позволяют управлять доступностью функций по плану тарифа, тенанту или пользователю без деплоя.
// Простая реализация feature flags через Redis
class FeatureFlags {
constructor(redis) {
this.redis = redis;
this.cache = new Map();
this.CACHE_TTL = 60000; // 1 минута
}
async isEnabled(flagName, context) {
const { tenantId, userId, plan } = context;
const cacheKey = `${flagName}:${tenantId}`;
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.value;
// Проверяем в порядке специфичности
const checks = [
`flag:${flagName}:tenant:${tenantId}`, // переопределение для тенанта
`flag:${flagName}:plan:${plan}`, // по плану
`flag:${flagName}:global`, // глобальный флаг
];
for (const key of checks) {
const value = await this.redis.get(key);
if (value !== null) {
const result = value === '1';
this.cache.set(cacheKey, { value: result, expiresAt: Date.now() + this.CACHE_TTL });
return result;
}
}
return false; // по умолчанию выключено
}
}
// Использование в коде
const flags = new FeatureFlags(redis);
router.get('/analytics', async (req, res) => {
const hasAccess = await flags.isEnabled('advanced_analytics', {
tenantId: req.tenant.id,
plan: req.tenant.plan,
});
if (!hasAccess) {
return res.status(402).json({ error: 'upgrade_required', feature: 'advanced_analytics' });
}
// ...
});
Автоматизация онбординга
Онбординг нового тенанта — это транзакционный процесс с несколькими шагами. Важно делать его идемпотентным (повторный запуск не должен ломать уже готовые шаги):
async function provisionNewTenant(data) {
const steps = [
{ name: 'create_tenant', fn: createTenantRecord },
{ name: 'setup_database', fn: setupTenantDatabase },
{ name: 'create_owner', fn: createOwnerUser },
{ name: 'setup_billing', fn: createTenantBilling },
{ name: 'seed_default_data', fn: seedDefaultData },
{ name: 'send_welcome_email', fn: sendWelcomeEmail },
];
let progress = await OnboardingProgress.findOrCreate(data.email);
for (const step of steps) {
if (progress.completedSteps.includes(step.name)) {
console.log(`Skipping ${step.name} (already done)`);
continue;
}
try {
await step.fn(data, progress.tenantId);
await progress.markStepComplete(step.name);
} catch (err) {
await progress.markStepFailed(step.name, err.message);
throw err; // Можно поставить в очередь для retry
}
}
return progress.tenantId;
}
Тенант-осведомлённое кеширование
Кеш должен учитывать tenant_id, иначе данные одного тенанта окажутся у другого — критическая утечка данных.
class TenantAwareCache {
constructor(redis) {
this.redis = redis;
}
key(tenantId, key) {
return `t:${tenantId}:${key}`;
}
async get(tenantId, key) {
const value = await this.redis.get(this.key(tenantId, key));
return value ? JSON.parse(value) : null;
}
async set(tenantId, key, value, ttlSeconds = 300) {
await this.redis.setex(
this.key(tenantId, key),
ttlSeconds,
JSON.stringify(value)
);
}
// Инвалидация всего кеша тенанта
async invalidateTenant(tenantId) {
const keys = await this.redis.keys(`t:${tenantId}:*`);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
}
// Использование
const cache = new TenantAwareCache(redis);
async function getTenantProjects(tenantId) {
const cached = await cache.get(tenantId, 'projects:list');
if (cached) return cached;
const projects = await db.query(
'SELECT * FROM projects WHERE tenant_id = $1',
[tenantId]
);
await cache.set(tenantId, 'projects:list', projects.rows, 60);
return projects.rows;
}
Итог
Мультиарендная архитектура требует принятия ключевых решений в самом начале: модель изоляции данных, RBAC на уровне тенанта, биллинг с первого клиента, идемпотентный онбординг и изолированный кеш. Большинство этих паттернов несложно реализовать сразу, но болезненно добавлять post-factum. Для понимания работы с базой данных в контексте пространственных данных смотрите статью PostGIS: пространственные запросы.

