# 多租户

# 多租户概念

多租户技术(multi-tenancy technology),是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业)共用相同的系统或程序组件,并且确保各用户间数据隔离性。在服务器上运行的应用实例,它为多个租户(客户)提供服务。从定义中我们可以理解:多租户是一种架构,目的是为了让多用户环境下使用同一套程序,且保证用户间数据隔离。多租户的重点就是同程序下实现多用户数据的隔离。

# 原理介绍

提供了字段隔离数据源隔离两种多租户场景下的解决方案。

  • 字段隔离:字段隔离指的是所有租户共享数据库,共享数据架构,通过字段进行数据隔离。通俗的讲就是多个租户共用相同的数据库和表,但在表中通过租户ID来区分不同租户的数据,当有数据库操作时会在执行的SQL语句后边拼接租户ID条件来完成数据的筛选,从而实现操作不同租户的数据的目的。这种方案属于隔离级别最低的方案,单个租户进行数据备份和恢复比较困难,需要逐表逐条备份和还原,但是所需的服务器资源也是最少的。如果希望以较少的服务器为更多的租户提供服务,并且租户接受以牺牲隔离级别换取降低成本,这种方案最为适合。

  • 数据源隔离:数据源隔离即不同的租户使用不同的数据库存取数据来达到租户间数据隔离的目的。每个租户会维护自己的一套数据源,当有数据库操作时,会根据租户定位到指定数据源,然后在该数据源下执行相应操作从而实现租户间数据隔离。这种方案的数据隔离级别最高,安全性最好,如果出现故障,恢复数据比较简单,同时也有助于简化数据模型的扩展设计,满足不同租户的独特需求;但由于增大了数据库的安装数量,随之带来维护成本和购置成本也会增加。如果面对的是类似医院、银行等需要非常高数据隔离级别的租户,可以选择这种模式。

目前平台是两种方案一起使用

# 字段隔离

  • 实现字段隔离是通过整合Mybatis-Plus多租户组件TenantLineInnerInterceptor,并在此基础上进行了二次封装,最终形成了符合HOS平台架构风格的字段隔离方案。

  • 具体实现细节是每次请求数据库时拦截原始的SQL语句并对原始SQL进行改写,改写时会通过配置的具体的字段隔离方案进行一系列逻辑处理来确定在哪张表后拼接租户ID字段和相应的值,最后整合成完整的SQL语句进行执行。

# 数据源隔离

  • 实现数据源隔离是通过整合Mybatis-Plus动态数据源插件dynamic-datasource-spring-boot-starter,并在此基础上进行了二次封装,最终形成了符合HOS平台架构风格的数据源隔离方案。

  • 具体实现细节有以下几点:

    1. 代码启动时会加载配置文件中数据源作为默认数据源,同时加载数据源表中的数据作为租户数据源
    2. 每次接口请求时拦截相应controller,根据租户与数据源之间的关联关系通过租户定位到指定数据源,然后进行数据源切换,后续数据库操作会在切换后的数据源中执行。
    3. 加载的数据源最终存储在DynamicRoutingDataSourcedataSourceMap即内存中,支持动态添加、移除、更新数据源,无需重启即可生效。
    4. 懒加载机制:每次接口请求时如果dataSourceMap中没有待切换的数据源,则会先去默认数据库中查询该数据源是否存在,如果存在则会将该数据源添加至dataSourceMap中,然后再执行切换数据源操作。

# 使用配置

# 初始化多租户SQL

1.下载多租户数据库脚本hos-app-base-tenant.sql ,并导入数据库脚本。

img.png

2.修改默认租户的domain为前端服务的实际地址

update t_tenant set domain='前端地址';

# 配置文件修改

修改配置文件application-dev.yml,添加hos-app-base需要忽略的表

framework:
  multi-tenant:
    enable: true
    column: tenant_id
    ignore-table:
    - t_tenant_package
    - t_tenant
    - t_datasource
    - t_tenant_package_resource
    #是否开启数据源隔离,默认关闭
    dynamic-datasource: false
  • enable:多租户字段隔离全局开关,默认为false关闭状态
  • column:数据库中租户ID的列名,默认为tenant_id
  • ignore-table:数据库中忽略租户ID的表名,此处配置的表在拼接SQL时忽略租户ID查询条件,默认为空,有多张表需要忽略时以-分隔
  • dynamic-datasource:数据源隔离全局开关,默认为false关闭(未配置的效果等同于配置为false)

# 注意事项

  • 若要使用多租户字段隔离插件,需要在业务表和对应的实体类中增加租户ID字段,同配置文件中的framework.multi-tenant.column保持一致。

  • 需要事先将租户ID的值放在上下文中,如果没有从上下文中获取到租户ID的值,则不会在SQL后拼接租户ID条件。提供了操作租户ID的工具类com.mediway.hos.base.util.TenantUtil

  • 执行数据库的INSERT操作时,如果手动或通过其它机制(如使用了自动填充注解@TableField(fill = FieldFill.INSERT)@TableField(fill = FieldFill.INSERT_UPDATE))给租户ID进行了赋值,则本插件不再重复给租户ID赋值。

  • 本插件不支持在Mybatis的mapper.xml中的<resultMap>标签下的<collection>标签中书写select方法这种方式,该方式不会给select方法拼接租户ID字段,示例如下。

