Содержание
- Что такое мультитенантность и зачем она SaaS-платформе
- Модель 1: Shared database + Row-Level Security
- Модель 2: Schema-per-tenant
- Модель 3: Database-per-tenant
- Сравнительная таблица
- Реализация: TenantContext, TenantFilter, AbstractRoutingDataSource
- Стратегия миграций: Flyway + мультисхема
- Тестирование: TestContainers с изоляцией тенантов
- Частые ошибки — и как мы на них напоролись
- Дерево решений: какая модель подойдёт вашему SaaS
Что такое мультитенантность
Мультитенантная архитектура — это когда один экземпляр приложения обслуживает несколько клиентов-арендаторов (тенантов), а данные каждого тенанта изолированы друг от друга. Один деплой. Одна кодовая база. Десятки или тысячи клиентов.
Почему не сделать каждому клиенту отдельный инстанс? Можно. Мы видели такой подход у логистической платформы — 40 инстансов, 40 CI/CD-пайплайнов, 40 наборов мониторинга. Стоимость инфраструктуры была выше, чем выручка от половины клиентов.
Мультитенантность решает три задачи:
- Экономия инфраструктуры. Один кластер PostgreSQL вместо сорока. Один Kubernetes namespace вместо сорока.
- Единая кодовая база. Фикс бага — один деплой. Не сорок.
- Онбординг за минуты. Новый клиент — запись в таблице или новая схема. Не Terraform-прогон на полчаса.
Мы в Новаком строим SaaS-платформы на Spring Boot с мультитенантной архитектурой уже четвёртый год. Три модели, о которых пойдёт речь ниже — не теория из учебника. Это решения, которые мы внедряли в продакшене для HR-tech стартапа, логистической платформы и enterprise CRM.
Каждая модель — компромисс между изоляцией, стоимостью и операционной сложностью. Серебряной пули нет. Но есть чёткие критерии выбора.
Модель 1: Shared database + Row-Level Security
Все тенанты живут в одной базе, в одних таблицах. Изоляция — через колонку tenant_id и политики Row-Level Security (RLS) в PostgreSQL.
Как это работает
Каждая бизнес-таблица получает колонку tenant_id. При каждом запросе PostgreSQL автоматически фильтрует строки по текущему тенанту. Приложение устанавливает переменную сессии — база делает остальное.
SQL для настройки RLS:
-- Включаем RLS на таблице
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Политика: видим только свои строки
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Политика для INSERT: нельзя вставить чужой tenant_id
CREATE POLICY tenant_insert ON orders
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Spring Boot: установка tenant_id через фильтр
На стороне Spring Boot нужно перед каждым запросом к базе установить переменную app.current_tenant. Это делается через Hibernate StatementInspector или напрямую через JDBC:
@Component
class RlsTenantInterceptor(
private val dataSource: DataSource
) : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
val tenantId = TenantContext.current()
?: throw TenantNotResolvedException()
// Устанавливаем tenant для RLS-политик PostgreSQL
dataSource.connection.use { conn ->
conn.createStatement().execute(
"SET LOCAL app.current_tenant = '${tenantId}'"
)
}
return true
}
}
Проблема с кодом выше — SET LOCAL работает только внутри транзакции. Если запрос не обёрнут в @Transactional, политика не применится. Решение надёжнее — через ConnectionPreparedStatementCreator или Hibernate StatementInspector:
@Component
class TenantStatementInspector : StatementInspector {
override fun inspect(sql: String): String {
val tenantId = TenantContext.current() ?: return sql
// Для каждого SQL-запроса добавляем SET перед ним
return "SET LOCAL app.current_tenant = '$tenantId'; $sql"
}
}
Конфигурация HikariCP
При RLS-подходе пул соединений один — все тенанты используют общий HikariCP. Это и плюс (меньше ресурсов), и минус (один шумный тенант может забрать весь пул).
spring:
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 5000
# Критично: сбрасываем tenant при возврате соединения в пул
connection-init-sql: "RESET app.current_tenant"
Строка connection-init-sql — защита от утечки контекста тенанта между запросами. Без неё соединение, возвращённое в пул, может сохранить app.current_tenant от предыдущего запроса. Это прямой путь к cross-tenant data leak.
Плюсы
- Минимальная стоимость инфраструктуры: одна база, один пул.
- Масштабирование до тысяч тенантов без роста количества соединений.
- Простой онбординг нового тенанта — запись в таблице
tenants. - RLS работает на уровне базы — даже если в приложении забыли фильтр, PostgreSQL не отдаст чужие данные.
Минусы
- Если разработчик забудет включить RLS на новой таблице — утечка данных.
- Миграции сложнее:
ALTER TABLEблокирует всю таблицу, всех тенантов. - Один тенант с тяжёлым запросом может деградировать производительность для всех.
- GDPR-удаление данных одного тенанта —
DELETE FROMпо всем таблицам, а неDROP SCHEMA.
Когда использовать
Подходит для SaaS с сотнями или тысячами мелких тенантов с похожим объёмом данных. Типичный пример — B2B-платформа с бесплатным тарифом, где каждый тенант хранит 10-50 МБ. Мы использовали этот подход для HR-tech стартапа с 800+ компаниями-клиентами.
Модель 2: Schema-per-tenant
У каждого тенанта своя PostgreSQL-схема внутри одной базы данных. Таблицы идентичны, но физически разделены: tenant_acme.orders, tenant_globex.orders.
Динамическая маршрутизация схемы
Spring Boot должен переключать search_path PostgreSQL в зависимости от тенанта. Для этого используем AbstractRoutingDataSource — но вместо разных DataSource достаточно менять схему:
@Component
class SchemaPerTenantConnectionProvider(
private val dataSource: DataSource
) : MultiTenantConnectionProvider<String> {
override fun getConnection(tenantIdentifier: String): Connection {
val connection = dataSource.connection
connection.createStatement().execute(
"SET search_path TO tenant_$tenantIdentifier, public"
)
return connection
}
override fun releaseConnection(
tenantIdentifier: String,
connection: Connection
) {
connection.createStatement().execute(
"SET search_path TO public"
)
connection.close()
}
override fun getAnyConnection(): Connection = dataSource.connection
override fun releaseAnyConnection(connection: Connection) = connection.close()
override fun supportsAggressiveRelease(): Boolean = false
}
Hibernate: CurrentTenantIdentifierResolver
@Component
class TenantIdentifierResolver : CurrentTenantIdentifierResolver<String> {
override fun resolveCurrentTenantIdentifier(): String {
return TenantContext.current() ?: "public"
}
override fun validateExistingCurrentSessions(): Boolean = true
}
И конфигурация Hibernate:
@Configuration
class HibernateMultiTenantConfig {
@Bean
fun hibernateProperties(
connectionProvider: SchemaPerTenantConnectionProvider,
tenantResolver: TenantIdentifierResolver
): HibernatePropertiesCustomizer = HibernatePropertiesCustomizer { props ->
props[AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER] = connectionProvider
props[AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER] = tenantResolver
}
}
Flyway-миграции: по одной на каждую схему
Это главная боль schema-per-tenant. Стандартный Flyway мигрирует одну схему. Нужен цикл:
@Component
class MultiSchemaFlywayMigrator(
private val dataSource: DataSource,
private val tenantRepository: TenantRepository
) {
@EventListener(ApplicationReadyEvent::class)
fun migrateAllSchemas() {
val tenants = tenantRepository.findAllActive()
tenants.forEach { tenant ->
val flyway = Flyway.configure()
.dataSource(dataSource)
.schemas("tenant_${tenant.id}")
.locations("classpath:db/migration/tenant")
.baselineOnMigrate(true)
.load()
flyway.migrate()
}
}
}
При 50 тенантах это запуск 50 Flyway-миграций при старте приложения. При 500 — время старта растёт до минут. Решения: параллельное выполнение через корутины, или вынос миграций в отдельный Job.
Connection pooling
Один пул HikariCP — схемы переключаются через SET search_path. Это эффективнее, чем пул-на-тенанта, но требует аккуратности: соединение после использования нужно сбрасывать на public.
Для enterprise-систем с повышенными требованиями к изоляции мы добавляем отдельные роли PostgreSQL на каждую схему — тогда даже при SQL-инъекции атакующий не выйдет за пределы одной схемы.
Плюсы
- Физическая изоляция данных: таблицы разных тенантов не пересекаются.
- GDPR-удаление —
DROP SCHEMA tenant_X CASCADE. Одна команда, секунда. - Миграции не блокируют всех тенантов — можно катить по одной схеме.
- Бэкап/восстановление отдельного тенанта через
pg_dump -n. - Тот же пул соединений — экономия ресурсов.
Минусы
- Время старта приложения растёт с количеством тенантов (Flyway).
- Кросс-тенантные отчёты — боль. Нужен отдельный reporting-сервис.
- PostgreSQL имеет мягкий лимит в 10 000-15 000 таблиц. При 20 таблицах на тенанта это потолок в ~500-700 тенантов на одну базу.
- Мониторинг сложнее: нужно смотреть метрики по каждой схеме.
Когда использовать
Десятки-сотни тенантов среднего размера, где важна изоляция и простота удаления данных. Мы используем этот подход для B2B SaaS в корпоративном сегменте — когда клиент спрашивает «а где физически лежат мои данные?», ответ «в отдельной схеме» успокаивает юристов.
Модель 3: Database-per-tenant
Каждому тенанту — отдельная база данных. Максимальная изоляция. Максимальная операционная боль.
Routing DataSource
Здесь AbstractRoutingDataSource работает по назначению — маршрутизирует между разными DataSource:
@Component
class TenantRoutingDataSource(
private val tenantDataSourceRegistry: TenantDataSourceRegistry
) : AbstractRoutingDataSource() {
override fun determineCurrentLookupKey(): Any? {
return TenantContext.current()
}
@PostConstruct
fun init() {
val dataSources = tenantDataSourceRegistry.getAllDataSources()
setTargetDataSources(dataSources)
setDefaultTargetDataSource(dataSources["default"]!!)
afterPropertiesSet()
}
}
Управление пулом соединений
Каждая база — отдельный HikariCP пул. При 100 тенантах это 100 пулов. Если каждый пул имеет 10 соединений — 1000 соединений суммарно. PostgreSQL по умолчанию разрешает 100. Проблема.
@Component
class TenantDataSourceRegistry(
private val tenantRepository: TenantRepository,
private val hikariConfig: TenantHikariProperties
) {
private val dataSources = ConcurrentHashMap<String, DataSource>()
fun getDataSource(tenantId: String): DataSource {
return dataSources.computeIfAbsent(tenantId) { id ->
HikariDataSource(HikariConfig().apply {
jdbcUrl = "jdbc:postgresql://${hikariConfig.host}:5432/tenant_$id"
username = hikariConfig.username
password = hikariConfig.password
// Маленький пул — иначе кончатся соединения
maximumPoolSize = 5
minimumIdle = 1
idleTimeout = 300_000 // 5 минут
connectionTimeout = 3_000
poolName = "hikari-tenant-$id"
})
}
}
fun getAllDataSources(): Map<String, Any> {
return tenantRepository.findAllActive()
.associate { it.id to getDataSource(it.id) as Any }
}
// Удаление тенанта — закрываем пул
fun removeDataSource(tenantId: String) {
dataSources.remove(tenantId)?.let {
(it as HikariDataSource).close()
}
}
}
На практике при database-per-tenant почти всегда ставят PgBouncer перед PostgreSQL. Без него при 50+ тенантах соединения заканчиваются быстро.
Операционная сложность
Эта модель превращает одну задачу «задеплоить сервис» в десятки:
- Бэкапы: расписание для каждой базы (или скрипт, обходящий все).
- Мониторинг: алерты на размер, slow queries, репликацию — для каждой базы.
- Миграции: Flyway по каждой базе. При ошибке в одной — rolling back всех, или жить с рассинхроном.
- Failover: если один PostgreSQL-инстанс упал, какие тенанты пострадали?
Мы строим высоконагруженные системы и знаем цену операционной сложности. Database-per-tenant оправдан, но только при определённых условиях (о них ниже).
Плюсы
- Абсолютная изоляция. Один тенант не может повлиять на другого ни при каких обстоятельствах.
- Производительность тюнится индивидуально: для крупного тенанта — больше
shared_buffers, мощнее сервер. - GDPR:
DROP DATABASE. Проще некуда. - Можно разместить базы в разных регионах для соответствия data residency.
- Бэкап и восстановление одного тенанта — стандартный
pg_dump/pg_restore.
Минусы
- Стоимость инфраструктуры: каждая база = ресурсы.
- Connection pool explosion. PgBouncer обязателен.
- Деплой миграций — O(n) по количеству тенантов.
- Онбординг нового тенанта: создать базу, прогнать миграции, добавить в реестр, перезагрузить DataSource. Не секунды — минуты.
- При 500+ тенантах управление становится инженерной задачей само по себе.
Когда использовать
Единицы или десятки крупных тенантов с жёсткими требованиями к изоляции. Финтех, медицина, госсектор — там, где «данные тенанта A физически не должны находиться на том же сервере, что данные тенанта B». Или если один тенант генерирует в 100 раз больше нагрузки, чем остальные, и нуждается в индивидуальном тюнинге.
Сравнительная таблица
| Критерий | RLS (shared DB) | Schema-per-tenant | Database-per-tenant |
|---|---|---|---|
| Уровень изоляции | Логический (строки) | Физический (таблицы) | Полный (база) |
| Стоимость на тенанта | Минимальная | Низкая | Высокая |
| Операционная сложность | Низкая | Средняя | Высокая |
| Макс. тенантов (практика) | 10 000+ | 500-700 | 50-100 |
| GDPR-удаление | DELETE по таблицам | DROP SCHEMA | DROP DATABASE |
| Время онбординга | Секунды | Секунды-минуты | Минуты |
| Кросс-тенантная аналитика | Простая (один запрос) | Сложная (UNION ALL) | Очень сложная (ETL) |
| Индивидуальный тюнинг | Нет | Ограниченный | Полный |
| Data residency | Нет | Нет | Да |
| Риск cross-tenant утечки | Средний | Низкий | Минимальный |
Реализация: общие компоненты
Независимо от выбранной модели, три компонента одинаковы: TenantContext, TenantFilter и резолвер тенанта из запроса.
TenantContext: ThreadLocal и виртуальные потоки
Классический подход — ThreadLocal:
object TenantContext {
private val currentTenant = ThreadLocal<String?>()
fun set(tenantId: String) {
currentTenant.set(tenantId)
}
fun current(): String? = currentTenant.get()
fun clear() {
currentTenant.remove()
}
}
Это работает на обычных потоках. Но в Spring Boot 3.2+ с virtual threads (Project Loom) есть ловушка.
Virtual threads не наследуют ThreadLocal родительского потока по умолчанию. При @Async или реактивных цепочках тенант теряется. Мы столкнулись с этим на проекте платёжных интеграций — запрос начинался в одном потоке, обработка платежа продолжалась в другом, и TenantContext.current() возвращал null.
Решение для virtual threads — ScopedValue (preview в Java 21, стабильный в Java 25):
object TenantContext {
// Java 21+ preview API
val TENANT: ScopedValue<String> = ScopedValue.newInstance()
fun current(): String? = if (TENANT.isBound) TENANT.get() else null
}
// Использование
ScopedValue.where(TenantContext.TENANT, tenantId).run {
// Весь код внутри видит tenantId
// Включая код в дочерних virtual threads
orderService.processOrder(orderId)
}
Если вы ещё на Java 17 — оставайтесь на ThreadLocal, но никогда не используйте @Async без явной передачи тенанта. Или оберните TaskDecorator:
@Component
class TenantAwareTaskDecorator : TaskDecorator {
override fun decorate(runnable: Runnable): Runnable {
val tenantId = TenantContext.current()
return Runnable {
try {
tenantId?.let { TenantContext.set(it) }
runnable.run()
} finally {
TenantContext.clear()
}
}
}
}
TenantFilter: извлечение тенанта из запроса
Откуда берётся идентификатор тенанта? Три способа, в порядке популярности:
- HTTP-заголовок
X-Tenant-Id— для API-клиентов. - Поддомен —
acme.app.com→ тенантacme. - JWT-claim — тенант встроен в токен авторизации.
Фильтр, который поддерживает все три:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
class TenantFilter(
private val tenantRepository: TenantRepository
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val tenantId = resolveTenant(request)
if (tenantId == null) {
response.sendError(400, "Tenant not resolved")
return
}
if (!tenantRepository.existsById(tenantId)) {
response.sendError(404, "Tenant not found")
return
}
try {
TenantContext.set(tenantId)
filterChain.doFilter(request, response)
} finally {
TenantContext.clear() // Обязательно!
}
}
private fun resolveTenant(request: HttpServletRequest): String? {
// 1. Заголовок
request.getHeader("X-Tenant-Id")?.let { return it }
// 2. Поддомен
val host = request.serverName
if (host.contains(".")) {
val subdomain = host.substringBefore(".")
if (subdomain != "www" && subdomain != "api") {
return subdomain
}
}
// 3. JWT claim
val auth = SecurityContextHolder.getContext().authentication
if (auth is JwtAuthenticationToken) {
return auth.token.getClaimAsString("tenant_id")
}
return null
}
override fun shouldNotFilter(request: HttpServletRequest): Boolean {
// Публичные эндпоинты без тенанта
return request.requestURI.startsWith("/api/public/") ||
request.requestURI.startsWith("/actuator/")
}
}
Обратите внимание на shouldNotFilter. Каждый раз, когда вы добавляете новый публичный эндпоинт — обновляйте этот список. Мы забыли это в одном проекте: эндпоинт /api/health требовал тенанта, и мониторинг ложился каждые 30 секунд.
AbstractRoutingDataSource (для database-per-tenant)
@Configuration
class DataSourceConfig(
private val tenantDataSourceRegistry: TenantDataSourceRegistry
) {
@Bean
fun dataSource(): DataSource {
val routingDs = object : AbstractRoutingDataSource() {
override fun determineCurrentLookupKey(): Any? {
return TenantContext.current() ?: "default"
}
}
routingDs.setTargetDataSources(
tenantDataSourceRegistry.getAllDataSources()
)
routingDs.setDefaultTargetDataSource(
tenantDataSourceRegistry.getDataSource("default")
)
return routingDs
}
}
Стратегия миграций
Flyway + мультисхема
Для schema-per-tenant мы храним миграции в двух директориях:
src/main/resources/
db/
migration/
shared/ # Общие таблицы (tenants, billing, audit_log)
V001__init.sql
tenant/ # Таблицы тенанта (orders, products, users)
V001__init.sql
V002__add_status.sql
Миграция shared — стандартный Flyway при старте. Миграция tenant — цикл по всем схемам.
Для production-среды мы запускаем миграции не при старте приложения, а отдельным Job в Kubernetes:
@SpringBootApplication
class MigrationJob
fun main(args: Array<String>) {
val context = SpringApplication.run(MigrationJob::class.java, *args)
val migrator = context.getBean(MultiSchemaFlywayMigrator::class.java)
val results = migrator.migrateAll()
val failed = results.filter { it.success.not() }
if (failed.isNotEmpty()) {
System.err.println("Failed migrations: ${failed.map { it.tenantId }}")
exitProcess(1)
}
exitProcess(0)
}
Это отделяет миграции от деплоя приложения. Приложение стартует быстро. Миграция прогоняется в отдельном Pod, с таймаутами и ретраями.
Параллельные миграции
При 200+ схемах последовательная миграция занимает минуты. Корутины ускоряют в 5-8 раз:
suspend fun migrateAllParallel(): List<MigrationResult> = coroutineScope {
val tenants = tenantRepository.findAllActive()
tenants.map { tenant ->
async(Dispatchers.IO) {
try {
val flyway = Flyway.configure()
.dataSource(dataSource)
.schemas("tenant_${tenant.id}")
.locations("classpath:db/migration/tenant")
.load()
flyway.migrate()
MigrationResult(tenant.id, success = true)
} catch (e: Exception) {
logger.error("Migration failed for tenant ${tenant.id}", e)
MigrationResult(tenant.id, success = false, error = e.message)
}
}
}.awaitAll()
}
Ограничение: если миграция меняет тип колонки, PostgreSQL берёт ACCESS EXCLUSIVE lock. Параллельные миграции разных схем не конфликтуют — но если миграция shared-схемы бежит одновременно с обычным трафиком, готовьтесь к даунтайму.
Тестирование
TestContainers с изоляцией тенантов
Тесты мультитенантных приложений сложнее обычных. Нужно проверять не только «работает», но и «данные одного тенанта не утекают в другой».
@SpringBootTest
@Testcontainers
class MultiTenantIsolationTest {
companion object {
@Container
val postgres = PostgreSQLContainer("postgres:16-alpine")
.withDatabaseName("test_multitenant")
@DynamicPropertySource
@JvmStatic
fun configureProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
@Autowired
lateinit var orderRepository: OrderRepository
@Autowired
lateinit var tenantSetup: TestTenantSetup
@BeforeEach
fun setup() {
tenantSetup.createSchema("tenant_a")
tenantSetup.createSchema("tenant_b")
}
@Test
fun `tenant A cannot see tenant B orders`() {
// Arrange: создаём заказ от тенанта A
TenantContext.set("tenant_a")
orderRepository.save(Order(product = "Widget", amount = 100))
// Act: переключаемся на тенанта B
TenantContext.set("tenant_b")
val ordersForB = orderRepository.findAll()
// Assert: тенант B не видит заказов тенанта A
assertThat(ordersForB).isEmpty()
// Cleanup
TenantContext.clear()
}
@Test
fun `concurrent requests to different tenants are isolated`() {
val latch = CountDownLatch(2)
val errors = ConcurrentLinkedQueue<Throwable>()
// Два потока, два тенанта, одновременно
thread {
try {
TenantContext.set("tenant_a")
repeat(100) {
orderRepository.save(Order(product = "A-$it", amount = it))
}
val orders = orderRepository.findAll()
assertThat(orders).allMatch { it.product.startsWith("A-") }
} catch (e: Throwable) {
errors.add(e)
} finally {
TenantContext.clear()
latch.countDown()
}
}
thread {
try {
TenantContext.set("tenant_b")
repeat(100) {
orderRepository.save(Order(product = "B-$it", amount = it))
}
val orders = orderRepository.findAll()
assertThat(orders).allMatch { it.product.startsWith("B-") }
} catch (e: Throwable) {
errors.add(e)
} finally {
TenantContext.clear()
latch.countDown()
}
}
latch.await(10, TimeUnit.SECONDS)
assertThat(errors).isEmpty()
}
}
Ключевой тест — конкурентный. Если изоляция ломается под нагрузкой — это баг, который вы не поймаете юнит-тестом. Мы запускаем подобные тесты в CI на каждый PR, потому что кэширование и connection pooling вносят неочевидные side effects.
Частые ошибки
За четыре года работы с мультитенантной архитектурой мы собрали коллекцию багов. Каждый стоил нам от нескольких часов до нескольких дней отладки.
1. ThreadLocal + virtual threads = потерянный тенант
Описывал выше, но повторю — это ошибка номер один. Вы включаете spring.threads.virtual.enabled=true в Spring Boot 3.2, всё работает на dev-стенде, а в production под нагрузкой начинаются 403 и 500 с TenantNotResolvedException. Причина: virtual thread запускается на carrier thread, ThreadLocal не пробрасывается.
Решение: ScopedValue или TaskDecorator (описано выше). Или не включайте virtual threads, пока не проверите все пути выполнения.
2. Новый эндпоинт без TenantFilter
Разработчик добавил /api/v2/reports и забыл, что TenantFilter фильтрует по shouldNotFilter. Эндпоинт начал отдавать данные без тенантного контекста — то есть всех тенантов сразу (при RLS — нулевые результаты, при schema-per-tenant — ошибку relation does not exist).
Решение: в shouldNotFilter используйте whitelist, а не blacklist. Любой эндпоинт по умолчанию требует тенанта. Исключения — явно:
private val publicPaths = setOf("/api/public/", "/actuator/", "/api/auth/")
override fun shouldNotFilter(request: HttpServletRequest): Boolean {
return publicPaths.any { request.requestURI.startsWith(it) }
}
И интеграционный тест: «любой неизвестный эндпоинт без X-Tenant-Id возвращает 400».
3. Cross-tenant data leak через кэш
Spring Cache (@Cacheable) по умолчанию не знает о тенантах. Если тенант A загрузил справочник currencies, а тенант B запросил тот же ключ — получит кэш тенанта A.
Решение: tenant-aware CacheKeyGenerator:
@Component
class TenantAwareCacheKeyGenerator : KeyGenerator {
override fun generate(target: Any, method: Method, vararg params: Any?): Any {
val tenantId = TenantContext.current() ?: "global"
return "$tenantId:${method.name}:${params.contentDeepHashCode()}"
}
}
Или используйте tenant prefix в имени кэша. Мы подробно разбирали стратегии кэширования при высокой нагрузке — те же принципы применимы и здесь.
4. Scheduled-задачи без контекста тенанта
@Scheduled выполняется в потоке планировщика. TenantContext.current() — null. Отчёт по просроченным заказам не нашёл ни одного заказа — потому что RLS скрыл все строки.
Решение: scheduled-задача должна явно перебирать тенантов:
@Scheduled(cron = "0 0 3 * * *")
fun processOverdueOrders() {
val tenants = tenantRepository.findAllActive()
tenants.forEach { tenant ->
try {
TenantContext.set(tenant.id)
overdueOrderService.processForCurrentTenant()
} finally {
TenantContext.clear()
}
}
}
5. Аудит без tenant_id
Таблица audit_log — общая для всех тенантов (в shared-схеме). Но если забыли добавить tenant_id в лог — невозможно понять, кто из тенантов что делал. На одном проекте личного кабинета мы обнаружили это через три месяца после запуска — 2 миллиона записей без привязки к тенанту.
Дерево решений
Вместо абстрактных рассуждений — конкретный алгоритм выбора.
Вопрос 1: Сколько тенантов ожидается через 2 года?
- Более 500 → RLS (shared DB). Schema-per-tenant и database-per-tenant не масштабируются на тысячи.
- 50-500 → Schema-per-tenant. Золотая середина.
- Менее 50 → Любая модель. Можно даже database-per-tenant, если бюджет позволяет.
Вопрос 2: Есть ли регуляторные требования к физической изоляции?
- Да (ФЗ-152, GDPR data residency, PCI DSS) → Database-per-tenant. Или schema-per-tenant с отдельными ролями.
- Нет → RLS или schema-per-tenant.
Вопрос 3: Тенанты одинакового размера, или есть «слоны» и «мыши»?
- Все примерно равны → RLS или schema-per-tenant.
- Есть один тенант с 80% нагрузки → Database-per-tenant для крупного, RLS для остальных (гибридная модель).
Вопрос 4: Команда DevOps — сколько человек?
- 0-1 → RLS. Минимум операционной нагрузки.
- 2-3 → Schema-per-tenant.
- 4+ → Можно database-per-tenant, если предыдущие ответы тоже указывают на неё.
На практике мы чаще всего рекомендуем schema-per-tenant как стартовый вариант для SaaS-проектов. Он даёт хорошую изоляцию, простое удаление данных, адекватную стоимость — и позволяет позже мигрировать крупных тенантов на отдельные базы.
Вместо итога
Мультитенантная архитектура — не фреймворк и не библиотека. Это архитектурное решение, которое пронизывает весь стек: от фильтров в Spring Boot до политик в PostgreSQL, от стратегии миграций до тестов и мониторинга.
Три вещи, которые мы усвоили за десятки проектов:
- Выбирайте модель до первой строки кода. Миграция с RLS на schema-per-tenant — это переписывание половины data-слоя. Мы проходили через это. Не рекомендуем.
- Тестируйте изоляцию, а не только функциональность. Конкурентный тест «тенант A не видит данные тенанта B» должен быть в CI. Не в бэклоге «когда-нибудь напишем».
- TenantContext.clear() — священная обязанность. Забыли вызвать в
finally— получили утечку контекста. Один запрос, один баг, потенциально — инцидент с данными клиента.
Если вы строите SaaS-платформу и не уверены в выборе модели — мы помогаем с архитектурными решениями на ранних стадиях. Расскажите о проекте, и мы подберём подход под ваш масштаб и требования.