Einführung und Anwendungsfälle

Multi-Tenancy ist eine Architektur, bei der eine einzige Instanz einer Softwareanwendung mehrere Kunden bedient. Jeder Kunde wird als Tenant bezeichnet. SaaS-Plattformen wie Shopify ermöglichen es jedem, in wenigen Stunden ein eigenes Dropshipping-Unternehmen aufzubauen – ohne Code, ohne Aufwand! Was diese SaaS-Plattformen ermöglicht, sind Multi-Tenant-Architekturen, die Datenkonsistenz über alle Tenants hinweg gewährleisten.

Es gibt 3 Möglichkeiten, dies umzusetzen, die alle in diesem Artikel beschrieben werden.

  • Datenbank pro Tenant: Jeder Tenant hat seine eigene Datenbank und ist von anderen Tenants isoliert.
  • Gemeinsame Datenbank, gemeinsames Schema: Alle Tenants teilen sich eine Datenbank und Tabellen. Jede Tabelle hat eine Spalte mit dem Tenant-Bezeichner, der den Eigentümer der Zeile angibt.
  • Gemeinsame Datenbank, separates Schema: Alle Tenants teilen sich eine Datenbank, haben jedoch ihre eigenen Datenbankschemata und Tabellen.
multi-tenant architecture models

Interessanterweise beschreibt Florian Weingarten, ehemaliger Engineering Lead und Manager bei Shopify, ihre Architektur in diesem Vortrag auf der SreCon16 als vom zweiten Typ. Es versteht sich von selbst, dass der Shared-Database-Ansatz eigene Probleme hat, Datenkonsistenz ist jedoch sicher keines davon. Man kann allen Tenants ohne Weiteres dieselbe Datenbankstruktur bereitstellen, da es nur ein Schema gibt.

Das Knifflige an einer gemeinsamen Datenbank ist der Umgang mit großen Datenmengen, die sich in den Tabellen ansammeln. Jede Tabelle trägt eine tenantId-Spalte, und alle Abfragen brauchen im Grunde eine WHERE-Klausel, die nach dieser ID filtert. Sehr große Tabellen können zu Leistungsproblemen führen. Daher scheint das Aufteilen der Daten auf verschiedene Datenbanken oder auf verschiedene Schemata innerhalb derselben Datenbank eine bessere Wahl zu sein. Da die Einrichtung einer separaten Datenbank für jeden Tenant mit zusätzlichem Aufwand verbunden ist, scheint der Multi-Schema-Ansatz den besten Kompromiss zu bieten.

Was passiert, wenn es separate Schemata gibt – eines für jeden Tenant?

Die Datenkonsistenz zu wahren und sicherzustellen, dass Migrationen keine unerwünschten Seiteneffekte verursachen, während gleichzeitig dynamisch neue Schemata entstehen (immer wenn sich ein neuer Tenant registriert), klingt nach viel Arbeit. Es ist aber eigentlich ganz einfach. So setzt ihr das in einer einfachen Spring-Boot-Anwendung um.

Spring-Boot-Anwendungsbeispiel

Konfiguration

Den Code dazu findet man in diesem Repository. Wir modellieren eine einfache Anwendung mit nur einer Entität namens Book.

Wir benötigen eine DB-Verbindung mit zwei Datenbanken:

  • metadata (einzelnes Schema, enthält Daten über Benutzer, Tenants und die Verknüpfungen zwischen ihnen)
  • tenant (mehrere Schemata, enthält Daten, die jeweils einem bestimmten Tenant gehören)

Die application.properties definiert diese beiden separaten Datenbanken und ihre Zugangsdaten.

Als Nächstes müssen wir Spring mitteilen, wo die Entitäten für jede dieser Datenbanken zu finden sind, um sie korrekt zuzuordnen. Das erfordert eine Paketstruktur, die die Entitäten (und/oder funktionalen Module) in zwei Bereiche aufteilt: meta und tenant.

Wir konfigurieren dies durch zwei separate Konfigurationsklassen, MetaDbConfig und TenantDbConfig.

java

@Configuration
@EnableJpaRepositories(
basePackages = "com.multitenant.multitenancy.meta",
entityManagerFactoryRef = "metaEntityManagerFactory",
transactionManagerRef = "metaTransactionManager")
public class MetaDbConfig {

@Primary
@Bean
@ConfigurationProperties("meta.datasource")
public DataSource metaDataSource() {
return DataSourceBuilder.create().build();
}

@Primary
@Bean
public LocalContainerEntityManagerFactoryBean metaEntityManagerFactory(
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(metaDataSource())
.packages("com.multitenant.multitenancy.meta")
.persistenceUnit("metaDB")
.build();
}

@Primary
@Bean
public PlatformTransactionManager metaTransactionManager(
@Qualifier("metaEntityManagerFactory") EntityManagerFactory userEntityManagerFactory) {
return new JpaTransactionManager(userEntityManagerFactory);
}
}

java

@Configuration
@EnableJpaRepositories(
repositoryFactoryBeanClass = QuerydslJpaRepositoryFactoryBean.class,
basePackages = "com.multitenant.multitenancy.tenant",
entityManagerFactoryRef = "tenantEntityManagerFactory",
transactionManagerRef = "tenantTransactionManager")
public class TenantDbConfig {

@Autowired
private JpaProperties jpaProperties;

@Bean
JpaVendorAdapter jpaVendorAdapter() {
return new HibernateJpaVendorAdapter();
}

@Bean
@ConfigurationProperties("tenant.datasource")
public DataSource tenantDataSource() {
return DataSourceBuilder.create().build();
}

@Bean
public LocalContainerEntityManagerFactoryBean tenantEntityManagerFactory(
MultiTenantConnectionProvider multiTenantConnectionProviderImpl,
CurrentTenantIdentifierResolver currentTenantIdentifierResolverImpl) {

Map<String, Object> jpaPropertiesMap = new HashMap<>(jpaProperties.getProperties());
jpaPropertiesMap.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
jpaPropertiesMap.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProviderImpl);
jpaPropertiesMap.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolverImpl);

LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
em.setDataSource(tenantDataSource());
em.setPackagesToScan("com.multitenant.multitenancy.tenant");
em.setJpaVendorAdapter(this.jpaVendorAdapter());
em.setJpaPropertyMap(jpaPropertiesMap);
em.setPersistenceUnitName("tenantDB");
return em;
}

@Bean
public PlatformTransactionManager tenantTransactionManager(
@Qualifier("tenantEntityManagerFactory") EntityManagerFactory tenantEntityManagerFactory) {
return new JpaTransactionManager(tenantEntityManagerFactory);
}
}

Beim TenantDbConfig fällt bereits etwas Interessantes in Bezug auf Multi-Tenancy auf. Diese drei Eigenschaften:

java

jpaPropertiesMap.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
jpaPropertiesMap.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProviderImpl);
jpaPropertiesMap.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolverImpl);

Sowohl MultiTenantConnectionProvider als auch CurrentTenantIdentifierResolver sind Interfaces, die implementiert werden müssen.

Das erste wird in TenantConnectionProvider implementiert, das die tenantDataSource in seinem Konstruktor injiziert bekommt und zwei wichtige Methoden hat:

java

  @Override
  public Connection getConnection(String tenantIdentifier) throws SQLException {
    final Connection connection = getAnyConnection();
    connection.setSchema(tenantIdentifier);
    return connection;
  }
  @Override
  public void releaseConnection(String tenantIdentifier, Connection connection)
      throws SQLException {
    connection.setSchema(DEFAULT_SCHEMA);
    releaseAnyConnection(connection);
  }

Hier wird jeder Hibernate-Verbindung ihr Schema zugewiesen.

Als Nächstes gibt es CurrentTenantIdentifierResolver, das in TenantSchemaResolver implementiert wird:

java

@Component
public class TenantSchemaResolver implements CurrentTenantIdentifierResolver {
  @Override
  public String resolveCurrentTenantIdentifier() {
    String tenantUUID = TenantContext.getCurrentTenant();
    return tenantUUID != null ? tenantUUID : DEFAULT_SCHEMA;
  }
  @Override
  public boolean validateExistingCurrentSessions() {
    return true;
  }
}

Wir sehen eine TenantContext-Klasse, die es uns ermöglicht, den aktuellen Tenant/das aktuelle Schema von überall in der Anwendung zu setzen (zu beachten ist, dass dabei sichergestellt sein muss, dass die aktuelle Transaktion nicht in einem anderen Schema geöffnet ist – andernfalls müsste man manuell eine neue Transaktion erstellen).

Es gibt einen Filter (AuthTokenFilter), der Anfragen abfängt, den Schemanamen aus den Headern entnimmt und über diese TenantContext-Klasse das aktuelle Schema für alle nachfolgenden Anfragen setzt.

Flyway-Migrationen und der Tenant-Pool

Was ist mit Datenbankmigrationen? Und wann werden neue Tenants/Schemata erstellt?

Schemata werden normalerweise erstellt, wenn sich ein neuer Benutzer registriert. Die Schema-Erstellung kann jedoch rechenintensiv sein, und wir möchten neuen Benutzern einen reibungslosen Einstieg in die Anwendung bei der Registrierung bieten. Es wäre großartig, wenn wir Schemata schon im Voraus vorbereiten und sie neu registrierten Benutzern einfach zuweisen könnten.

