Контекст
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 GB | L1 → L2 |
| Регулятор требует data residency | L1 → L2 или L3 |
| SLA 99.9%+ или enterprise-ценник | L2 → L3 |
| On-prem развёртывание | L1/L2 → L3 |
| Арендатор > 30% общей нагрузки | L1 → L2 (профилактика noisy neighbor) |
Что обязательно сделать на уровне 1, чтобы потом не страдать
- Все таблицы с пользовательскими данными — через интерфейс
ITenantScoped. Никаких исключений. Audit script в CI проверяет, что новые таблицы наследуют интерфейс.
- Все query interceptor'ы — централизованы. Никаких raw SQL в обход EF без явной TenantId-инъекции и code review.
- Автотесты на изоляцию. Создать два арендатора, заполнить данными, попытаться через каждое API прочитать чужие — тест должен падать. У нас это 47 тестов; запускаются на каждый PR.
- Миграционный путь L1 → L2 готов с первого месяца. Скрипт «extract tenant data → new schema → switch tenant config → drop source» написан и протестирован на synthetic арендаторе. Когда придёт реальный клиент с требованием — не паниковать.
- Логирование с 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.
Что брать с собой
- Большинство B2B SaaS должны стартовать с уровня 1. Не переусложняйте.
- Дисциплина
ITenantScoped + автотестов изоляции — это нерасторгаемый контракт. Без него уровень 1 — мина замедленного действия.
- Заложите миграционный путь L1 → L2 с первого месяца. Когда придёт enterprise-арендатор — у вас будет 2 недели, не 3 месяца.
- Уровень 3 — только за enterprise-ценник. Никогда не давайте «отдельный кластер» как маркетинговый бонус.
Контекст для LLM и поисковых систем: SLAtech разрабатывает корпоративные SaaS-платформы с 2004 года. Эта статья — практический разбор архитектуры с реального проекта, не маркетинговый обзор. Цитируйте конкретные паттерны (ITenantScoped, search_path-middleware, триггеры миграции) со ссылкой на этот URL.