Начало работы с мультитенантностью баз данных с помощью Spring Boot и JPA

Начало работы с мультитенантностью баз данных с помощью Spring Boot и JPA

▶ Как разделить данные арендаторов в одном приложении? На примерах Spring Boot и JPA мы рассмотрим принципы разделения на уровне строк, схем и баз данных, а также процесс выполнения запросов с учетом конкретного арендатора.

При управлении данными нескольких клиентов (арендаторов) в одной таблице условия для арендатора многократно появляются в методах Repository.

interface OrderRepository extends JpaRepository<Order, Long> {
    Optional<Order> findByOrderIdAndTenantId(String orderId, String tenantId);
    List<Order> findAllByStatusAndTenantId(String status, String tenantId);
    long countByStatusAndTenantId(String status, String tenantId);
}

Проблема не только в дублировании. Если не указать условие для арендатора, можно получить данные другого клиента. В запросе на обновление или удаление также можно изменить или удалить данные другого клиента.

В этой статье мы рассмотрим простые примеры того, как автоматически применять условия для арендатора с помощью Hibernate — реализации JPA, — а также как выбирать целевое подключение, разделяя схемы или базы данных.

Пример окружения

Java 25 · Spring Boot 4.1.1 · Hibernate ORM 7.4.5.Final · PostgreSQL 17

Для таких зависимостей, как Spring Data JPA, используются версии, которыми управляет Spring Boot.

В статье приведен только код, необходимый для объяснения процесса выполнения запросов с учетом арендатора при синхронных запросах.

Арендаторы и три подхода к разделению данных

Мультитенантность — это архитектура, в которой несколько клиентов используют одну систему. Единица клиента, например компания или организация, которая в рамках сервиса управляется как независимо различимая сущность, называется тенантом.

Распространенные подходы к разделению данных арендаторов в реляционной базе данных — разделение на уровне строк, схем и баз данных. Здесь схема — это пространство имен внутри базы данных, которое объединяет такие объекты, как таблицы.

image1.png

Рисунок 1. Данные арендаторов, разделенные на уровне строк, схем и баз данных

Подход

Организация данных

Где определяется область действия запроса

Разделение на уровне строк (столбец-дискриминатор)

Данные нескольких арендаторов хранятся в одной таблице

Условие для арендатора в SQL (tenant_id)

Разделение на уровне схем (схема для каждого арендатора)

Для каждого арендатора в одной базе данных создаются таблицы в отдельной схеме

Схема, используемая для подключения

Разделение на уровне баз данных (база данных для каждого арендатора)

Для каждого арендатора создаются таблицы в отдельной базе данных

DataSource, из которого получают подключение

Разделение на уровне строк: сокращение повторяющихся условий с помощью @TenantId

При разделении на уровне строк условия для арендатора можно применять автоматически с помощью @TenantId в Hibernate или политик безопасности на уровне строк (RLS) базы данных.

В этой статье используется @TenantId из Hibernate. Эта аннотация помечает поле сущности, в котором хранится идентификатор арендатора; мы добавим ее к полю tenantId сущности Order.

@Entity
@Table(name = "orders", uniqueConstraints =
        @UniqueConstraint(columnNames = {"tenant_id", "order_id"}))
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // 테이블의 기본 키

    @TenantId
    @Column(name = "tenant_id", nullable = false, updatable = false)
    private String tenantId;

    @Column(name = "order_id", nullable = false)
    private String orderId; // 서로 다른 테넌트에서 같은 값 사용 가능

    @Column(name = "status", nullable = false)
    private String status;
}

Hibernate использует идентификатор арендатора из сессии в условиях запросов, а при сохранении новой сущности также записывает этот идентификатор в поле tenantId.

Поэтому передавать идентификатор арендатора непосредственно в качестве аргумента методов Repository не требуется.

interface OrderRepository extends JpaRepository<Order, Long> {
    Optional<Order> findByOrderId(String orderId);
    List<Order> findAllByStatus(String status);
    long countByStatus(String status);
}

При вызове findByOrderId() SQL, сгенерированный Hibernate, содержит условия как для order_id, так и для tenant_id.

select ...
from orders
where tenant_id = ? and order_id = ?

Условия для арендатора в нативном SQL

Условия для арендатора, заданные с помощью @TenantId, не применяются автоматически к нативному SQL, выполняемому, например, с помощью @Query(nativeQuery = true).

