Spring boot + Hibernate 多租户的使用

本文发布在个人博客,因为Github Pages在百度不会被收录,所以为了能帮助到更多的人,特意发到简书上,大家可以关注我的博客:http://lanyuanxiaoyao.com/

多租户

多租户(Multi Tenancy/Tenant) 是一种软件架构,其定义是:在一台服务器上运行单个应用实例,它为多个租户提供服务

概念是抽象的,但是理解起来并不困难,简单来说就是分组,举个例子:我们管理学校学生的时候,可以按照不同的范围来进行分组,比如我们可以按照学生个人为单位进行分组,也可以按照班级为单位进行分组,然后班级下面有很多的学生,也可以按照年级为单位进行分组,以学校为单位……这样的每一个分组的单位,都可以是我们概念里面说的一个租户
但是这样不就和我们以前说的按照面向对象来分类是一样的吗?其实是差不多的,但是有着一些细节上的差别,首先多租户架构的概念是针对数据存储的,我们是一个数据服务提供商,假设我们给所有的学校提供服务,对于我们来说,分组是按照学校为单位的,而且学校与学校之间互相没有任何关系,也就说学校与学校之间是隔离的,对于不同学校的数据我们需要将它们隔离开来。这种数据的分组就是多租户架构要研究的问题。
当然这只是概念上的区别,在实际使用上和我们传统的分组并无太大差异。

多租户的三种模式

多租户的架构分为以下三种:

  1. 独立数据库
  2. 共享数据库,独立Schema
  3. 共享数据库,独立Schema,共享数据表

注:在这个架构的概念里面,数据库指的是物理机器数据库,也就是我们的一部运行着数据库软件的计算机是一个物理数据库,Schema就是我们在数据库软件里面创建的“数据库”,实际上都是在同一个物理机器里面的,表就是表,一个简单的表

独立数据库是一个租户独享一个数据库实例,它提供了最强的分离度,租户的数据彼此物理不可见,备份与恢复都很灵活
共享数据库、独立 Schema 将每个租户关联到同一个数据库的不同 Schema,租户间数据彼此逻辑不可见,上层应用程序的实现和独立数据库一样简单,但备份恢复稍显复杂;
最后一种模式则是租户数据在数据表级别实现共享,它提供了最低的成本,但引入了额外的编程复杂性(程序的数据访问需要用 tenantId 来区分不同租户),备份与恢复也更复杂。
这三种模式的特点可以用一张图来概括:

三种部署模式的异同

多租户模式选择

从上面的图我们可以看到,在成本上,独立数据库是最高的,毕竟我们一个租户就是一个物理机器,而且数据共享起来会麻烦,涉及到跨物理机器的通信,但这种模式的优势体现在单个租户数据量庞大,而且有非常大的扩展需求,那么单个机器内的调整就非常容易,而且不会影响到其他的租户,因为它的隔离程度是最高的。
事实上,多租户模式的选择,主要是成本原因,对于多数场景而言,共享度越高,软硬件资源利用效率更好成本更低。但同时也要解决好租户资源共享和隔离带来的安全与性能、扩展性等问题。毕竟,也有客户无法满意于将数据与其他租户放在共享资源中。

Hibernate 多租户的使用

Mybatis 多租户的使用

一开始我也是使用Mybatis进行多租户的设计,但是事实上Mybatis本身是没有对多租户提供支持的,也就说我们如果使用Mybatis设计多租户的架构的话,那么我们就需要手动实现sql语句的拦截然后在执行具体sql语句之前执行use tenant_id的操作,拦截sql语句的一个比较简单的方式是通过spring aop在service层的操作里进行切入实现拦截。
实际上Hibernate也是这么干的,不过Hibernate在框架层面帮我们进行了sql语句拦截,不需要自己设计。
虽然最后我选择了Hibernate进行多租户的设计,但是这里也记录下Mybatis的设计思路,实现起来就简单了。

项目结构

可能与Github(地址在文章末尾)实际编码有点出入,因为我可能会修改,但大体相同。

