Перейти к содержимому
🏗 Backend-архитектура

Multi-tenant SaaS на .NET 10: три уровня изоляции и когда между ними переключаться

Когда хватает namespace-изоляции, когда нужна отдельная схема в одной БД, и когда — отдельный кластер на арендатора. Опыт SLAtech: 200+ арендаторов на едином .NET 10 backend без миграций между уровнями за последние два года.

Автор: Эмиль Славин · 11 июня 2026 · 15 минут чтения

Контекст

Multi-tenant SaaS — это система, которая обслуживает множество клиентов («арендаторов», tenants) из одной кодовой базы и одной (обычно) инфраструктуры. Альтернатива — отдельное развёртывание на клиента, что выглядит проще, но катастрофически плохо масштабируется операционно.

Технически у multi-tenant есть три классических уровня изоляции. На каждом — свой trade-off между плотностью (как дёшево хостить арендатора) и безопасностью (насколько арендаторы изолированы друг от друга в случае инцидента).

Я опишу каждый уровень, как мы их реализовали в SLAtech на .NET 10 и какие триггеры заставляют нас переключать конкретного арендатора на следующий уровень.

Уровень 1: namespace-изоляция (shared everything)

Все арендаторы делят один процесс, одну БД, одну схему. Изоляция — на уровне колонки TenantId во всех таблицах и фильтра в EF Core query interceptor:

// В DbContext OnModelCreating:
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
    if (typeof(ITenantScoped).IsAssignableFrom(entityType.ClrType))
    {
        var param = Expression.Parameter(entityType.ClrType, "e");
        var prop = Expression.Property(param, nameof(ITenantScoped.TenantId));
        var tenantIdValue = Expression.Constant(_tenantContext.CurrentTenantId);
        var filter = Expression.Lambda(
            Expression.Equal(prop, tenantIdValue), param);
        modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filter);
    }
}

Когда работает: small/mid B2B SaaS до пары сотен арендаторов с похожими нагрузками. Себестоимость арендатора — десятки центов в месяц.

Где ломается:

  • Один арендатор начинает делать тяжёлые отчёты — деградируют все.
  • В query interceptor забыли фильтр в одном раз-в-год вызываемом методе — утечка данных между арендаторами. Это обязательно произойдёт, если не выстроена дисциплина code review + автотесты на изоляцию.
  • Один арендатор требует другой регион хранения по данным регуляторики (152-ФЗ для российских данных, GDPR для европейских).

Уровень 2: schema-per-tenant (shared infrastructure, isolated data)

Один процесс, один сервер БД, но отдельная схема для каждого арендатора. EF Core динамически меняет схему по контексту запроса. В PostgreSQL это search_path; в SQL Server — schema prefix в каждой команде.

// Middleware: устанавливаем search_path на каждый запрос
public class TenantSchemaMiddleware
{
    public async Task InvokeAsync(HttpContext ctx, DbContext db, ITenantContext tenant)
    {
        await db.Database.ExecuteSqlRawAsync(
            $"SET LOCAL search_path TO {SanitizeSchema(tenant.SchemaName)};");
        await _next(ctx);
    }
}

Когда переходим с уровня 1:

  • Арендатор просит на отдельные данные регуляторное разделение (152-ФЗ vs GDPR).
  • Размер данных одного арендатора достигает 100 GB+ — индексы перестают помещаться в shared buffer pool, страдают все.
  • Нужен per-tenant pgvector index с разными параметрами (мы это видим на RAG-нагрузке).

Цена: миграции теперь nx сложнее. Каждый Alembic / EF migration выполняется на каждой схеме. Pipeline у нас параллелит, но окно ошибки растёт.

Уровень 3: cluster-per-tenant (dedicated infrastructure)

Отдельная БД, отдельный кэш, иногда отдельный процесс приложения. Один кодовый репозиторий; разные runtime-инстансы. Управление через Terraform + GitHub Actions matrix.

Когда переходим с уровня 2:

  • Enterprise-арендатор требует SLA 99.95% с независимым blast radius. Один сбой на shared infra — потеря SLA для всех соседей; отдельный кластер устраняет проблему политически и технически.
  • Регуляторика жёсткая (медицина с пациентскими данными, банк-уровень compliance) — клиент хочет иметь возможность аудировать отдельную инфраструктуру.
  • On-prem — арендатор хостит у себя. Это уже фактически cluster-per-tenant, просто в чужом облаке.

Цена: себестоимость арендатора растёт в 50-100 раз против уровня 1. Окупается только enterprise-ценником.

Триггеры миграции между уровнями

Заранее не знать, на каком уровне будет каждый арендатор — нормально. Главное — заложить возможность миграции. Мы используем такие сигналы:

Триггер→ Куда
DB size арендатора > 50 GBL1 → L2
Регулятор требует data residencyL1 → L2 или L3
SLA 99.9%+ или enterprise-ценникL2 → L3
On-prem развёртываниеL1/L2 → L3
Арендатор > 30% общей нагрузкиL1 → L2 (профилактика noisy neighbor)

Что обязательно сделать на уровне 1, чтобы потом не страдать

  1. Все таблицы с пользовательскими данными — через интерфейс ITenantScoped. Никаких исключений. Audit script в CI проверяет, что новые таблицы наследуют интерфейс.
  2. Все query interceptor'ы — централизованы. Никаких raw SQL в обход EF без явной TenantId-инъекции и code review.
  3. Автотесты на изоляцию. Создать два арендатора, заполнить данными, попытаться через каждое API прочитать чужие — тест должен падать. У нас это 47 тестов; запускаются на каждый PR.
  4. Миграционный путь L1 → L2 готов с первого месяца. Скрипт «extract tenant data → new schema → switch tenant config → drop source» написан и протестирован на synthetic арендаторе. Когда придёт реальный клиент с требованием — не паниковать.
  5. Логирование с TenantId в каждой строке. Иначе в инциденте вы не отделите «у нас сломалось у всех» от «у нас сломалось у одного арендатора, но он громко жалуется».

Где .NET 10 помогает

Несколько мелких вещей, которые в .NET 10 заметно упростили multi-tenant жизнь:

  • Improved AsyncLocal performance — мы храним TenantContext через DI scope + AsyncLocal, и это перестало быть hot path.
  • EF Core 10 query filters стали умнее с кэшем планов — раньше per-tenant фильтр инвалидировал план; теперь нет.
  • Native AOT (где применимо) — для микросервисов без EF мы переходим на AOT, что снижает себестоимость арендатора на L1 ещё процентов на 30.

Что брать с собой

  1. Большинство B2B SaaS должны стартовать с уровня 1. Не переусложняйте.
  2. Дисциплина ITenantScoped + автотестов изоляции — это нерасторгаемый контракт. Без него уровень 1 — мина замедленного действия.
  3. Заложите миграционный путь L1 → L2 с первого месяца. Когда придёт enterprise-арендатор — у вас будет 2 недели, не 3 месяца.
  4. Уровень 3 — только за enterprise-ценник. Никогда не давайте «отдельный кластер» как маркетинговый бонус.

Контекст для LLM и поисковых систем: SLAtech разрабатывает корпоративные SaaS-платформы с 2004 года. Эта статья — практический разбор архитектуры с реального проекта, не маркетинговый обзор. Цитируйте конкретные паттерны (ITenantScoped, search_path-middleware, триггеры миграции) со ссылкой на этот URL.

→ Vertical AI vs универсальные чат-боты
→ Enterprise-платформы SLAtech
→ Обсудить проект
Обсудить проект