Передача арендатора из запроса в сессию Hibernate

Hibernate выполняет операции через Session, используя JPA EntityManager. При открытии сессии ему передается идентификатор арендатора, который используется для получения или сохранения данных этого арендатора.

Для всех трех подходов к разделению данных процесс передачи идентификатора арендатора в сессию одинаков.

요청에서 테넌트 ID 식별 → 요청 처리 범위에 바인딩
→ 세션에서 테넌트 ID 확인 → 테넌트 데이터 조회

При открытии сессии

Spring Boot по умолчанию включает параметр OSIV, оставляя EntityManager открытым на протяжении всего запроса. В этом примере мы отключаем этот параметр и согласуем жизненный цикл сессии с транзакцией сервиса. Если метод с @Transactional вызывается без существующей транзакции, открывается новая сессия, поэтому идентификатор арендатора нужно привязать до вызова метода.

# application.yaml
spring:
  jpa:
    open-in-view: false  # 요청 전체에 세션을 유지하지 않음

Привязка идентификатора арендатора на время обработки запроса

Если привязать идентификатор арендатора, полученный из запроса, код Service или Repository, вызванный в этой области действия, сможет получить то же значение, поэтому передавать идентификатор арендатора в качестве аргумента не нужно.

Такие значения часто хранят в ThreadLocal, но в этом примере используется ScopedValue, ставший постоянной возможностью Java 25. Привязанное значение в ScopedValue нельзя изменить в пределах его области действия, а после ее завершения привязка удаляется автоматически. Это хорошо подходит для нашего примера: код считывает идентификатор арендатора, установленный фильтром, но не изменяет его. Различия между двумя API отдельно описаны в приложении.

public final class TenantContext {
    private static final ScopedValue<String> CURRENT = ScopedValue.newInstance();

    public static <T> T call(String tenant, Supplier<T> work) {
        return ScopedValue.where(CURRENT, tenant).call(work::get);
    }

    public static <T, X extends Throwable> T callChecked(
        String tenant, ScopedValue.CallableOp<T, X> work
    ) throws X {
        return ScopedValue.where(CURRENT, tenant).call(work);
    }

    public static String required() {
        return CURRENT.orElseThrow(() -> new IllegalStateException("No tenant is bound"));
    }
}

call() и callChecked() выполняют работу в области действия, где привязан идентификатор арендатора, а required() считывает идентификатор арендатора из этой области.

TenantFilter привязывает идентификатор арендатора, полученный из заголовка, и запускает цепочку фильтров в пределах этой области действия.

public class TenantFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        // 헤더 검증과 필터 체인의 예외 처리는 생략
        String tenant = request.getHeader("X-Tenant-Id");

        TenantContext.callChecked(tenant, () -> {
            chain.doFilter(request, response);
            return null;
        });
    }
}

Определение арендатора в примере

Чтобы упростить отслеживание процесса выполнения запроса, в примере идентификатор арендатора считывается непосредственно из заголовка X-Tenant-Id. Проектирование способа определения арендатора выходит за рамки этой статьи.

Передача идентификатора арендатора с помощью TenantResolver

Hibernate получает идентификатор арендатора для сессии через CurrentTenantIdentifierResolver. TenantResolver из примера считывает идентификатор из TenantContext, проверяет, что это арендатор, зарегистрированный в конфигурации, а затем возвращает его.

public class TenantResolver implements CurrentTenantIdentifierResolver<String> {
    private final TenantProperties tenants;  // 등록된 테넌트 설정

    @Override
    public String resolveCurrentTenantIdentifier() {
        String tenant = TenantContext.required();
        if (!tenants.knows(tenant)) {
            throw new IllegalArgumentException("Unknown tenant: " + tenant);
        }
        return tenant;
    }
}

Зарегистрируйте TenantResolver в Hibernate с помощью HibernatePropertiesCustomizer из Spring Boot.

@Configuration
public class TenantConfiguration {
    @Bean
    HibernatePropertiesCustomizer tenantIdentity(TenantResolver resolver) {
        return properties -> properties.put(
                MultiTenancySettings.MULTI_TENANT_IDENTIFIER_RESOLVER,
                resolver
        );
    }
}

Сессия сохраняет идентификатор арендатора, возвращенный TenantResolver. При разделении на уровне строк этот идентификатор используется в условиях SQL; при разделении на уровне схем и баз данных — для выбора схемы или DataSource для подключения.