主要目录及文件说明

  • config
    一些设置文件,一开始我有一些设置文件的,但是后来去掉了,所以你可以忽略这个设置文件夹
    • ConstId
      用来暂存租户IDTenantId的一个文件,没有特别的作用,通常情况下,这个租户ID是登陆的时候存在session里面的,然后读取也是从session里面读取,这里显然是我为了方便就随便用一个文件来存了
  • controller
    顾名思义……
    • HelloController
  • dao
    这个也不解释了,dao层
    • StudentDao
    • TenantInfoDao
  • entity
    实体类……
    • Student
    • TenantInfo
      这个是租户信息的实体类
  • service
    Service层,只有一个StudentService是因为我嫌麻烦就不多创建一个TenantInfoService了
    • StudentService
  • tenant
    多租户相关的文件都在这里了,这个文件夹下的文件是重点!这些类的作用会在下面详细分析,这里就先不赘述了
    • MultiTenantConnectionProviderImpl
    • MultiTenantIdentifierResolver
    • TenantDataSourceProvider
  • util
    一些辅助的工具,方便操作用的(各个web项目都可以通用,大家可以参考)
    • JsonUtil
      给Gson整了一个单例,不同到处new Gson()
    • Result
      统一的返回结果格式,满足REST架构
    • ResultCode
      统一的返回码,参照HTTP响应码
    • ResultGenerator
      构造返回Result结果的工具类
  • CloudApplication.java

数据库结构和说明

首先在数据库里有三个Schema,其中cloud_config是存储租户信息的,class_1class_2分别为我们预设的两个租户

cloud_configtenant_info表结构

  • 字段说明
    • id
      主键
    • tenant_type
      数据库类型,用于识别连接不同的数据库的时候设置驱动的字段,在我这个小Demo中没有用上
    • url
      数据库连接URL
    • username
      数据库连接用户名
    • password
      数据库连接密码
    • tenant_id
      租户ID

class_1class_2student表结构

代码

实际上需要设置的代码非常简单,但是网上的资料极其稀少,很多Demo项目都没有注释和说明,让我走了很多弯路,也是促使我写一个博客来说明这个多租户配置和使用的主要动力

application.properties

怎么配置开启Hibernate的多租户功能,网上各种配置形式都有,有两种形式,一种是写配置类,一种就是在application.properties文件直接配置,显然直接配置要比配置类简单太多了

# Database
spring.datasource.url=jdbc:postgresql://localhost:5432/cloud_config
spring.datasource.username=lanyuanxiaoyao
spring.datasource.password=
spring.datasource.driver-class-name=org.postgresql.Driver

# Hibernate
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.multiTenancy=SCHEMA
spring.jpa.properties.hibernate.tenant_identifier_resolver=cloud.tenant.MultiTenantIdentifierResolver
spring.jpa.properties.hibernate.multi_tenant_connection_provider=cloud.tenant.MultiTenantConnectionProviderImpl

这就是所需要的所有相关配置(如果你有别的配置就另外加上就是了),其中Database配置一定要有,就是一定要有一个默认的配置才能启动Spring boot,这个不能省……这是一个坑。

  • 关于Hibernate的几个配置项的说明
    • show-sql
      这个也无关多租户的设置,只是在控制台显示Hibernate执行的sql语句,方便调试
    • hibernate.multiTenancy
      选择多租户的模式,有四个参数:NONEDATABASESCHEMADISCRIMINATOR,其中NONE就是默认没有模式,DISCRIMINATOR会在Hibernate5支持,所以我们根据模式选择是独立数据库还是不独立数据库就可以了,我这里选择SCHEMA,因为只有一台物理机器
    • hibernate.tenant_identifier_resolver
      租户ID解析器,简单来说就是这个设置指定的类负责每次执行sql语句的时候获取租户ID
    • hibernate.multi_tenant_connection_provider
      这个设置指定的类负责按照租户ID来提供相应的数据源

配置后三个设置项的时候会没有自动提示,直接复制就行了,只要名字没错就ok,因为没有自动提示搞到我以为设置在这里是不行的

tenant包

这里的三个类是全部和多租户相关的类,这里我连同导包的信息也一并贴上了,希望大家不要导错包,同名的包有不少

TenantDataSourceProvider

import cloud.entity.TenantInfo;
import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

/**
 * @author lanyuanxiaoyao
 */
public class TenantDataSourceProvider {

    // 使用一个map来存储我们租户和对应的数据源,租户和数据源的信息就是从我们的tenant_info表中读出来
    private static Map<String, DataSource> dataSourceMap = new HashMap<>();