<resultMap id="dictMap" type="com.mediway.hos.app.base.sys.model.vo.DictVO">
    <result column="id" property="id"/>
    <result column="parent_id" property="parentId"/>
    <collection property="options" ofType="com.mediway.hos.app.base.sys.model.entity.Dict"
                column="id" select="com.mediway.hos.app.base.sys.mapper.DictMapper.selectByTypeCodeCopy">
    </collection>
</resultMap>

  • 若要使用多租户数据源隔离插件,需要事先给租户绑定数据源,如果没有绑定则会使用默认数据源

  • 数据源隔离全局配置插件与排除注解只对系统中的controller或使用了@GetMapping、@PutMapping、@PostMapping、@DeleteMapping、@RequestMapping注解的方法上起作用。

  • 系统运行时所有加载的数据源都存储在DynamicRoutingDataSourcedataSourceMap中,假如给某个租户绑定了一个系统环境dataSourceMap中或数据库中并不存在的数据源,则会使用默认数据源

  • 数据源隔离插件执行过程中是通过数据源标识进行数据源切换的,而数据源标识在整个过程中是存放在ThreadLocal里的,如果每次请求只在单个线程内执行的话肯定没有问题,如果请求过程中涉及到使用多个线程的情况(如使用多线程、消息队列等),不在同一个线程内就无法从ThreadLocal获取到数据源标识,真正执行数据库操作的时候也就无法确定具体数据源,导致数据源隔离失效。针对这种场景,需要使用者使用显式传参的方式,把数据源标识传递到后续数据库执行方法中,并且在执行数据库操作之前需要手动调用com.mediway.hos.tenant.content.DynamicDSHelper#switchDataSource方法进行数据源切换操作,后续会在切换后的数据源中执行操作从而达到想要的效果。

数据源隔离下Druid配置(非必设置)

spring:
  datasource:
    dynamic:
      druid:
        #动态数据源开启后,druid相关配置应在此处设置,详情参考com.baomidou.dynamic.datasource.spring.boot.autoconfigure.druid.DruidConfig
        #connection-error-retry-attempts: 3
        #break-after-acquire-failure: true
        ...

  • 在动态数据源开关开启后,Druid连接池的相关配置从原生Druid的spring.datasource.druid节点变为现在的spring.datasource.dynamic.druid节点。原生节点除了可以处理少数配置(spring.datasource.druid.aop-patternsspring.datasource.druid.web-stat-filterspring.datasource.druid.stat-view-servletspring.datasource.druid.filter)外,其余都会读取新节点spring.datasource.dynamic.druid下的配置,如果新节点也没有配置,则会读取原生Druid配置的默认值。
  • spring.datasource.dynamic.druid节点支持配置的参数可参考com.baomidou.dynamic.datasource.spring.boot.autoconfigure.druid.DruidConfig的属性,由于配置项过多,此处不一一列举说明,大家可自行了解后再进行设置。

# 租户初始化数据

如果新建一个租户,需要给租户初始化业务数据时,需要实现TenantInitDataService

@Service
@Slf4j
@Order(10)
public class TenantInitDataServiceImpl implements TenantInitDataService {
    
    @Override
    public void initDataProcessor(String tenantId) {
        log.info("初始化数据。。。。。");
    }

}

# 数据源隔离排除注解

在开启数据源隔离全局开关后,所有的接口请求都会进行数据源的切换,但在有些场景下一些请求需要用到默认数据源,为此提供了数据源隔离排除注解@IgnoreDS,使用后就不会受全局插件的影响。

  • 配置该注解的类或方法将不会切换数据源,使用的是默认数据源
  • 由于全局插件的原理是对controller的请求进行拦截,所以该注解也仅作用在controller的类或方法上,配置在其它类上不会生效。

至此数据源隔离插件配置介绍完毕,下边介绍下使用过程中的一些注意事项。

# 使用示例

# 字段隔离使用示例

以关联查询用户信息为例演示多租户字段隔离插件的使用步骤

1.添加多租户maven依赖

<dependency>
    <groupId>com.mediway.hos</groupId>
    <artifactId>hos-framework-tenant-starter</artifactId>
</dependency>

2.数据库表中添加租户ID字段

staff员工信息表添加字段tenant_id

staff_table

organization组织结构表添加字段tenant_id

organization_table

3.实体类中添加租户ID字段

@ApiModelProperty(value = "租户id")
@TableField("tenant_id")
private String tenantId;

4.关联查询用户信息示例代码

接口响应参数StaffVo

@Data
public class StaffVo {
    private String id;
    private String name;
    private String phone;
    private String email;
    private Integer age;
    private String orgId;
    private String orgName;
    private String orgCode;
}

