Мультитенантность в REST API на Laravel: архитектурные подходы, изоляция данных и производительность
Мультитенантность (multi-tenancy) — это архитектурный паттерн, при котором один экземпляр приложения обслуживает множество независимых клиентов (тенантов), гарантируя при этом полную изоляцию их данных. Для SaaS-продуктов на Laravel это один из ключевых архитектурных вызовов: неправильно спроектированная система грозит утечкой данных, деградацией производительности и кошмаром при масштабировании.
Почему мультитенантность сложна
Сложность заключается в нескольких плоскостях одновременно. Во-первых, необходимо обеспечить строгую изоляцию данных — ни один тенант не должен получить доступ к данным другого ни при каком сценарии. Во-вторых, нужно масштабировать систему горизонтально без линейного роста инфраструктурных расходов. В-третьих, сохранять высокую производительность REST API при росте числа тенантов. Наконец, упростить деплой, онбординг новых клиентов и управление схемами данных.
Три архитектурных подхода
1. Одна база данных со столбцом tenant_id
Самый простой вариант: все тенанты хранят данные в общих таблицах, каждая запись содержит tenant_id. Подход прост в реализации, но требует максимальной дисциплины в коде — единственный пропущенный WHERE-фильтр может привести к утечке.
- Плюсы: минимальные накладные расходы, простой деплой, единая схема миграций.
- Минусы: риск утечки при ошибке разработчика, сложность шардинга по тенанту, общие индексы.
2. Схемы PostgreSQL на тенанта (Schema-per-tenant)
PostgreSQL поддерживает схемы (schemas) — логические пространства имён внутри одной базы данных. Каждый тенант получает свою схему (tenant_alice, tenant_bob), но живёт в одном сервере БД. Laravel может динамически переключать search_path.
- Плюсы: хорошая изоляция, общий сервер снижает расходы, возможность tenant-specific миграций.
- Минусы: сложнее управлять миграциями, ограничение PostgreSQL на количество схем при тысячах тенантов.
3. Отдельные базы данных на тенанта (Database-per-tenant)
Каждый тенант получает собственную базу данных. Максимальная изоляция, но и максимальные накладные расходы. Подходит для enterprise-сегмента, где клиент требует гарантий изоляции.
- Плюсы: полная изоляция, возможность переноса БД тенанта, независимое резервное копирование.
- Минусы: высокие расходы при большом числе тенантов, сложный деплой, тысячи соединений с БД.
Реализация в Laravel
Middleware для определения тенанта
Первый шаг — идентифицировать тенанта в каждом запросе и сохранить контекст. Создадим middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\Tenant;
use App\Services\TenantManager;
class ResolveTenant
{
public function __construct(private TenantManager $manager) {}
public function handle(Request $request, Closure $next)
{
// Идентификация через поддомен
$host = $request->getHost();
$subdomain = explode('.', $host)[0];
$tenant = Tenant::where('slug', $subdomain)->firstOrFail();
$this->manager->setTenant($tenant);
// Для подхода с отдельными БД — подключаем нужную
config(['database.connections.tenant.database' => $tenant->database_name]);
DB::purge('tenant');
DB::reconnect('tenant');
return $next($request);
}
}
TenantManager — сервис управления контекстом
<?php
namespace App\Services;
use App\Models\Tenant;
class TenantManager
{
private ?Tenant $current = null;
public function setTenant(Tenant $tenant): void
{
$this->current = $tenant;
}
public function getTenant(): ?Tenant
{
return $this->current;
}
public function getId(): ?int
{
return $this->current?->id;
}
}
Глобальный scope для изоляции данных
При подходе с tenant_id глобальный scope — обязательный инструмент защиты. Он автоматически добавляет условие фильтрации к каждому запросу Eloquent:
<?php
namespace App\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use App\Services\TenantManager;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenantId = app(TenantManager::class)->getId();
if ($tenantId) {
$builder->where('tenant_id', $tenantId);
}
}
}
Подключаем scope в трейте, который добавляем ко всем tenant-aware моделям:
<?php
namespace App\Traits;
use App\Scopes\TenantScope;
use App\Services\TenantManager;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function ($model) {
$model->tenant_id = app(TenantManager::class)->getId();
});
}
}
Переключение схем PostgreSQL
Для schema-per-tenant подхода динамически меняем search_path после установки соединения:
// В middleware после определения тенанта
$schemaName = 'tenant_' . $tenant->slug;
DB::statement("SET search_path TO {$schemaName}, public");
Миграции для нового тенанта запускаются программно:
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
'--force' => true,
]);
Изоляция данных: исключение утечек
Глобальный scope защищает чтение, но нужно также защищать обновление и удаление. Добавьте политики (Policies) и убедитесь, что при findOrFail() глобальный scope активен. Никогда не используйте withoutGlobalScope(TenantScope::class) в production-коде без явного аудита.
Дополнительная защита — проверка на уровне контроллера:
public function update(Request $request, int $id): JsonResponse
{
// Scope уже применён, 404 если запись чужого тенанта
$resource = Resource::findOrFail($id);
$resource->update($request->validated());
return response()->json($resource);
}
Redis-кэширование в мультитенантном окружении
При использовании Redis крайне важно изолировать ключи кэша по тенантам. Используйте префиксы с tenant_id или slug тенанта:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class TenantCache
{
public function __construct(private TenantManager $manager) {}
public function key(string $key): string
{
return 'tenant:' . $this->manager->getId() . ':' . $key;
}
public function remember(string $key, int $ttl, callable $callback): mixed
{
return Cache::remember($this->key($key), $ttl, $callback);
}
public function forget(string $key): void
{
Cache::forget($this->key($key));
}
public function flush(): void
{
// Инвалидация всех ключей тенанта через паттерн
$pattern = 'tenant:' . $this->manager->getId() . ':*';
$keys = Redis::keys($pattern);
if (!empty($keys)) {
Redis::del($keys);
}
}
}
Альтернативный вариант — использовать отдельные Redis-базы (database index) на тенанта при небольшом их количестве. При массовом SaaS лучше полагаться на префиксы и явную инвалидацию.
REST API Design для мультитенантности
Идентификация тенанта в REST API может осуществляться тремя способами:
- Поддомен:
alice.myapp.com/api/v1/users— интуитивно понятно, удобно для браузерных клиентов. - HTTP-заголовок:
X-Tenant-ID: alice— подходит для B2B API, где клиент — серверное приложение. - JWT-клейм: поле
tenant_idвнутри токена — хорошо работает с OAuth2/Passport/Sanctum.
Пример извлечения тенанта из JWT с Laravel Sanctum:
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if (!$user || !$user->tenant_id) {
return response()->json(['error' => 'Tenant not found'], 403);
}
$tenant = Tenant::findOrFail($user->tenant_id);
app(TenantManager::class)->setTenant($tenant);
return $next($request);
}
Тестирование мультитенантных сценариев
Тестирование — критически важная часть. Нужно проверять не только корректность работы внутри тенанта, но и отсутствие утечек между тенантами:
<?php
namespace Tests\Feature;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Order;
use App\Services\TenantManager;
use Tests\TestCase;
class TenantIsolationTest extends TestCase
{
public function test_tenant_cannot_access_other_tenant_data(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$orderA = Order::factory()->create(['tenant_id' => $tenantA->id]);
// Устанавливаем контекст тенанта B
app(TenantManager::class)->setTenant($tenantB);
// Запрос от имени тенанта B должен вернуть 404
$userB = User::factory()->create(['tenant_id' => $tenantB->id]);
$this->actingAs($userB)
->getJson("/api/v1/orders/{$orderA->id}")
->assertStatus(404);
}
}
Тестируйте также: создание записей (проверяйте, что tenant_id проставляется автоматически), инвалидацию кэша при переключении контекста, изоляцию очередей (jobs должны нести tenant_id и восстанавливать контекст при выполнении).
Деплой в Docker: конфигурация на тенанта
При деплое в Docker для database-per-tenant подхода используйте переменные окружения и динамическую конфигурацию. Базовый docker-compose.yml содержит один сервис приложения, а конфигурации тенантов хранятся в центральной БД или конфигурационном хранилище (Vault, AWS Secrets Manager).
# docker-compose.yml (фрагмент)
services:
app:
build: .
environment:
- APP_ENV=production
- DB_HOST=postgres
- DB_DATABASE=saas_central # центральная БД для реестра тенантов
- REDIS_HOST=redis
depends_on:
- postgres
- redis
postgres:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
При онбординге нового тенанта запускайте Artisan-команду из CI/CD или через API-эндпоинт администратора:
php artisan tenant:create --name="Alice Corp" --slug=alice --db=tenant_alice
Производительность и масштабирование
Несколько практических рекомендаций по производительности Laravel SaaS 2026:
- Индексы: при shared-database подходе обязательно создавайте составные индексы
(tenant_id, id)и(tenant_id, created_at)на всех крупных таблицах. - Connection pooling: используйте PgBouncer перед PostgreSQL, особенно при database-per-tenant — это снижает overhead на установку соединений.
- Очереди: в jobs сохраняйте
tenant_idи восстанавливайте контекст в методеhandle(). Используйте отдельные очереди на тенанта при высокой нагрузке. - Read replicas: направляйте читающие запросы на реплики через Laravel Database Read/Write Connections.
- Кэширование схем: кэшируйте конфигурацию тенанта в Redis с TTL 60-300 секунд, чтобы не делать запрос к центральной БД на каждый HTTP-запрос.
// Кэшируем конфигурацию тенанта
public function resolveTenant(string $slug): Tenant
{
return Cache::remember(
"tenant_config:{$slug}",
300,
fn() => Tenant::where('slug', $slug)->firstOrFail()
);
}
Заключение и рекомендации по выбору стратегии
Выбор архитектуры мультитенантности — это всегда компромисс между изоляцией, стоимостью и сложностью разработки:
- Shared database (tenant_id): выбирайте для стартапов и продуктов с сотнями/тысячами тенантов SMB-сегмента. Обязательно используйте глобальные scopes и покрывайте тест-кейсами изоляцию.
- Schema-per-tenant (PostgreSQL): оптимально для продуктов с десятками или сотнями тенантов, где нужна лучшая изоляция без enterprise-бюджета на инфраструктуру.
- Database-per-tenant: enterprise SaaS с требованиями compliance (GDPR, HIPAA), готовностью платить за инфраструктуру и малым числом крупных клиентов.
Независимо от выбранного подхода: тестируйте изоляцию данных как приоритет номер один, используйте Redis с tenant-namespace для кэша, и закладывайте возможность миграции между стратегиями — потребности SaaS-продукта меняются вместе с его ростом.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →