介绍
多租户(Multi-tenancy)是一种软件架构,一个服务实例可以服务多个客户,每个客户叫一个租户。而这其中最关键的一部分就是各个租户的数据的分离。
针对这种情形,主要有三种策略,数据的隔离级别从高到低依次是:Database per Tenant
, Shared Database, Separate Schema
, Shared Database, Shared Schema
:
Database per Tenant
: 每一个tenant有它自己的数据库实例,并且是和其他tenant的数据库隔离的。
Shared Database, Separate Schema
: 所有的tenant共享一个数据库,但是每个tenant被schema隔离,有自己的专属schema。因为在Mysql中database等同于schema,所以即为一个数据库实例不同的数据库。
Shared Database, Shared Schema
: 所有的tennat共享数据库和表,但是每个表以一个列区分不同tenant的数据,比如company_id
, origanization_id
等。
一般情况下,实现多租户可以通过Spring Boot的方式或者Hibernate的方式来实现,因为我们项目是基于Hibernate,所以这里要介绍的也是通过Hibernate的方式。
请求流程
通常情况下,实现多租户,连接当前租户的数据库由以下几个步骤组成:
- 拦截请求,检查用户是否登录,如果没有重定向用户到登录页。
- 根据请求中的信息识别用户属于哪个tenant。识别用户所属tenant是基于默认的database或schema的,它有需要使用到的数据, 比如说当前用户的
company_id
对应的database或schema的数据或信息。 - 和请求用户所属的tenant的数据库或schema建立连接。
登录
第一步验证登录其实就是普通的执行验证的流程,比如Spring security或者Shiro等框架提供的登录功能,即只要保证用户在进入下一步之前是已登录状态就行。如果是可以匿名访问的url当然也是另当别论。
找到用户所属tenant
识别用户所属tenant可以通过Spring的拦截器来实现,根据请求头所带的信息来得到所属的tenant,然后存在一个ThreadLocal
变量中。这样在接下来的其他处理中可以拿到当前的tenant。在请求结束后把ThreadLocal
中的内容清除。
首先创建一个类来存储当前的Tenant。静态类变量currentTenant
存储的就是当前tenant的标识符。InheritableThreadLocal使得当前线程创建的子线程也可以继承这个tenant的值。
class TenantContext {
private TenantContext() {
}
private static final ThreadLocal<String> currentTenant = new InheritableThreadLocal<>();
static String getCurrentTenant() {
return currentTenant.get();
}
static void setCurrentTenant(final String tenant) {
currentTenant.set(tenant);
}
static void clear() {
currentTenant.remove();
}
}
然后是Spring拦截器,用来拦截请求,并找到当前用户所属的tenant。我这里是找的company_id
,因为我这里默认数据库里有一张表存储了company_id和tenant所属数据库的对应关系。因此根据company_id能够找到tenant的数据库名称(我这里使用的是一个数据库实例,多个database/schema - mysql数据库)。
public class TenantInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(
final HttpServletRequest request, final HttpServletResponse response, final Object handler) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.isBlank(bearerToken)) {
return true;
}
bearerToken = bearerToken.replace("Bearer", "").replace(" ", "");
// 从jwt token中解析出company id
String companyId = getCompanyId(barerToken);
TenantContext.setCurrentTenant(companyId);
return true;
}
@Override
public void afterCompletion(
final HttpServletRequest request,
final HttpServletResponse response,
final Object handler,
@Nullable final Exception ex) {
TenantContext.clear();
}
}
连接到tenant所属数据库
为了使Hibernate支持多租户,需要实现两个接口,一个是CurrentTenantIdentifierResolver
用来得到tennat identifier,被下一个接口使用得到数据库连接,另一个是MultiTenantConnectionProvider
用来得到数据库连接。
首先是实现接口CurrentTenantIdentifierResolver
, 根据company id得到数据库(database/schema)的名称。
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {
private final DatabaseManager databaseManager;
@Autowired
public TenantIdentifierResolver(@Lazy final DatabaseManager databaseManager) {
this.databaseManager = databaseManager;
}
@Override
public String resolveCurrentTenantIdentifier() {
if (StringUtils.isBlank(TenantContext.getCurrentTenant())) {
return databaseManager.getDefaultSchemaName();
}
return databaseManager.getSchemaNameByCompanyId(TenantContext.getCurrentTenant());
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
然后实现另一个接口MultiTenantConnectionProvider
来获取与释放数据库连接。
@Component
public class TenantConnectionProvider implements MultiTenantConnectionProvider {
private static final long serialVersionUID = -1166976596388409766L;
private final transient DatabaseManager databaseManager;
private final transient DataSource defaultDataSource;
@Autowired
public TenantConnectionProvider(@Lazy final DatabaseManager databaseManager,
final DataSource pactsafeDataSource) {
this.databaseManager = databaseManager;
defaultDataSource = pactsafeDataSource;
}
@Override
public Connection getAnyConnection() throws SQLException {
return defaultDataSource.getConnection();
}
@Override
public void releaseAnyConnection(final Connection connection) throws SQLException {
connection.close();
}
@Override
public Connection getConnection(final String tenantIdentifier) throws SQLException {
final Connection connection = getAnyConnection();
connection.setCatalog(tenantIdentifier);
connection.setSchema(tenantIdentifier);
return connection;
}
@Override
public void releaseConnection(final String tenantIdentifier, final Connection connection)
throws SQLException {
connection.setSchema(tenantIdentifier);
connection.setCatalog(tenantIdentifier);
releaseAnyConnection(connection);
}
@Override
public boolean supportsAggressiveRelease() {
return false;
}
@Override
public boolean isUnwrappableAs(final Class unwrapType) {
return false;
}
@Override
public <T> T unwrap(final Class<T> unwrapType) {
return null;
}
}
最后需要加上Hibernate配置文件:
@Configuration
public class HibernateConfiguration {
private final JpaProperties jpaProperties;
@Autowired
public HibernateConfiguration(final JpaProperties jpaProperties) {
this.jpaProperties = jpaProperties;
}
@Bean
JpaVendorAdapter jpaVendorAdapter() {
return new HibernateJpaVendorAdapter();
}
@Bean
LocalContainerEntityManagerFactoryBean entityManagerFactory(
final DataSource dataSource,
final MultiTenantConnectionProvider multiTenantConnectionProvider,
final CurrentTenantIdentifierResolver currentTenantIdentifierResolver) {
final Map<String, Object> newJpaProperties = new HashMap<>(jpaProperties.getProperties());
newJpaProperties.put(MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
newJpaProperties.put(
MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider);
newJpaProperties.put(
MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolver);
newJpaProperties.put(
IMPLICIT_NAMING_STRATEGY, SpringImplicitNamingStrategy.class.getName());
newJpaProperties.put(
PHYSICAL_NAMING_STRATEGY, SpringPhysicalNamingStrategy.class.getName());
newJpaProperties.put(DIALECT, MySQL57Dialect.class.getName());
final LocalContainerEntityManagerFactoryBean entityManagerFactoryBean =
new LocalContainerEntityManagerFactoryBean();
entityManagerFactoryBean.setDataSource(dataSource);
entityManagerFactoryBean.setJpaPropertyMap(newJpaProperties);
entityManagerFactoryBean.setJpaVendorAdapter(jpaVendorAdapter());
entityManagerFactoryBean.setPackagesToScan("your_package_here");
entityManagerFactoryBean.setPersistenceUnitName("default");
return entityManagerFactoryBean;
}
}
然后Hibernate连接数据库的时候就会首先根据首先调用CurrentTenantIdentifierResolver
获取tenant identifier,然后调用MultiTenantConnectionProvider
来获取数据库连接。这样就能够连接到正确的数据库。
通过EntityManager来连接指定数据库
另外如果想连接指定的数据库,而不是当前的tenant的数据库,可以通过EntityManagerFactory
提供的功能来实现,代码示例如下:
final Session session = entityManagerFactory.unwrap(SessionFactory.class)
.withOptions()
.tenantIdentifier(schemaName)
.openSession();
final Transaction transaction = session.getTransaction();
transaction.begin();
session.save(entity);
transaction.commit();
session.close();
return entity;
https://medium.com/swlh/multi-tenancy-implementation-using-spring-boot-hibernate-6a8e3ecb251a
https://docs.jboss.org/hibernate/orm/5.2/userguide/html_single/Hibernate_User_Guide.html#multitenacy
https://medium.com/innomizetech/dynamic-multi-database-application-with-spring-boot-7c61a743e914
https://www.baeldung.com/spring-abstract-routing-data-source