Новаком
SAAS

Мультитенантная архитектура на Spring Boot: 3 модели изоляции данных

Мультитенантность в SaaS: schema-per-tenant, row-level security, database-per-tenant. Реализация на Spring Boot 3 + PostgreSQL. Код, миграции, тесты, подводные камни.

Н
Новаком
2026-05-24 · 14 минут чтения

Содержание


Что такое мультитенантность

Мультитенантная архитектура — это когда один экземпляр приложения обслуживает несколько клиентов-арендаторов (тенантов), а данные каждого тенанта изолированы друг от друга. Один деплой. Одна кодовая база. Десятки или тысячи клиентов.

Почему не сделать каждому клиенту отдельный инстанс? Можно. Мы видели такой подход у логистической платформы — 40 инстансов, 40 CI/CD-пайплайнов, 40 наборов мониторинга. Стоимость инфраструктуры была выше, чем выручка от половины клиентов.

Мультитенантность решает три задачи:

  1. Экономия инфраструктуры. Один кластер PostgreSQL вместо сорока. Один Kubernetes namespace вместо сорока.
  2. Единая кодовая база. Фикс бага — один деплой. Не сорок.
  3. Онбординг за минуты. Новый клиент — запись в таблице или новая схема. Не 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-tenantDatabase-per-tenant
Уровень изоляцииЛогический (строки)Физический (таблицы)Полный (база)
Стоимость на тенантаМинимальнаяНизкаяВысокая
Операционная сложностьНизкаяСредняяВысокая
Макс. тенантов (практика)10 000+500-70050-100
GDPR-удалениеDELETE по таблицамDROP SCHEMADROP 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: извлечение тенанта из запроса

Откуда берётся идентификатор тенанта? Три способа, в порядке популярности:

  1. HTTP-заголовок X-Tenant-Id — для API-клиентов.
  2. Поддоменacme.app.com → тенант acme.
  3. 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, от стратегии миграций до тестов и мониторинга.

Три вещи, которые мы усвоили за десятки проектов:

  1. Выбирайте модель до первой строки кода. Миграция с RLS на schema-per-tenant — это переписывание половины data-слоя. Мы проходили через это. Не рекомендуем.
  2. Тестируйте изоляцию, а не только функциональность. Конкурентный тест «тенант A не видит данные тенанта B» должен быть в CI. Не в бэклоге «когда-нибудь напишем».
  3. TenantContext.clear() — священная обязанность. Забыли вызвать в finally — получили утечку контекста. Один запрос, один баг, потенциально — инцидент с данными клиента.

Если вы строите SaaS-платформу и не уверены в выборе модели — мы помогаем с архитектурными решениями на ранних стадиях. Расскажите о проекте, и мы подберём подход под ваш масштаб и требования.

РАЗРАБОТКА

Нужна похожая задача?

Обсудим вашу задачу и предложим решение за 30 минут.

Обсудить проект