דלג לתוכן הראשי
🏢 Backend-архитектура

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

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

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

TL;DR

Multi-tenant - не tenant_id в WHERE. Изоляция, регуляторика, latency, observability - всё меняется на каждом уровне.

3
уровня изоляции
15 мин
время чтения
.NET 10
production stack
200+
tenants в кластере

Контекст

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)

Мультиюрисдикционный арендатор

Отдельный частый случай для нашей аудитории: один продукт обслуживает клиентов в РФ, Израиле и ЕС одновременно. 152-ФЗ требует локализации персональных данных граждан РФ на территории России; GDPR локализацию не требует, но требует законного механизма трансграничной передачи. На практике это означает, что уровень изоляции определяется не размером арендатора, а его юрисдикцией: российский контур уезжает на L2 или L3 в российском датацентре раньше, чем этого потребовала бы нагрузка.

Что обязательно сделать на уровне 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.

Related reading

Что прочитать дальше

Строите multi-tenant платформу?

30-минутная консультация - разберём модель изоляции и требования регуляторики к вашим арендаторам.