    /**
     * 静态建立一个数据源,也就是我们的默认数据源,假如我们的访问信息里面没有指定tenantId,就使用默认数据源。
     * 在我这里默认数据源是cloud_config,实际上你可以指向你们的公共信息的库,或者拦截这个操作返回错误。
     */
    static {
        DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create();
        dataSourceBuilder.url("jdbc:postgresql://localhost:5432/cloud_config");
        dataSourceBuilder.username("lanyuanxiaoyao");
        dataSourceBuilder.password("");
        dataSourceBuilder.driverClassName("org.postgresql.Driver");
        dataSourceMap.put("Default", dataSourceBuilder.build());
    }

    // 根据传进来的tenantId决定返回的数据源
    public static DataSource getTenantDataSource(String tenantId) {
        if (dataSourceMap.containsKey(tenantId)) {
            System.out.println("GetDataSource:" + tenantId);
            return dataSourceMap.get(tenantId);
        } else {
            System.out.println("GetDataSource:" + "Default");
            return dataSourceMap.get("Default");
        }
    }

    // 初始化的时候用于添加数据源的方法
    public static void addDataSource(TenantInfo tenantInfo) {
        DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create();
        dataSourceBuilder.url(tenantInfo.getUrl());
        dataSourceBuilder.username(tenantInfo.getUsername());
        dataSourceBuilder.password(tenantInfo.getPassword());
        dataSourceBuilder.driverClassName("org.postgresql.Driver");
        dataSourceMap.put(tenantInfo.getTenantId(), dataSourceBuilder.build());
    }

}

MultiTenantConnectionProviderImpl

import org.hibernate.engine.jdbc.connections.spi.AbstractDataSourceBasedMultiTenantConnectionProviderImpl;
import javax.sql.DataSource;

/**
 * 这个类是Hibernate框架拦截sql语句并在执行sql语句之前更换数据源提供的类
 * @author lanyuanxiaoyao
 * @version 1.0
 */
public class MultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {

    // 在没有提供tenantId的情况下返回默认数据源
    @Override
    protected DataSource selectAnyDataSource() {
        return TenantDataSourceProvider.getTenantDataSource("Default");
    }

    // 提供了tenantId的话就根据ID来返回数据源
    @Override
    protected DataSource selectDataSource(String tenantIdentifier) {
        return TenantDataSourceProvider.getTenantDataSource(tenantIdentifier);
    }
}

MultiTenantIdentifierResolver

package cloud.tenant;

import cloud.config.ConstId;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;

/**
 * 这个类是由Hibernate提供的用于识别tenantId的类,当每次执行sql语句被拦截就会调用这个类中的方法来获取tenantId
 * @author lanyuanxiaoyao
 * @version 1.0
 */
public class MultiTenantIdentifierResolver implements CurrentTenantIdentifierResolver{

    // 获取tenantId的逻辑在这个方法里面写
    @Override
    public String resolveCurrentTenantIdentifier() {
        if (!"".equals(ConstId.Id)){
            return ConstId.Id;
        }
        return "Default";
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}

Hibernate 多租户实现原理

真如前面所说,Hibernate实现多租户的原理实际上就是在调用具体sql语句之前先调用一句user database来切换数据库,实现切换租户空间的功能,所以Hibernate提供了两个类来帮助我们在框架层面拦截我们要执行的sql语句,并注入切换数据库的操作,操作流程见下图:

测试

因为Demo实在是简单,所以有一些细节没有处理,包括从session中取tenantId也没有写进去,所以测试流程就先写下来,免得无法测试实际项目效果

初始化datasourceMap

访问http://localhost:8080/

可以看到我们从cloud_configschema的tenant_info获取到所有租户的信息

登陆

访问http://localhost:8080/login?t=class_1

看到返回成功,即后台已经设置好了tenantIdclass_1

查询

访问http://localhost:8080/select?t=class_1

可以看到我们在不改动实际数据库连接的情况下获取到了class_1schema的student的数据,到这里我们已经可以访问租户的信息了

切换租户

我们重复登录和查询的步骤
访问http://localhost:8080/login?t=class_2http://localhost:8080/select?t=class_2

我们成功获取到了另一个租户的信息,到这里我们多租户的实现已经成功了。

总结

多租户架构这个看起来好像还挺新的,也许是应用范围不够广泛,网上的资料相当少,也让我走了很多的弯路,在此总结这篇文档,希望能够帮助到大家。
Demo的GIthub地址:https://github.com/lanyuanxiaoyao/multi-tenant

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容