▶ Как разделить данные арендаторов в одном приложении? На примерах 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. В статье приведен только код, необходимый для объяснения процесса выполнения запросов с учетом арендатора при синхронных запросах. |
|---|
Арендаторы и три подхода к разделению данных
Мультитенантность — это архитектура, в которой несколько клиентов используют одну систему. Единица клиента, например компания или организация, которая в рамках сервиса управляется как независимо различимая сущность, называется тенантом.
Распространенные подходы к разделению данных арендаторов в реляционной базе данных — разделение на уровне строк, схем и баз данных. Здесь схема — это пространство имен внутри базы данных, которое объединяет такие объекты, как таблицы.
Рисунок 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.
Рисунок 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 выбирает используемую реализацию.
Рисунок 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, чтобы получить роль одного и того же актёра.
Рисунок 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 {}
Рисунок 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