StaffMapper.xml

<select id="selectStaffVoTenantDemo" resultType="com.mediway.oa.user.model.vo.StaffVo">
    select s.id, s.name, s.age,s.email,s.phone,o.id as orgId,o.name as orgName,o.code
    from staff s
    left join organization o
    on s.org_id = o.id
    where s.name = #{name}
</select>

StaffMapper

List<StaffVo> selectStaffVoTenantDemo(@Param("name") String name);

StaffService

List<StaffVo> selectStaffVoTenantDemo(String name);

StaffServiceImpl

@Override
public List<StaffVo> selectStaffVoTenantDemo(String name) {
    return staffMapper.selectStaffVoTenantDemo(name);
}

StaffController

@ApiOperation(value = "多租户使用demo,关联查询用户信息")
@GetMapping("/selectStaffVoTenantDemo")
public BaseResponse<List<StaffVo>> selectStaffVoTenantDemo(@RequestParam("name") String name) {
    // 模拟在上下文中放入租户id
    ThreadLocalManager.put(TenantConstant.KEY_TENANT_ID, "1");
    return BaseResponse.success(staffService.selectStaffVoTenantDemo(name));
}

5.开启多租户全局开关及指定租户ID字段

framework:
  multi-tenant:
    enable: true
    column: tenant_id
    ignore-table:
    - t_tenant_package
    - t_tenant
    - t_datasource
    - t_tenant_package_resource

6.启动用户服务,出现如下字样,说明多租户插件加载成功

tenant_load_success

7.请求接口

url:http://localhost:8367/user/staff/selectStaffVoTenantDemo?name=张三
method:GET

请求SQL如下

SELECT s.id, s.name, s.age, s.email, s.phone, o.id AS orgId, o.name AS orgName, o.code FROM staff s LEFT JOIN organization o ON s.org_id = o.id AND o.tenant_id = '1' WHERE s.name = '张三' AND s.tenant_id = '1'

请求结果

{
    "code": "200",
    "msg": "success",
    "data": [
        {
            "id": "9e9c735844b841c0a970decdb2b5e182",
            "name": "张三",
            "phone": "13812345678",
            "email": "zhangsan@qq.com",
            "age": 28,
            "orgId": "1",
            "orgName": "开发部",
            "orgCode": null
        }
    ],
    "success": true
}

# 数据源隔离使用示例

# 数据源配置

下边以查询员工信息为例演示数据源隔离插件使用步骤。

1.前端数据源管理页面添加3组数据源

data_source_manage

  1. 前往租户管理页面给租户1绑定数据源mysql,至此前端配置结束

tenant_manage_1

tenant_manage_2

点击保存按钮即可完成数据源的绑定,同时清空按钮也可完成租户与数据源的解绑操作。

tenant_manage_3

tenant_manage_4

3.服务端添加多租户maven依赖

<dependency>
    <groupId>com.mediway.hos</groupId>
    <artifactId>hos-framework-tenant-starter</artifactId>
</dependency>

4.数据库中默认数据源和3组租户数据源的staff员工信息表各添加一条员工记录,使用姓名加以区分

data_source_staff_default

data_source_staff_1

data_source_staff_3

data_source_staff_3

5.开启数据源隔离全局开关

framework:
  #多租户相关配置
  multi-tenant:
    #动态数据源开关,默认关闭
    dynamic-datasource: true

6.启动服务,若控制台看到如下日志则说明数据库隔离配置启动成功

data_source_load_success

7.请求接口

url:http://localhost:8367/user/staff/selectById?id=feec0d3941259b5648bcfdcc11581a18
method:GET

请求结果

{
"code": "200",
"msg": "success",
"data": {
    "id": "feec0d3941259b5648bcfdcc11581a18",
    "createTime": null,
    "updateTime": "2022-03-28 19:44:01",
    "current": null,
    "size": null,
    "name": "测试多租户数据源切换333",
    "gender": "女",
    "age": 28,
    "orgId": null,
    "email": "jingjing@qq.com",
    "phone": "13201122530",
    "description": "jingjing",
    "isDeleted": 0
},
"success": true
}

可以看到查询到了数据源3的员工信息

8.在员工信息controller类上添加排除注解@IgnoreDS,再次启动服务

@IgnoreDS
@Api(tags = "员工信息")
@RestController
@RequestMapping("/user/staff")
public class StaffController extends BaseController<Staff> {
    ...
}

9.再次请求该接口,结果如下

{
  "code": "200",
  "msg": "success",
  "data": {
      "id": "feec0d3941259b5648bcfdcc11581a18",
      "createTime": null,
      "updateTime": "2022-03-17 14:54:51",
      "current": null,
      "size": null,
      "name": "测试多租户数据源切换000",
      "gender": "女",
      "age": 28,
      "orgId": null,
      "email": "jingjing@qq.com",
      "phone": "13201122530",
      "description": "jingjing",
      "isDeleted": 0
  },
  "success": true
}

发现访问了默认数据源,说明排除注解已经生效。