意见箱
恒创运营部门将仔细参阅您的意见和建议,必要时将通过预留邮箱与您保持联络。感谢您的支持!
意见/建议
提交建议

Spring Boot JPA MySQL 多租户系统 Part1 - 基础实现

来源:恒创科技 编辑:恒创科技编辑部
2022-09-13 01:54:00

(目录)

什么是多租户

多租户是一个用于软件开发的术语,表示单个应用程序实例同时为多个客户端(租户)提供服务的软件架构。一般在 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

上一篇: 租用美国服务器:潜在的风险与应对策略。 下一篇: MongoDB 5.0 扩展开源文档数据库操作