SaaS мультитенантность: отдельная БД на тенанта
Максимальная изоляция: каждый клиент получает собственную базу данных. Данные физически разделены — утечка между тенантами невозможна. Сложнее в управлении, дороже в инфраструктуре, обязательно для некоторых compliance требований.
Когда выбирать
Database-per-tenant подходит если:
- Compliance требует физической изоляции (HIPAA, финансовые данные)
- Клиенты требуют возможность экспорта/удаления своих данных
- Разные тенанты имеют разные схемы или версии
- Нужны независимые бэкапы на уровне БД
Не подходит если:
- Тысячи мелких тенантов (overhead на подключения)
- Нужна аналитика поперёк тенантов
- Ограниченный бюджет
Управление подключениями
// lib/db/tenant-manager.ts
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
// Пул подключений к разным БД
const clientPool = new Map<string, PrismaClient>();
export async function getTenantDb(tenantId: string): Promise<PrismaClient> {
if (clientPool.has(tenantId)) {
return clientPool.get(tenantId)!;
}
// Получаем connection string для тенанта
const tenant = await masterDb.tenant.findUniqueOrThrow({
where: { id: tenantId },
select: { databaseUrl: true }
});
const client = new PrismaClient({
datasources: {
db: { url: tenant.databaseUrl }
},
// Ограничиваем количество подключений на тенанта
datasourceUrl: tenant.databaseUrl,
});
clientPool.set(tenantId, client);
// Убираем из пула при неактивности
setTimeout(() => {
clientPool.get(tenantId)?.$disconnect();
clientPool.delete(tenantId);
}, 30 * 60 * 1000); // 30 минут
return client;
}
Создание БД при онбординге
// Провизионирование тенанта
export async function provisionTenant(
tenantSlug: string,
plan: string
): Promise<Tenant> {
// 1. Создаём запись в master DB
const tenant = await masterDb.tenant.create({
data: {
slug: tenantSlug,
plan,
status: 'PROVISIONING',
}
});
try {
// 2. Создаём базу данных
const dbName = `tenant_${tenantSlug.replace(/-/g, '_')}`;
const dbUser = `user_${tenant.id.substring(0, 8)}`;
const dbPassword = generateSecurePassword();
// Создаём БД и пользователя через postgres superuser
const adminPool = new Pool({ connectionString: process.env.POSTGRES_ADMIN_URL });
await adminPool.query(`CREATE DATABASE "${dbName}"`);
await adminPool.query(`CREATE USER "${dbUser}" WITH PASSWORD '${dbPassword}'`);
await adminPool.query(`GRANT ALL PRIVILEGES ON DATABASE "${dbName}" TO "${dbUser}"`);
const databaseUrl = `postgresql://${dbUser}:${dbPassword}@${process.env.DB_HOST}/${dbName}`;
// 3. Применяем миграции к новой БД
const { execSync } = await import('child_process');
execSync(`DATABASE_URL="${databaseUrl}" npx prisma migrate deploy`, {
env: { ...process.env, DATABASE_URL: databaseUrl }
});
// 4. Обновляем запись с connection string
await masterDb.tenant.update({
where: { id: tenant.id },
data: {
databaseUrl,
databaseName: dbName,
status: 'ACTIVE',
}
});
return tenant;
} catch (error) {
// Откатываем при ошибке
await masterDb.tenant.update({
where: { id: tenant.id },
data: { status: 'FAILED' }
});
throw error;
}
}
Миграции: накатывание на все тенантов
// scripts/migrate-all-tenants.ts
import { execSync } from 'child_process';
async function migrateAllTenants() {
const tenants = await masterDb.tenant.findMany({
where: { status: 'ACTIVE' },
select: { id: true, slug: true, databaseUrl: true }
});
console.log(`Migrating ${tenants.length} tenants...`);
const results = {
success: [] as string[],
failed: [] as string[],
};
// Мигрируем параллельно батчами по 10
for (let i = 0; i < tenants.length; i += 10) {
const batch = tenants.slice(i, i + 10);
await Promise.allSettled(
batch.map(async (tenant) => {
try {
execSync(`npx prisma migrate deploy`, {
env: { ...process.env, DATABASE_URL: tenant.databaseUrl },
stdio: 'pipe',
});
results.success.push(tenant.slug);
} catch (error) {
results.failed.push(tenant.slug);
console.error(`Failed to migrate ${tenant.slug}:`, error);
}
})
);
}
console.log(`Success: ${results.success.length}, Failed: ${results.failed.length}`);
if (results.failed.length > 0) {
console.error('Failed tenants:', results.failed);
process.exit(1);
}
}
migrateAllTenants();
Бэкапы per-tenant
#!/bin/bash
# backup-tenant.sh
TENANT_ID=$1
DB_URL=$(psql $MASTER_DB_URL -t -c "SELECT database_url FROM tenants WHERE id='$TENANT_ID'")
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="backup_${TENANT_ID}_${TIMESTAMP}.dump"
# Создаём бэкап
pg_dump "$DB_URL" -Fc -f "$BACKUP_FILE"
# Загружаем в S3
aws s3 cp "$BACKUP_FILE" \
"s3://my-backups/tenants/${TENANT_ID}/${BACKUP_FILE}" \
--server-side-encryption aws:kms
rm "$BACKUP_FILE"
echo "Backup completed: s3://my-backups/tenants/${TENANT_ID}/${BACKUP_FILE}"
Мониторинг
-- Размер БД каждого тенанта
SELECT
d.datname as database,
pg_database_size(d.datname) as size,
pg_size_pretty(pg_database_size(d.datname)) as size_pretty
FROM pg_database d
WHERE datname LIKE 'tenant_%'
ORDER BY size DESC;
Разработка database-per-tenant архитектуры с автоматическим провизионированием — 5–10 рабочих дней.







