(目录)
什么是多租户多租户是一个用于软件开发的术语,表示单个应用程序实例同时为多个客户端(租户)提供服务的软件架构。一般在 SaaS 系统中比较常见,这个架构相对困难的地方在于,隔离各个租户的数据,又同时尽可能共享其他资源,并且可以做到请求到达时在各租户间无缝切换。
多租户架构有这样一些特点:
硬件资源共享:一套硬件系统为多个租户服务,共享可以提高资源利用率。 软件资源共享:多个租户服务共用一个应用程序资源,可以节省迭代和管理成本。 可扩展性高:由于基础设施共享,增加和调整租户信息都会比较方便和快捷。 多租户实现分类既然多租户设计的难点在于隔离用户数据,又同时共享资源。那么可以根据用户数据的物理分离程度来进行分类。
分为三类:数据库(DataBase)、模式(Schema)、**数据表(Table)**。
分离数据库分离数据库类型每个租户在物理上单独使用一个数据库,JDBC 会连接到不同的数据库上,并且使用不同的连接池。一般应用程序会为每个租户添加一个 JDBC 连接池,然后根据访问的租户信息选择使用对应的连接池。
分离模式分离模式类型租户使用同一个数据库,但是分别创建一个模式(Schema)(“模式”是数据库的一个概念,这个翻译不是很好理解,下文本会使用 Schema 叙述)。这种类型下,不同租户使用的是不同 JDBC 连接池,和分离数据库类型一样。
值得一提的是,不同的数据库供应商对 Schema 的定义不同。MySQL 认为 Schema 和数据库在物理上同义,所以在MySQL中 Schema 和数据库概念上完全一致。
给表添加标识这种类型下,应用会为每个区分租户的实体添加鉴别信息。租户共享数据库,Schema 和数据表,每个相关查询语句中都需要添加租户信息,才能得到正确的结果。这种类型提供了最少的数据隔离,但可以最大限度地共享了数据。从架构上讲,它是概念上最简单的方案,而将复杂性推到了应用程序中,使得管理租户数据变得比较困难。
虽然看起来这个方案不太优秀,存在的必要性不大,但是多租户软件架构通常会替我们完成相当一部分工作,所以主要区别还是在数据的隔离程度上。
基础实现 方案选择本文希望各租户数据在物理上可以隔离,方便以后数据的备份和迁移,使用 MySQL 又不区分数据库和 Schema 的概念,所以选择第一种方案:分离数据库。
SpringBoot 已经为我们完成了架构的搭建,我们只需要按照文档实现相关的接口或者抽象类即可。可惜的是,SpringBoot 官方文档关于多租户系统的描述有限(可能是我没能找到),仅分别提供了三种类型的一个简单的示例。
示例项目原码:https://github.com/spring-projects/spring-data-examples.git 子项目路径:jpa > multitenant > db / partition / schema
接下来我们自己写一个实例,实现分离数据库的多租户系统。
本文实例的开发环境: SpringBoot 2.7.3 语言:Kotlin JDK:jbr-11 数据库: MySQL8.0.27
获取租户ID首先我们需要从前端请求中获取租户ID并保存,方便接下来的数据库请求能够读取到正确的租户ID使用。由于每个请求从开始到结束,应当保持租户ID不变,普通变量在并发中线程不安全,所以我们需要使用 ThreadLocal 来保存租户ID。
object TenantContext {
private const val TAG = "TenantContext"
private const val DEFAULT_TENANT = "tenant1"
private val currentTenant = InheritableThreadLocal<String>()
fun setTenantId(tenantId: String) {
Log.i(TAG, "Setting tenantId to $tenantId")
currentTenant.set(tenantId.ifBlank { DEFAULT_TENANT })
}
fun getTenantId(): String {
return currentTenant.get() ?: DEFAULT_TENANT
}
fun clear() {
currentTenant.remove()
}
}
其中,默认添加一个租户 "tenant1" ,在没有指定租户的时候使用。
在请求开始时,我们保存请求携带的租户ID,一般会放在请求头中,比如使用 “X-TENANT-ID”指定。从请求头中获取数据,我们可以使用 WebRequestInterceptor:
@Component
class TenantInterceptor : WebRequestInterceptor {
companion object{
private const val TAG = "TenantInterceptor"
}
override fun preHandle(request: WebRequest) {
val tenantId = request.getHeader("X-TENANT-ID") ?: ""
Log.i(TAG, "preHandle: tenantId: $tenantId")
TenantContext.setTenantId(tenantId)
}
override fun postHandle(request: WebRequest, model: ModelMap?) {
TenantContext.clear()
}
override fun afterCompletion(request: WebRequest, ex: Exception?) {
}
}
如果请求头中没有指定租户ID,我们传空给 TenantContext 让它指定默认租户。配置生效前还需要注册一下:
@Configuration
class WebConfig(private val tenantInterceptor: TenantInterceptor): WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addWebRequestInterceptor(tenantInterceptor)
}
}
完成以上工作,我们即可从请求中读取和保存租户信息了。
租户ID解析器SpringBoot需要知道,数据库请求发生时,应该使用哪个租户的连接信息。SpringBoot 使用 CurrentTenantIdentifierResolver (租户ID解析器)接口获取这一信息,我们去实现它。
@Component
class TenantIdentifierResolver : CurrentTenantIdentifierResolver, HibernatePropertiesCustomizer {
override fun resolveCurrentTenantIdentifier(): String {
return TenantContext.getTenantId()
}
override fun validateExistingCurrentSessions(): Boolean {
return true
}
override fun customize(hibernateProperties: MutableMap<String, Any>?) {
hibernateProperties?.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, this)
}
}
resolveCurrentTenantIdentifier方法在SpringBoot需要知道租户ID时调用,我们返回TenantContext中保存的ID。
validateExistingCurrentSessions方法告诉SpringBoot是否需要在内部执行CurrentSessionContext.currentSession() 时再校验一次租户ID,检查前后是否一致。这里暂时指定为 true。
这里还实现了 HibernatePropertiesCustomizer 接口,该接口等同于在 application.properties 中配置属性,告诉 SpringBoot,我们配置的“租户ID解析器”是当前组件,即当前类。
多租户连接提供者现在 SpringBoot 已经知道数据库请求发生时使用的租户信息,但还不清楚使用哪个 JDBC 连接,也即使用哪个数据库。我们需要通过实现MultiTenantConnectionProvider接口来,来指定。
这是比较关键的一步,有许多关键点需要我们关注。先上示例代码,然后一一解析。
@Component
class TenantConnectionProvider :
AbstractDataSourceBasedMultiTenantConnectionProviderImpl(),
HibernatePropertiesCustomizer {
@Autowired(required = false)
private lateinit var dataSource: DataSource
@Autowired
private lateinit var dataSources: DataSources
override fun selectAnyDataSource(): DataSource {
return dataSource
}
override fun selectDataSource(tenantIdentifier: String?): DataSource {
return if (tenantIdentifier.isNullOrBlank()) {
dataSource
} else {
dataSources.tenantDataSource(tenantIdentifier)
}
}
override fun customize(hibernateProperties: MutableMap<String, Any>?) {
hibernateProperties?.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, this)
hibernateProperties?.put(AvailableSettings.MULTI_TENANT, MultiTenancyStrategy.DATABASE)
}
}
AbstractDataSourceBasedMultiTenantConnectionProviderImpl 抽象类名称很长,表达也很明确。它实现了MultiTenantConnectionProvider接口,帮我们完成了基于DataSource的连接选择机制。
DataSource是java.sql的一个接口,用来获取JDBC连接。我们使用不同的DataSource来生成各自的连接池连接到对应的数据库。
selectAnyDataSource方法用于未指定租户ID时,SpringBoot 选择的 DataSource。比如,应用启动还没有请求时选择的DataSource。这里我们使用 @Autowired 注入一个 DataSource,这个DataSource是SpringBoot JPA启动时从配置文件中读取信息生成的。由于我们没有定义自己的 DataSource 组件,这里 @Autowired required 参数指定为 false。
对应的配置参数:
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/tenant1?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT
spring.datasource.username=root
spring.datasource.password=test
selectDataSource方法用于指定,CurrentTenantIdentifierResolver 获取到租户ID之后,选定的DataSource。这里我们使用自定义的DataSources类来管理租户ID与DataSource的对应关系,以及DataSource的生成。DataSources 类会在下文描述。
和TenantIdentifierResolver 一样,我们让TenantConnectionProvider 实现HibernatePropertiesCustomizer 接口,添加自己到配置里。不过这里添加了一个属性:AvailableSettings.MULTI_TENANT,用于指定当前多租户系统使用的是分离数据库的方案。如果不指定该参数,SpringBoot 会认为我们自己会配置 SessionFactory,而我们又没有指定,则会报错。
SessionFactory was not configured for multi-tenancy
数据源管理
我们自定义 DataSources,用来生成和管理 DataSource。
@Component
class DataSources {
companion object {
private const val TAG = "DataSources"
}
@Autowired(required = false)
private lateinit var properties: DataSourceProperties
private val dataSources = HashMap<String, DataSource>()
fun tenantDataSource(tenantId: String): DataSource {
return dataSources[tenantId] ?: kotlin.run {
val ds = createDatasource(tenantId)
dataSources[tenantId] = ds
ds
}
}
/**
* 替换 jdbc url 中的数据库名称
*/
private fun replaceJdbcDb(url: String, dbName: String): String {
val start = url.lastIndexOf('/') + 1
val end = url.indexOf('?')
val db = url.substring(start, end)
return url.replace(db, dbName)
}
private fun createDatasource(dbName: String): DataSource {
Log.i(TAG, "createDatasource: $dbName")
val jdbcUrl = replaceJdbcDb(properties.url, dbName)
Log.i(TAG, "jdbc url: $jdbcUrl")
return properties.initializeDataSourceBuilder()
.url(jdbcUrl)
.build()
}
}
因为各租户的 DataSource 我们使用的都是 MySQL,配置与未指定租户时的 DataSource基本一样,只是数据库名称不一样。所以可以复用配置文件的参数,使用@AutoWired 注入 DataSourceProperties。DataSourceProperties类是系统用来解析 DataSource 配置参数的类。注入后即可获取配置参数。
生成 DataSource 的方法很多,根据使用的数据库厂商不同会有对应不同的生成方法。一般使用 DataSourceBuilder 指定连接参数,然后由系统根据 url 或者其他信息自动获取数据库类型生成对应 DataSource并配置连接池。这里直接使用 DataSourceProperties 类的 initializeDataSourceBuilder 方法生成 DataSourceBuilder,再产生 DataSource。
示例测试DriverManagerDataSource 也可以生成 DataSource,但是默认没有配置连接池,不建议在生产环境下简单调用。
完成以上配置后,基础功能实现我们已经完成,接下来做个简单的测试。
添加测试实体:
@Entity
class Person(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0L,
var name: String = ""
){
override fun toString(): String {
return "(Person: id=$id, name=$name)"
}
}
interface Persons: JpaRepository<Person, Long>
添加 Controller:
@RestController
class TenantController {
@Autowired
private lateinit var persons: Persons
@PostMapping("/")
fun addPerson(name: String): String {
persons.save(Person(name = name))
return "success"
}
}
添加测试用例:
@SpringBootTest
class MultiTenantApplicationTests {
@Autowired
private lateinit var controller: TenantController
@Autowired
private lateinit var persons: Persons
@Test
fun contextLoads() {
TenantContext.setTenantId("tenant1")
controller.addPerson("Peter")
println(persons.findAll())
TenantContext.setTenantId("tenant2")
controller.addPerson("Ross")
println(persons.findAll())
}
}
在运行测试用例前,需要新建两个数据库,“tenant1”、“tenant2”;分别在数据库中添加表 person。
测试用例中使用的是手动切换租户ID,在请求头中添加租户ID的部分代码,可以使用postman测试下。
得到的结果是分别在两个数据库中各添加了一条记录,实现了分离数据库的目的。
总结本文介绍了多租户系统的定义和分类,探讨了各种实现方案的优缺点。使用MySQL举例,展示了基础功能的实现过程和代码。
本文中使用的“SpringBoot”术语指代SpringBoot为我们提供的基础技术支持,主要是 Sping Data 技术,包括 JDBC 连接,JPA 和 Hibernate ORM 技术等。具体区分什么技术包含什么内容的意义不大,这里不作细分。相关文档可以参考:
Spring Data https://spring.io/projects/spring-data Hibernate ORM https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html
接下来,我们会继续探讨如何手动配置EntityManagerFactory,自动生成数据库、数据表,异步方法中上的租户信息传递等问题。请关注后续文章。
本文源码:https://gitee.com/yoshii_x/multi-tenant.git