Архитектура

Мультитенантность в REST API на Laravel: архитектурные подходы, изоляция данных и производительность

Ruslan Ismailov Опубликовано 12 мин чтения
М

Мультитенантность (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 может осуществляться тремя способами:

  1. Поддомен: alice.myapp.com/api/v1/users — интуитивно понятно, удобно для браузерных клиентов.
  2. HTTP-заголовок: X-Tenant-ID: alice — подходит для B2B API, где клиент — серверное приложение.
  3. 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. Подробнее обо мне →