Мульти-тенантность (multi-tenancy, многоарендность) — это архитектурный подход, при котором один единственный работающий экземпляр приложения обслуживает множество изолированных клиентов (тенантов или арендаторов). Тенантом может быть отдельная компания в B2B SaaS, филиал корпоративной сети или семья в финансовом сервисе вроде FamFi. Приложение едино для всех, но каждый клиент живет в полной иллюзии того, что вся система и база данных выделены исключительно под него.
Необходимость в такой архитектуре возникает сразу, как только проект вырастает из штучного сайта в тиражируемый сервис: разворачивать отдельный сервер, виртуальную машину и копию кода под каждого нового пользователя экономически нецелесообразно. Однако вместе с выгодой одного инстанса возникает главная архитектурная угроза: клиенты делят общие ресурсы, но ни при каких условиях не должны видеть чужие заказы, настройки или персональные данные. Особенно остро эта проблема встает в эпоху микро-SaaS и фоновых AI-агентов, где автоматизация непрерывно читает и пишет данные в фоне от лица разных тенантов.
Почему здесь регулярно происходят утечки? Чтобы сэкономить на серверах, разработчики складывают всех клиентов в одну общую базу с колонкой tenant_id. В теории достаточно добавлять фильтр where('tenant_id') к каждому запросу. На практике в кодовой базе на десятки моделей безопасность целиком перекладывается на человеческий фактор: забытый фильтр в сыром SQL, вызов DB::table() в обход ORM, случайный сброс скоупа в консольной команде или гонка в общем Redis-кэше приводят к тому, что клиент «А» внезапно видит чужую конфиденциальную информацию.
Бороться с этим можно тремя архитектурными путями: физически изолировать клиентов по разным базам данных (надежно, но дорого и тяжело в деплое), вешать прикладные Global Scopes в коде (дешево, но регулярно протекает) либо переносить проверку в ядро СУБД (PostgreSQL RLS) в связке с изоляцией очередей и кэша. Ниже — полный архитектурный разбор всех уровней изоляции, различия PostgreSQL и MySQL 8 и практические паттерны из продакшена.
Три модели изоляции данных и их накладные расходы
В архитектуре баз данных для multi-tenant приложений существуют три классических паттерна с принципиально разным балансом стоимости, сложности и безопасности:
1. Shared Database, Shared Schema (Колонка tenant_id)
Все клиенты делят общие таблицы в одной базе данных. Каждая строка содержит идентификатор владельца (tenant_id). Минимальная стоимость инфраструктуры, простой бэкап и удобная сквозная аналитика. Однако безопасность целиком перекладывается на прикладной код: любая ошибка в сыром SQL-запросе приводит к мгновенной утечке чужих персональных данных.
2. Shared Database, Separate Schema (Схемы в PostgreSQL)
Одна общая база, но для каждого тенанта создается изолированная схема (tenant_1, tenant_2). Переключение контекста выполняется установкой пути поиска SET search_path TO tenant_1, public;. Накладные расходы на ресурсы умеренные, но возникают две проблемы:
- Транзакционный пул соединений (PgBouncer): в режиме Transaction Pooling директива
search_pathсбрасывается или может утекать между транзакциями разных клиентов. - Масштабирование миграций: выполнение
artisan migrateна 1000 схем превращается в долгий последовательный процесс при деплое.
3. Database per tenant (Изолированная БД на клиента)
Для каждого клиента разворачивается физически отдельная база данных. Полная гарантия изоляции, соответствие регуляторным требованиям (GDPR, HIPAA, SOC 2) и возможность легкого бэкапа/удаления данных конкретной компании. Минусы — инфраструктурный оверхед: поддержка сотен открытых TCP-соединений к PostgreSQL/MySQL требует тяжелых пулеров, а сквозные агрегирующие отчеты по всей платформе требуют выгрузки данных в отдельный DWH/ClickHouse.
Почему Eloquent Global Scopes протекают в продакшене
Большинство разработчиков при реализации Shared Schema выбирают глобальные скоупы: создается трейт BelongsToTenant, который вешает на модель TenantScope с условием where('tenant_id', TenantManager::id()).
В реальном проде эта схема регулярно дает сбои по трем причинам:
- Сырые запросы и Query Builder: прямой вызов фасада
DB::table('invoices')->where('status', 'paid')->get()полностью игнорирует Eloquent-скоупы. Если разработчик написал быстрый аналитический запрос или дамп, он выгрузит данные всех компаний сразу. - Опасность withoutGlobalScopes: метод
withoutGlobalScopes()часто вызывают во внутренних джобах, отчетах или админ-панелях (MoonShine / Filament). Достаточно забыть восстановить фильтрацию или случайно передать нефильтрованный Query Builder в другой сервис — и контекст тенанта теряется. - Промежуточные таблицы в HasManyThrough: глобальный скоуп применяется к конечной модели, но промежуточная таблица может джойниться без проверки
tenant_id, что при определенных условиях приводит к пересечению связей.
Практический кейс: гибридная модель в FamFi (System vs Tenant)
В реальных приложениях (например, в сервисе семейных бюджетов FamFi) сущности редко бывают чисто клиентскими. Справочники часто декомпозируют на два уровня:
- Системные записи (
is_system = true, family_id = null): базовые категории (Продукты, Аренда, Зарплата), предзаполненные сидерами и доступные всем по умолчанию. - Клиентские записи (
is_system = false, family_id = $familyId): кастомные сущности, создаваемые конкретными пользователями.
1. Ловушка приоритета операторов OR в SQL
Попытка объединить системные и клиентские записи простым скоупом:
// ОШИБКА: разрушит последующие фильтры в запросе
public function scopeForFamily($query, int $familyId): void
{
$query->where('is_system', true)
->orWhere('family_id', $familyId);
}
При вызове Category::forFamily($id)->where('type', 'expense')->get() скомпилированный SQL выполнится как:
SELECT * FROM categories
WHERE is_system = 1 OR family_id = 123 AND type = 'expense';
Из-за того, что в SQL оператор AND имеет приоритет над OR, база данных вернет все системные категории вообще любого типа (включая доходы). Обязательна группировка через анонимную функцию:
#[Scope]
protected function forFamily($query, int $familyId): void
{
$query->where(function ($q) use ($familyId): void {
$q->where('is_system', true)
->orWhere('family_id', $familyId);
});
}
2. Защита от IDOR на уровне валидации (Rule::exists)
Когда API принимает внешний ID связанной сущности (например, category_id при создании транзакции), стандартного правила 'category_id' => 'required|exists:categories,id' недостаточно. Злоумышленник может передать ID категории чужого тенанта, и запись будет создана.
В FamFi это решено централизованным правилом валидации в модели:
public static function validationRule(int $familyId): Exists
{
return Rule::exists('categories', 'id')->where(function ($query) use ($familyId): void {
$query->where('family_id', $familyId)
->orWhere(function ($q): void {
$q->where('is_system', true)
->whereNull('family_id');
});
});
}
Аппаратная изоляция: Row-Level Security (RLS) в PostgreSQL
Самый надежный инженерный способ защиты при использовании Shared Database в PostgreSQL — перенос валидации тенанта из PHP-кода напрямую в ядро СУБД с помощью Row-Level Security (RLS). База данных физически не отдаст и не обновит чужие строки, даже если запрос выполнен через сырой SQL без where('tenant_id', ...).
1. Настройка политики в миграции PostgreSQL:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON orders
AS RESTRICTIVE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
2. Установка контекста в Laravel Middleware:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SetTenantContext
{
public function handle(Request $request, Closure $next)
{
$tenant = $request->user()?->tenant;
if ($tenant) {
// SET LOCAL действует строго в рамках текущей транзакции
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
}
return $next($request);
}
}
Почему критично использовать SET LOCAL: директива SET LOCAL привязывает параметр конфигурации исключительно к текущей транзакции. Как только транзакция завершается (COMMIT или ROLLBACK), параметр сбрасывается. Это исключает утечку чужого tenant_id в следующий HTTP-запрос при использовании пулеров соединений (PgBouncer, Laravel Octane, FrankenPHP).
Реальность MySQL 8: жизнь без Row-Level Security
Если проект развернут на MySQL 8, нативного механизма Row-Level Security в СУБД нет. Это меняет архитектурный подход:
- Опасность переменных сессии: в MySQL пользовательские переменные (
SET @current_tenant_id = 123) живут на уровне всего TCP-соединения. При использовании постоянных коннектов или долгоживущих воркеров переменная не сбрасывается транзакционно и протекает в запросы других пользователей. Опираться на переменные сессии в MySQL нельзя. - Три эшелона защиты в коде:
- Явные методы выборки: отказ от неявной «магии» в пользу строгих методов репозиториев/моделей (вроде
assignableForFamily($id)), где фильтр тенанта зашит жестко. - FormRequest-валидация внешних ID: обязательное использование
Rule::existsс проверкойtenant_idдля отсечения IDOR до попадания данных в контроллер. - Композитные индексы: составные индексы вида
KEY (tenant_id, created_at)иUNIQUE (tenant_id, code)в схеме InnoDB для предотвращения деградации производительности и коллизий уникальности.
- Явные методы выборки: отказ от неявной «магии» в пользу строгих методов репозиториев/моделей (вроде
Очереди, воркеры и утечки контекста в долгоживущих процессах
Одна из самых коварных проблем multi-tenancy — выполнение фоновых задач в Laravel Horizon или queue:work. Когда задача диспатчится из веб-контроллера, текущий тенант известен. Но воркер очереди запускается в чистом фоновом процессе без HTTP-контекста.
Если задача не умеет восстанавливать тенанта, она выполнится в вакууме либо обратится к неверной БД. Еще опаснее ситуация в долгоживущих демонах (Laravel Octane / RoadRunner): если статический сервис TenantManager::$currentTenant установил тенанта в процессе выполнения задачи, этот инстанс останется висеть в оперативной памяти PHP-воркера и по цепочке перетечет в следующую входящую задачу другого клиента.
Архитектурный паттерн: TenantAwareQueueMiddleware
Все задачи, работающие с контекстом клиента, должны явно сериализовать tenant_id в полезную нагрузку и восстанавливать окружение перед выполнением:
namespace App\Jobs\Middleware;
use App\Services\TenantManager;
class IdentifyTenant
{
public function handle($job, $next)
{
if (property_exists($job, 'tenantId') && $job->tenantId) {
TenantManager::switchTo($job->tenantId);
}
try {
return $next($job);
} finally {
// Обязательная очистка контекста для долгоживущих воркеров
TenantManager::reset();
}
}
}
Изоляция кэша и пространства ключей Redis
При использовании Shared Cache ключи вроде Cache::remember('user_profile_' . $userId, ...) приводят к катастрофическим коллизиям: в разных компаниях автоинкрементные ID пользователей могут совпадать. Пользователь компании «Б» мгновенно увидит профиль пользователя компании «А».
Безопасные решения для кэша:
- Динамический префикс драйвера: при переключении тенанта на лету подменять префикс кэша:
config(['cache.prefix' => 'tenant_' . $tenant->id . '_cache']); - Тегированный кэш (Tagged Cache): оборачивать все кэшируемые ключи в тег тенанта:
Cache::tags(['tenant_' . $tenant->id])->remember('profile_' . $userId, 3600, $callback);Это позволяет мгновенно сбросить кэш конкретного клиента (
Cache::tags(['tenant_' . $id])->flush()), не затрагивая остальные компании на платформе.
Резюме
- Выбор архитектурной модели (Shared DB, Schemas или Database-per-tenant) диктуется требованиями compliance и стоимостью поддержки миграций.
- В гибридных структурах (системные + клиентские сущности) всегда группируйте условия
ORв скоупах скобками во избежание логических сбоев в SQL. - В PostgreSQL используйте RLS с
SET LOCALдля аппаратной гарантии изоляции на уровне СУБД. В MySQL 8 переносите защиту на строгую валидациюRule::existsи типизированные методы моделей. - В долгоживущих процессах Horizon и Octane всегда сбрасывайте контекст тенанта в блоке
finallyQueue Middleware во избежание утечек памяти между задачами. - Изолируйте кэш через теги или динамические префиксы ключей для предотвращения коллизий одинаковых ID.