1、多租户的概念
多租户是一种单个软件实例可以为多个不同用户组提供服务的软件架构。软件即服务(SaaS)就是一种多租户架构。在云计算中,多租户也可以指共享主机,其服务器资源将在不同客户之间进行分配。与多租户相对应的是单租户,单租户是指软件实例或计算机系统中有 1 个最终用户或用户组。多租户应用通常包括对租户提供一定程度的定制,例如定制应用的外观,或允许租户决定用户的具体访问控制权限和限制。
2、多租户与云计算
多租户作为一个概念是云计算的一个重要特征,因其是软件应用的单个实例,提供给多个租户。多租户通常和 SaaS 应用相关,与之不同,云被视为平台即服务(PaaS)。云服务提供商从资源池中为用户提供云计算所需的平台和底层 IT 基础架构,这些资源被分配给多个用户(或租户)。 云架构是指构建云所需的所有组件和功能如何连接起来,以便交付供应用运行的在线平台。
公共云架构:一种利用非最终用户所有的资源创建的云环境,可重新分发给其他租户。
私有云架构:可广义地定义为一种专为最终用户而创建,而且通常位于用户的防火墙内(有时也是内部部署)的云环境。
3、多租户架构的优势
多租户可以节省成本。计算规模越大,成本就越低,并且多租户还允许对资源进行有效地整合和分配,最终节省运营成本。对于个人用户而言,访问云服务或 SaaS 应用所需的费用通常要比运行单租户硬件和软件更具成本效益。
多租户可以提高灵活性。如果自行购置硬件和软件,那么在需求旺盛时可能会难以满足需求,而在需求疲软时则可能会闲置不用。另一方面,多租户云却可以根据用户的需要来灵活地扩展和缩减资源池。作为公共云提供商的客户,您可以在需要时获得额外容量,而在不需要时则无需付费。
多租户可以提高效率。多租户消除了单个用户管理基础架构及处理更新和维护的必要。
4、多租户的三种主要方案
4.1 方案一 、独立数据库(The Database per Tenant and Schema per Tenant patterns)
在独立数据库模式下,一个租户一个数据库,每个租户数据库和租户模式提供了租户之间数据的清晰分离,但代价是为每个租户重复数据库表定义。 这可能会导致可扩展性问题,因为所需的每个数据库迁移都必须应用于每个租户。 如果在应用程序启动时自动应用数据库迁移,大量租户将导致启动时间过长,同时后期随着数据量的增加,维护成本较低效。
4.2 方案二、共享数据库,不同Schema
在共享数据库实例时,对每个租户使用单独的Schema。每个租户的数据通过数据库引擎提供的独立模式的语义进行逻辑隔离。这个模式由每个租户的单独数据库用户所拥有,数据库引擎的安全机制将进一步保证数据的隐私性和机密性(但是在这种情况下,数据库连接池不能被数据访问层重用)。 优点: 为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可支持更多的租户数量。 缺点: 如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。
4.3 方案三、共享数据库、共享schema、共享表
每插入一条数据时都需要有一个客户的标识,这样才能在同一张表中区分出不同客户的数据。在具有租户字段模式的共享数据库中,这个问题不再存在。 将所有租户的数据放在一个数据库中,我们只需管理一组数据库。为了分隔不同租户之间的数据,我们在每个表中使用租户字段来保存表中每一行的租户信息 。 因此,每次存储数据时,我们都需要使用正确的租户信息填充租户字段,并且每次查询数据时,我们都需要将租户字段作为额外的 where 条件包含在内。 这显然是我们希望在一个地方捕获的跨领域关注点。 租户之间的数据隔离保证(我们的客户很可能会要求我们)依赖于我们能够证明租户字段正确用于所有数据库访问。
通过以上三种方案的对比,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。接下来将通过增加租户字段方式来实现JPA的多租户隔离功能。
5、JPA通过增加租户字段来实现多租户隔离
5.1 需求背景
JPA底层是通过hibernate框架实现的,在Hibernate 的 5.4.x之前的版本,虽然有MultiTenancyStrategy.DISCRIMINATOR这种策略,但是官方并没有提供多租户的具体实现方式,直到Hibernate 6出来了,官方才正式支持共享数据库共享实例。然而,Hibernate6版本强依赖于Spring Boot 3.XX, Spring Boot3又需要把JDK升级到17。因为目前大多数的公司,都是用jdk8 或者jdk11的,为了避免升级springboot版本引发的系列问题,本着最小修改原则,本文通过Hibernate过滤器和切面的方式来实现多租户隔离。
5.2 方案设计
多租户隔离的需要实现以下两种能力:
(1)在保存实体时使用正确的的租户信息来填充所有实体的租户字段列
(2)在查询实体时,统一在where条件中增加租户字段
JPA EntityListener 和 Hibernate 特定过滤器恰好可以完成以上两种能力
5.2.1 Jpa Entity Listener
Hibernate的Entity Listener 允许我们在JPA的生命周期(新增、删除、更新)中,添加一个监听器。通过这个监听器,我们就能够对实体类做一些额外的操作,例如增加属性tenantId。假设我们自定义的监听器是TenantAware,那所有实现这个接口的实体类都可以实现如下所示的功能: 在更新、删除、插入之前,给entity增加一个租户id的字段。
相关代码如下:
public interface TenantAware {
void setTenantId(String tenantId);
}
public class TenantListener {
@PreUpdate
@PreRemove
@PrePersist
public void setTenant(TenantAware entity) {
final String tenantId = TenantContext.getTenantId();
entity.setTenantId(tenantId);
}
}
在上述介绍的监听器中,只能在监听新增、删除、更新这3个操作,对于查询,就无能为力了,因此引入 Hibernate Filter。
5.2.2 Hibernate Filter
Hibernate Filter的过滤器有一个机制:只要实体类上有@Filter,那么这个实体的所有查询语句,都能被过滤器拦截
@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "tenantId", type = "string")})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
5.2.3 多租户隔离方案的实现
@MappedSuperclass
@Getter
@Setter
@NoArgsConstructor
@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "tenantId", type = "string")})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@EntityListeners(TenantListener.class)
public abstract class AbstractBaseEntity implements TenantAware, Serializable {
private static final long serialVersionUID = 1L;
@Size(max = 30)
@Column(name = "tenant_id")
private String tenantId;
public AbstractBaseEntity(String tenantId) {
this.tenantId = tenantId;
}
}
其中,所有租户必须继承抽象类AbstractBaseEntity。不幸的是,执行上述代码时,发现保存数据时租户字段并没有自动插入,在实体上定义的过滤器没有自动生效。这是因为当一个查询请求发送过来的时候,会打开一个Hibernate的Session会话,需要显式配置底层 Hibernate Session 来使用过滤器。 由于 Session 对象是在运行时动态创建的(通常每个事务创建一次),因此我们无法在应用程序启动时一劳永逸地应用 Filter过滤器。 因此,我们需要一个额外的机制:切面技术Aspect。
AspectJ 提供了一种机制来定义细粒度的执行点并拦截这些点的执行以注入额外的行为。 这正是我们所需要的:通过拦截Hibernate Session创建的方法,以确保我们的 Filter能正确应用于每个创建的 Session。 需要注意的是,由于 Hibernate Session对象不是Spring管理,我们不能使用 Spring 中的轻量级内置 Aspect 功能来实现上述功能,为了拦截任意代码并在运行时注入功能,AspectJ 需要将定义的切面编织到应该受影响的类中。 编织可以在编译时完成(在 Java 编译完成后,使用 AspectJ 编译器作为构建链中的一个步骤),也可以在加载时使用加载时编织来完成。 后一种方法侵入性较小,因此我们选择加载时注入。
注: 配置 AspectJ 加载时 Weaver 必须使用类路径中的 META-INF/aop.xml 文件实现的
<aspectj>
<weaver options="-Xreweavable -verbose -showWeaveInfo">
<include within="se.callista.blog.service.multi_tenancy.aspect.TenantFilterAspect"/>
<include within="org.hibernate.internal.SessionFactoryImpl.SessionBuilderImpl"/>
</weaver>
<aspects>
<aspect name="se.callista.blog.service.multi_tenancy.aspect.TenantFilterAspect"/>
</aspects>
</aspectj>
在该配置中定义了相应的切面及其应用类,其中
<weaver> 元素:指定了 AspectJ 织入器的配置选项
-Xreweavable 选项用于支持增量编译和重新织入,允许在运行时修改切面并动态重新织入到目标类中。
-verbose 选项用于在织入过程中输出详细信息,可以帮助调试和观察织入的效果。
-showWeaveInfo 选项用于在织入过程中显示织入的详细信息,包括被织入的类和方法等。
<include> 元素:用于指定需要织入的目标类或切点
切面的定义如下:
@Aspect
public class TenantFilterAspect {
@Pointcut("execution (* org.hibernate.internal.SessionFactoryImpl.SessionBuilderImpl.openSession(..))")
public void openSession() {
}
@AfterReturning(pointcut = "openSession()", returning = "session")
public void afterOpenSession(Object session) {
if (session != null && Session.class.isInstance(session)) {
final String tenantId = TenantContext.getTenantId();
if (tenantId != null) {
org.hibernate.Filter filter = ((Session) session).enableFilter("tenantFilter");
filter.setParameter("tenantId", tenantId);
}
}
}
}
注: 由于 AspectJ 加载时编织在 Spring Boot 中工作比较复杂, 因此考虑引入Spring Boot starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
接下来就是在启动类上,使用@EnableLoadTimeWeaving注解,来启用 AspectJ 加载时间编织器。
@SpringBootApplication
@EnableLoadTimeWeaving(aspectjWeaving = EnableLoadTimeWeaving.AspectJWeaving.ENABLED)
public class MultiTenantServiceApplication extends SpringBootServletInitializer {
...
}
最后,我们需要使用Spring的instrumentation代理和AspectJ的aspectjweaver代理作为-javaagent JVM参数传递。 java代理的配置会根据部署场景的不同而有所不同。 使用 Maven spring-boot 插件,以下配置将起作用:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<agents>
<agent>${project.build.directory}/spring-instrument-${spring-framework.version}.jar</agent>
<agent>${project.build.directory}/aspectjweaver-${aspectj.version}.jar</agent>
</agents>
</configuration>
</plugin>
其中JVM启动参数为: java -javaagent:spring-instrument.jar -javaagent:aspectjweaver.jar -jar app.jar
5.3 总结
上述方案在现有框架的基础上,通过最小的改动,实现了多租户功能。 通过方面应用的 EntityListener 和 Hibernate Filter 的系统使用相当稳健。 它将保证每个租户的数据与其他租户完全隔离(即使数据位于同一个数据库中)。