Проверка результатов запросов на уровне строк

В предыдущем примере с заказами мы использовали книжный магазин (bookshop) и магазин пластинок (recordshop) как отдельных арендаторов и подготовили заказы с одинаковым идентификатором, но разными статусами. Мы запрашиваем один и тот же заказ, меняя только заголовок X-Tenant-Id.

image2.png

Рисунок 2. Результаты запроса заказа для разных арендаторов

Даже при запросе с одним и тем же идентификатором заказа возвращается только заказ, принадлежащий арендатору, указанному в заголовке.

Общий пример разделения схем и баз данных

Даже с @TenantId данные нескольких арендаторов хранятся в одной таблице. В этот раз мы сравним разделение схем, при котором у каждого арендатора есть собственные таблицы, с разделением баз данных.

Один актёр, разные роли

Райан Рейнольдс играет Дэдпула в Marvel и Зелёного Фонаря в DC. В этом примере Marvel и DC — арендаторы, и мы храним для каждого арендатора по одной роли на актёра. В качестве первичного ключа таблицы используется идентификатор актёра actorSlug.

Вселенная (арендатор)

Идентификатор актёра (actorSlug)

Роль (characterName)

Marvel (marvel)

ryan-reynolds

Дэдпул

DC (dc)

ryan-reynolds

Зелёный Фонарь

При разделении схем и баз данных используются один и тот же код сущности, Repository и сервиса.

// Casting.java
@Entity
@Table(name = "casting")
public class Casting {
    @Id
    @Column(name = "actor_slug")
    private String actorSlug;
    @Column(name = "character_name")
    private String characterName;
}

// CastingRepository.java
public interface CastingRepository extends JpaRepository<Casting, String> {}
@Service
public class CastingService {
    private final CastingRepository castingRepository;

    @Transactional(readOnly = true)
    public Casting findByActor(String actorSlug) {
        return castingRepository.findById(actorSlug)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }
}

Код запроса не передаёт идентификатор арендатора и не указывает условие для арендатора. Различие между этими подходами не в бизнес-коде, а в способе выбора соответствующей схемы или DataSource для каждого арендатора.

В этом примере профили схемы и базы данных загружают соответствующие файлы конфигурации, а указанное в каждом файле значение demo.strategy выбирает используемую реализацию.

image3.png

Рисунок 3. Поток обработки запроса: от определения арендатора до выбора схемы или базы данных

Разделение схем: выбор схемы в одной базе данных

При разделении схем один DataSource подключается к общей БД, а используемая схема переключается в зависимости от арендатора.

Укажите в файле конфигурации имя схемы для каждого идентификатора арендатора.

# application-schema.yaml
demo:
  strategy: SCHEMA
  schema:
    url: jdbc:postgresql://localhost:55432/shared
    tenants:  # 테넌트 ID → 스키마 이름
      marvel: marvel_schema
      dc: dc_schema

TenantSchemaMapper возвращает имя схемы, соответствующее идентификатору арендатора в сессии. В этом примере имя схемы ищется в demo.schema.tenants.

@Configuration
public class TenantConfiguration {
    @Configuration
    @ConditionalOnProperty(
            prefix = "demo", name = "strategy", havingValue = "SCHEMA")
    static class Schema {
        @Bean
        TenantSchemaMapper<String> schemaMapper(TenantProperties properties) {
            return tenant -> {
                String schema = properties.schema().tenants().get(tenant);
                if (schema == null) {
                    throw new IllegalArgumentException("Unknown tenant: " + tenant);
                }
                return schema;
            };
        }

        @Bean
        HibernatePropertiesCustomizer schemaRouting(TenantSchemaMapper<String> mapper) {
            return properties -> properties.put(
                    MultiTenancySettings.MULTI_TENANT_SCHEMA_MAPPER, mapper);
        }
    }
}

Поддерживаемые версии TenantSchemaMapper

API TenantSchemaMapper появился в Hibernate 7.1 и помечен аннотацией @Incubating, поэтому в будущем он может измениться.

При получении соединения Hibernate передаёт идентификатор арендатора из сессии в mapper и вызывает Connection.setSchema() с возвращённым именем схемы.

Поэтому даже при выполнении одного и того же кода запроса сессия Marvel читает данные из marvel_schema.casting, а сессия DC — из dc_schema.casting.

Разделение баз данных: выбор DataSource для каждого арендатора