Introducing the concept of TenantPool (or, schema pool)

Ein Tenant-Pool ist nichts weiter als ein Pool aus unbenutzten, aber bereits erstellten Schemata, die darauf warten, neu registrierten Benutzern zugewiesen zu werden. Ein geplanter Job, der so oft wie gewünscht ausgeführt wird, füllt diesen Tenant-Pool auf.

java

  @Scheduled(cron = "0 * * * * *")
  public void execute() {
    log.info("TenantPoolJob");
    tenantPoolService.fillUpTenantPool();
    log.info("TenantPoolJob finished");
  }

Schließlich führen wir hier Flyway ein, unser bevorzugtes Tool für Datenbankmigrationen. Es ist leichtgewichtig, einfach konfigurierbar und lässt sich programmatisch verwenden – was in unserem Kontext mit dynamischen Schemata genau das ist, was wir benötigen.

Der TenantPoolService löst unseren FlywayService aus, der zwei Methoden hat: initNewTenantSchema und initMetadataSchema:

java

  public void initNewTenantSchema(String schema) {
    Flyway tenantDbMigration =
        Flyway.configure()
            .dataSource(tenantDbUrl, tenantDbUsername, tenantDbPassword)
            .locations("classpath:migrations/tenant")
            .target(LATEST)
            .baselineOnMigrate(true)
            .schemas(schema)
            .load();
    tenantDbMigration.migrate();
  }
  public void initMetadataSchema() {
    Flyway tenantDbMigration =
        Flyway.configure()
            .dataSource(metaDbUrl, metaDbUsername, metaDbPassword)
            .locations("classpath:migrations/metadata")
            .target(LATEST)
            .baselineOnMigrate(true)
            .schemas(METADATA_SCHEMA_NAME)
            .load();
    tenantDbMigration.migrate();
  }

Es wird jedoch nur initNewTenantSchema aufgerufen, da das Metadaten-Schema hoffentlich bereits erstellt wurde.

Dieselben Methoden werden beim ersten Start der Anwendung verwendet:

java

@SpringBootApplication
@RequiredArgsConstructor
public class InitRunner {
  private final FlywayService flywayService;
  private final ApplicationContext context;
  public static void main(String[] args) {
    new SpringApplicationBuilder(InitRunner.class).web(WebApplicationType.NONE).run(args);
  }
  @PostConstruct
  public void run() {
    flywayService.initMetadataSchema();
    flywayService.initNewTenantSchema("public");
    System.exit(SpringApplication.exit(context, () -> 0));
  }
}

Als Letztes auf unserer Liste müssen wir sicherstellen, dass bei der Einführung einer neuen Migration alle bestehenden Schemata die Änderungen erhalten.

Eine FlywayConfig-Datei wird zu diesem Zweck verwendet, mit einer einzigen Methode namens migrateFlyway():

java

  @PostConstruct
  public void migrateFlyway() {
    final Set<String> schemas = tenantService.getSchemas();
    schemas.forEach(
        tenant -> {
          Flyway tenantDbMigration =
              Flyway.configure()
                  .dataSource(tenantDbUrl, tenantDbUsername, tenantDbPassword)
                  .locations("classpath:migrations/tenant")
                  .target(LATEST)
                  .baselineOnMigrate(true)
                  .defaultSchema(tenant)
                  .load();
          tenantDbMigration.migrate();
        });
    Flyway metadataDbMigration =
        Flyway.configure()
            .dataSource(metadataDbUrl, metadataDbUsername, metadataDbPassword)
            .locations("classpath:migrations/metadata")
            .baselineOnMigrate(true)
            .target(LATEST)
            .load();
    metadataDbMigration.migrate();
  }

Wir holen alle vorhandenen Tenants aus dem TenantService und wenden neue Migrationen auf alle an. Das geschieht automatisch beim Start der Anwendung, was jedoch den Nachteil hat, dass der Anwendungsstart bei vielen Tenants recht lange dauern kann.

Um sicherzustellen, dass alle Schemata alle Migrationen in ihrer flyway_database_history-Tabelle enthalten, muss eine Baseline-Migrationsdatei in den jeweiligen Migrationsordnern für die tenant– und die metadata-Datenbank bereitgestellt werden.

Der gesamte Code befindet sich erneut in diesem Repository. Wir hoffen, dass euch diese kleine Einführung in die Arbeit mit Multi-Tenant-Architekturen und Datenbankmigrationen in Spring Boot gefallen hat!