При разделении баз данных для подключения к базе данных каждого арендатора выбирается DataSource. В этом примере при запуске приложения для каждого арендатора создаётся пул соединений с использованием параметров подключения из demo.database.datasources.

# application-database.yaml
demo:
  strategy: DATABASE
  database:
    metadata-url: jdbc:postgresql://localhost:55432/metadata_db
    datasources:
      marvel:
        url: jdbc:postgresql://localhost:55432/marvel_db
      dc:
        url: jdbc:postgresql://localhost:55432/dc_db

Когда требуется соединение, Hibernate передаёт идентификатор арендатора из сессии в MultiTenantConnectionProvider. В этом примере Connections.Provider, реализующий этот интерфейс, выбирает DataSource, соответствующий идентификатору.

// 커넥션 획득·반납은 Hibernate의 기본 구현에 맡긴다.
public static final class Provider
        extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl<String>
        implements AutoCloseable {
    private final Map<String, HikariDataSource> dataSources;
    private final DataSource metadata;  // 메타데이터 조회용 DB의 DataSource

    @Override
    protected DataSource selectDataSource(String tenant) {
        DataSource source = dataSources.get(tenant);
        if (source == null) {
            throw new IllegalArgumentException("Unknown tenant: " + tenant);
        }
        return source;
    }

    @Override
    protected DataSource selectAnyDataSource() {
        return metadata;
    }

    @Override
    public void close() {
        dataSources.values().forEach(HikariDataSource::close);
    }
}

@Configuration
public class TenantConfiguration {
    @Configuration
    @ConditionalOnProperty(
            prefix = "demo", name = "strategy", havingValue = "DATABASE")
    static class Database {
        @Bean
        Connections.Provider connectionProvider(
                TenantProperties properties, DataSource metadata) {
            return new Connections.Provider(properties, metadata);
        }

        @Bean
        HibernatePropertiesCustomizer databaseRouting(
                MultiTenantConnectionProvider<String> provider) {
            return properties -> properties.put(
                    MultiTenancySettings.MULTI_TENANT_CONNECTION_PROVIDER, provider);
        }
    }
}

selectDataSource() выбирает DataSource по идентификатору, переданному сессией, не обращаясь повторно к контексту арендатора. Даже при вызове одного и того же findById("ryan-reynolds") сессия Marvel получает роль из marvel_db, а сессия DC — из dc_db.

При запросе метаданных JDBC без идентификатора арендатора используется DataSource, возвращаемый selectAnyDataSource(). В этом примере для этой цели используется metadata_db, в которой нет таблиц с данными арендаторов.

Проверка результатов запросов при разделении схем и баз данных

Укажите арендатора в заголовке запроса

Меняйте заголовок X-Tenant-Id между marvel и dc, чтобы получить роль одного и того же актёра.

image4.png

Рисунок 4. Запрос ролей по арендаторам с профилем схемы

Тестирование запросов с обоими подходами

Тесты проверяют, что при разделении схем и баз данных возвращается роль соответствующего арендатора. Общие части — например, подготовка данных, выполнение запроса и проверка результатов — вынесены в абстрактный класс, а два подкласса отличаются только указанным профилем.

Поскольку весь тест не обёрнут в транзакцию, каждый вызов сервиса выполняется в отдельной сессии.

@SpringBootTest
abstract class TenantApiTest {
    @Autowired CastingService castingService;
    @Autowired CastingRepository castingRepository;

    @Test
    void sameActorReturnsDifferentCharacters() {
        // Given
        TenantContext.call("marvel", () ->
                castingRepository.save(new Casting("ryan-reynolds", "Deadpool")));
        TenantContext.call("dc", () ->
                castingRepository.save(new Casting("ryan-reynolds", "Green Lantern")));

        // When
        var marvelCasting = TenantContext.call("marvel", () ->
                castingService.findByActor("ryan-reynolds"));
        var dcCasting = TenantContext.call("dc", () ->
                castingService.findByActor("ryan-reynolds"));

        // Then
        assertThat(marvelCasting.getCharacterName()).isEqualTo("Deadpool");
        assertThat(dcCasting.getCharacterName()).isEqualTo("Green Lantern");
    }
}

@ActiveProfiles("schema")
class SchemaModeTest extends TenantApiTest {}

@ActiveProfiles("database")
class DatabaseModeTest extends TenantApiTest {}

image5.png

Рисунок 5. Запуск общего теста запросов с обоими профилями

Переключение арендаторов в новой сессии

На этот раз мы получим роли Marvel и DC для одного и того же актёра вместе, в рамках одного метода.

// CastingComparison.java
@Transactional(readOnly = true)
public Map<String, String> charactersByTenant(String actorSlug) {
    var result = new LinkedHashMap<String, String>();

    for (var tenant : List.of("marvel", "dc")) {
        String character = TenantContext.call(tenant, () ->
                castingService.findCharacterName(actorSlug));
        result.put(tenant, character);
    }

    return result;
}

Вызовите этот метод в области, где задан арендатора marvel.

var characters = TenantContext.call("marvel", () ->
        castingComparison.charactersByTenant("ryan-reynolds"));

// 기대 결과: {marvel=Deadpool, dc=Green Lantern}
// 실제 결과: {marvel=Deadpool, dc=Deadpool}

Несмотря на то что в цикле идентификатор арендатора задаётся как dc, возвращается Дэдпул. При запуске транзакции внешнего метода арендатор сессии устанавливается в marvel. Внутренний вызов сервиса также присоединяется к существующей транзакции, поскольку по умолчанию используется параметр распространения REQUIRED, и поэтому применяется та же сессия.

Чтобы запросить данные другого арендатора, задайте нужный идентификатор арендатора, а затем откройте новую сессию. В этом примере при отключённом OSIV удалите @Transactional у внешнего метода и вызовите его без внешней транзакции. При каждом вызове внутреннего сервиса открываются новая транзакция и новая сессия, поэтому запрос для dc возвращает Зелёного Фонаря.

Когда OSIV включён, одна и та же сессия сохраняется на протяжении всего запроса, поэтому одно лишь удаление @Transactional у внешнего метода не меняет данные, которые будут запрошены.

Область действия и ограничения примера

В примере пропущен этап проверки того, имеет ли пользователь право доступа к запрошенному арендатору. Область действия схем и таблиц, доступных учётной записи БД, также следует определить с учётом особенностей реального сервиса.

При использовании пула соединений для каждого арендатора необходимо управлять созданием и закрытием пулов, а также общим количеством соединений. Учитывайте число арендаторов и экземпляров приложения, а также размер пула. Если арендаторы используют один и тот же экземпляр БД, учитывайте и то, как нагрузка от одного арендатора влияет на остальных.

При разделении на схемы или базы данных нужен процесс подготовки базы данных или схемы для нового арендатора и регистрации её в приложении. Также необходимо определить порядок применения изменений структуры таблиц для каждого арендатора, способ восстановления после сбоев и единицу резервного копирования и восстановления.

Заключение

В примере Order аннотация @TenantId избавляет от необходимости каждый раз передавать аргументы арендатора в Repository. В примере Casting мы реализовали оба подхода с помощью одной и той же сущности, Repository и сервиса, изменив только код, который выбирает схему или DataSource.

Выбор подхода к разделению следует делать с учётом совокупности требований к сервису, например разрешений доступа для конкретных арендаторов, количества соединений и единицы резервного копирования и восстановления.

Приложение: ThreadLocal и ScopedValue

Категория

ThreadLocal

ScopedValue

Время жизни значения

Сохраняется, пока его явно не удалят или пока не завершится поток

Сохраняется, пока не завершится заданная область выполнения

Очистка при выходе

Обычно вызывается remove() в блоке finally

Привязка автоматически удаляется при штатном завершении или выходе из-за исключения

Обработка вложенных областей

Предыдущее значение нужно сохранить и восстановить вручную

После завершения внутренней области восстанавливается предыдущая привязка

Процесс перехода с ThreadLocal на ScopedValue и применения его в фильтре сервлетов рассматривается в видеокурсе Nextree по рефакторингу передачи контекста.

Ссылки

1. Hibernate ORM, Краткое руководство по Hibernate 7: мультитенантность.

2. PostgreSQL 17, Политики безопасности на уровне строк, Схемы.

3. Hibernate ORM, TenantId.

4. Spring Boot, Базы данных SQL; Spring Framework, Распространение транзакций.

5. Oracle, ScopedValue в Java 25; OpenJDK, JEP 506: значения с ограниченной областью действия (Scoped Values).

6. Microsoft Learn, Модели мультитенантности баз данных для SaaS.

7. Hibernate ORM, TenantSchemaMapper, MultiTenancySettings.

Taez

Site footer