基于Mybatis-Plus的多租户架构下的数据隔离解决方案

一、多租户架构

多租户(Multitenancy)架构即指同一套服务运行实例(代码相同、可多实例并行运行)下支持不同客户、组织同时进行操作,且不同客户、组织间的数据需要相互隔离,互不影响。多租户架构常见于SaaS解决方案中。相互隔离的数据可能包含DB数据、附件等等,本文重点讲解多租户架构下的DB数据隔离。

在多租户架构下,主要有以下3种的DB隔离方案。

注:
后文提到的数据库实例即对应部署实例,例如我们使用Docker启动了一个MySql数据库实例,
我们通过数据库连接工具(如Navicat)连接我们之前创建的MySql数据库实例,
可在此数据库实例中创建不同的数据库,也即后文提到的Schema
之后我们又用Docker再启动了一个MySql数据库实例等,此时即存在多个不同的Mysql数据库实例。
在这里插入图片描述

方案1:数据分区隔离(Partitioned (discriminator) data)

即使用同一个数据库实例、同一个数据库Schema,通过向数据表table添加租户标识列来区分数据,如下图向数据表中统一添加tenant_id,使用该tenant_id列来区分不同租户的数据,后续查询、操作数据时都需带上tenant_id这个列,也就是说所有的Sql语句都需要被修改以适配此多租户架构,如:

select * from my_table where ... and tenant_id = "myTenantIdVal";
insert into my_table(..., tenant_id) values(..., "myTenantIdVal");

此种方式也是最好实现的,即应用仅对应一套数据库连接池
但单库单Schema毕竟性能有限,租户不多且单租户数据量不大的场景下此模式比较适用。
在这里插入图片描述

方案2:数据库实例隔离(Separate database)

之前的方案1基于tenant_id列区分租户数据的方式可以理解为只是在逻辑上对数据进行了租户隔离,但是数据的存储并没有真正进行隔离。数据库实例隔离方式即通过每个租户对应一个数据库实例来对租户数据进行隔离,此种方式对租户数据进行了物理隔离,每个租户的数据都分别存储在各自的数据库实例中(各自的数据库实例都单独部署),相互不受影响,对DB进行操作时仅对租户的自己的DB实例进行操作,后续添加租户时仅需添加新的数据库实例,单个租户需要升级时仅需对自己的DB实例进行升级即可。

但此种方式需要应用为每个租户分别对应一套数据库连接池,也即需要应用支持多数据源及租户间的数据源切换(根据登录用户的租户ID动态切换到此租户对应的数据源),相较于方案1实现起来更复杂,以下场景可考虑使用数据库实例隔离方式:

  • 租户对数据隔离性要求比较高(物理隔离)
  • 单租户数据量非常大,出于性能考虑进行数据库实例物理隔离
  • 单租户DB实例支持配置升级(类似SaaS支持客户升级DB存储服务配置)
  • 多租户间数据库存储结构不同,或者 支持单独定制数据库存储结构

  • 在这里插入图片描述

方案3:Schema隔离(Separate schema)

相较于方案2的数据库实例隔离,Schema隔离方式仅使用一个数据库实例,但为每个租户各自创建独立的Schema,即多个租户共享数据库实例,但每个租户各自使用该数据库实例中单独的Schema。
此种隔离方案可通过如下2种方式定义数据库连接池:
方式1:方案2数据库实例隔离方式相同,即应用为每个租户各自创建一套连接池
方式2: 应用仅创建一套连接池,指向共同的一个数据库实例,后续通过SQL命令如SET SCHEMA来切换schema。
该方案可以理解为方案2数据库实例隔离的过渡方案,推荐使用方式1为每个租户创建一套连接池,方便后续无缝迁移到方案2。
在这里插入图片描述

混合使用

以上3种方案也可混合使用,例如

  • 单个Schema可以采用方案1 数据分区隔离,但是单个Schema服务的租户数量有限,例如单Schema仅支持3个租户,
  • 同一个数据库实例可以采用方案3 Schema隔离创建多个Schema,例如4核8G配置的数据库实例可以支持2个Schema,则每个数据库实例可以支持6个租户(2个Schema * 单Schema支持3个租户),
  • 而随着租户数量的增加,可以采用方案2 数据库实例隔离不断增加新的数据库实例 。

在这里插入图片描述

可以根据需要合理组合使用。

二、基于Mybatis-Plus的多租户数据分区隔离方案(方案1 - 逻辑隔离)

上面介绍了多租户的几种实现方案,接下来结合Mybatis-Plus生态给出具体的代码落地方案。

2.1 数据库规划

首先针对方案1 数据分区隔离(逻辑隔离) 的场景,即首先需要在DB中对租户间共享的数据表中添加租户标识列,如tenant_id,示例数据table定义如下:

CREATE TABLE `my_data` (
  `id` bigint(20) NOT NULL COMMENT '主键ID',
  `my_name` varchar(64) NOT NULL COMMENT '名称',
  `my_type` tinyint(4) NOT NULL COMMENT '类型',
  `my_version` int(4) NOT NULL DEFAULT '0' COMMENT '版本号',
  -- ===================================================
  -- 重点关注此tenant_id列
  `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
  -- ===================================================
  `created_time` datetime NOT NULL COMMENT '创建时间',
  `created_by` varchar(64) NOT NULL COMMENT '创建人',
  `modified_time` datetime NOT NULL COMMENT '修改时间',
  `modified_by` varchar(64) NOT NULL COMMENT '修改人',
  PRIMARY KEY (`id`) USING BTREE
) COMMENT='我的数据';

亦需单独新建一张表维护租户信息:

CREATE TABLE `my_tenant` (
  `id` bigint(20) NOT NULL COMMENT '主键ID',
  `tenant_name` varchar(64) NOT NULL COMMENT '租户名称',
  `tenant_desc` varchar(255) NOT NULL COMMENT '租户详情',
  `my_version` int(4) NOT NULL DEFAULT '0' COMMENT '版本号',
  `created_time` datetime NOT NULL COMMENT '创建时间',
  `created_by` varchar(64) NOT NULL COMMENT '创建人',
  `modified_time` datetime NOT NULL COMMENT '修改时间',
  `modified_by` varchar(64) NOT NULL COMMENT '修改人',
  PRIMARY KEY (`id`) USING BTREE
) COMMENT='我的租户';

2.2 数据库连接池配置

方案1 数据分区隔离(逻辑隔离)仅使用一个数据库实例,也即仅对应一套数据库连接池 即可,因此可以直接使用Spring DataSource实现即可,配置示例如下:

# 基础配置
spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/my_db?useUnicode=true&characterEncoding=utf-8&useSSL=false
    username: root
    password: 123456
    # Hikari 连接池配置
    hikari:
      # 最小空闲连接数量
      minimum-idle: 5
      # 空闲连接存活最大时间,默认600000(10分钟)
      idle-timeout: 180000
      # 连接池最大连接数,默认是10
      maximum-pool-size: 10
      # 此属性控制从池返回的连接的默认自动提交行为,默认值:true
      auto-commit: true
      # 连接池名称
      pool-name: MyHikariCP
      # 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认1800000即30分钟
      max-lifetime: 1800000
      # 数据库连接超时时间,默认30秒,即30000
      connection-timeout: 30000
      connection-test-query: SELECT 1

2.3 多租户处理代码集成

之后可以使用Mybatis Plus提供的多租户插件,来告诉Mybatis-Plus如何自动拦截并插入多租户处理SQL,示例代码如下:

/**
 * Mybatis-Plus配置
 *
 * @author luohq
 * @date 2022-08-07 11:00
 */
@Configuration
public class MybatisPlusConfig {
    
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
    
    
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
       	...
        //启用多租户插件
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantLineHandlerImpl()));
        return interceptor;
    }
}

---------------------------------------------------------------------------
/**
 * 租户处理器
 *
 * @author luohq
 * @date 2022-08-07 12:31
 */
@Slf4j
public class MyTenantLineHandlerImpl implements TenantLineHandler {
    
    
    /**
     * 租户ID请求头名称
     */
    public static final String TENANT_ID_HEADER = "X-TENANT-ID";
    /**
     * 默认租户ID
     */
    public static final Long DEFAULT_TENANT_ID = 1L;
    /**
     * 不进行租户处理的table
     */
    private static final List<String> IGNORE_TABLES = Arrays.asList("my_tenant");

    @Override
    public Expression getTenantId() {
    
    
        Long tenantId = Optional.ofNullable(HttpContextUtils.getRequestHeader(TENANT_ID_HEADER))
                .map(Long::valueOf)
                //.orElseThrow(() -> new RuntimeException("解析请求头中的X-TENANT-ID失败!"));
                .orElseGet(() -> {
    
    
                    log.info("解析请求头中的X-TENANT-ID失败 - 使用默认租户ID: {}", DEFAULT_TENANT_ID);
                    return DEFAULT_TENANT_ID;
                });
        return new LongValue(tenantId);
    }

    @Override
    public String getTenantIdColumn() {
    
    
        //默认tenant_id
        return "tenant_id";
    }

    @Override
    public boolean ignoreTable(String tableName) {
    
    
        //是否忽略此table的租户处理逻辑
        return IGNORE_TABLES.contains(tableName);
    }
}

如上代码中注册了TenantLineInnerInterceptor多租户行级处理过滤器,该过滤器依赖TenantLineHandler处理器实现,即MyTenantLineHandlerImpl 实现类,MyTenantLineHandlerImpl 实现类的主要职责如下:

  • getTenantId - 解析当前调用上下文中的租户ID,如提取Http请求头X-TENANT-ID的值即为租户ID
  • getTenantIdColumn - 设置数据库中数据表对应的租户标识列,即上面sql中my_data表格的tenant_id列
  • ignoreTable - 设置哪些表无需多租户逻辑处理,如一些跨租户的管理类表格,如my_tenant表

添加该多租户插件后,Mybatis-Plus框架即可帮我们自动向Sql中拼接tenant_id相关逻辑,示例代码如下:

/**
 * <p>
 * 我的数据 服务实现类
 * </p>
 *
 * @author luohq
 * @since 2022-08-07
 */
@Service
public class MyDataServiceImpl implements IMyDataService {
    
    
    @Resource
    private MyDataMapper myDataMapper;

    @Override
    public MyData findById(Long id) {
    
    
        //BaseMapper.selectById - 支持自动拼接租户Id参数
        //select .. where ... and tenant_id = ?
        return this.myDataMapper.selectById(id);
    }

    @Override
    public MyData findByQuery(MyDataQueryDto myDataQueryDto) {
    
    
        //QueryWrapper - 支持自动拼接租户Id参数
        //select .. where ... and tenant_id = ?
        return this.myDataMapper.selectOne(Wrappers.<MyData>lambdaQuery()
                .eq(Objects.nonNull(myDataQueryDto.getId()), MyData::getId, myDataQueryDto.getId())
                .like(StringUtils.hasText(myDataQueryDto.getMyName()), MyData::getMyName, myDataQueryDto.getMyName()));
    }

    @Override
    public MyData findByName(String myName) {
    
    
        //mapper.xml自定义查询 - 支持自动拼接租户Id参数
        //select .. where name like '%...%' and tenant_id = ?
        return this.myDataMapper.selectByName(myName);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Integer addData(MyData myData) {
    
    
        //BaseMapper.insert - 支持自动设置tenantId
        //myData.tenantId可无需设置,由多租户插件负责自动填充tenant_id值
        //insert into my_data(... , tenant_id) values(... , ?)
        Integer retCount = this.myDataMapper.insert(myData);
        return retCount;
    }
}

如此便实现了 方案1 数据分区隔离(逻辑隔离) 的多租户架构。
该方案的具体实现代码可参见:
https://gitee.com/luoex/multi-datasource-demo/tree/master/mp-dynamic-ds

三、基于Mybatis-Plus及Dynamic-Datasource的多租户数据库实例隔离方案(方案2、方案3 - 物理隔离)

方案1 数据分区隔离仅使用了一套连接池,而方案2 数据库实例隔离 需要支持多数据源管理,因此可结合Mybatis-Plus的多数据源扩展Dynamic-Datasource模块来实现。

注:
关于Mybatis-Plus的多数据源扩展Dynamic-Datasource模块的相关介绍可以参见我之前的博客:
《基于Mybatis及Mybatis-Plus的多数据源解决方案》

3.1 数据库规划

我们可以先假设有2个租户:租户1(tenantId=1),租户2(tenantId=2)
每个租户分别对应一个数据库实例:db1,db2
每个租户对应的数据库实例中均创建一个对应的Schema:db1.schema_biz, db2.shcema_biz
且存在主数据库实例用来管理租户信息:dbMaster
即如下图最左侧的3个数据库实例规划,但实际本地测试时仅安装了一个数据库实例,
由于是本地测试,所以将3个数据库实例合并到一起,如中间所示同一个数据库实例中存在3个Schema,
进一步也可以将schema_admin和schema_biz1进行合并,最终效果如下图最右边所示
(仅仅是为了方便开发测试才进行的合并,实际项目可无需合并)
同时即便采用下图最右边的方式,若采取为每个租户单独对应一套数据库连接池的方式,即使后续再拆分成多数据库实例也是兼容的。

在这里插入图片描述
最后合并后(上图中最右侧方式)
db1.schema_biz1同时作为主数据库和租户1的业务数据库,
db1.shcema_biz2则作为租户2的业务数据库,

注:
实际示例代码中使用的
schema_biz1命名为multi-ds-1,
schema_biz2命名为multi-ds-2

我们可以在主数据库db1.schema_biz1中新建租户信息表my_tenant如下:

CREATE TABLE `my_tenant` (
  -- 租户ID,也即对应租户标识
  `id` bigint(20) NOT NULL COMMENT '主键ID',
  -- 租户相关信息
  `tenant_name` varchar(64) NOT NULL COMMENT '租户名称',
  `tenant_desc` varchar(255) NOT NULL COMMENT '租户详情',
  -- 租户对应的数据源连接信息
  `db_url` varchar(128) DEFAULT NULL COMMENT '租户数据库URL',
  `db_username` varchar(128) DEFAULT NULL COMMENT '租户数据库用户名',
  `db_password` varchar(128) DEFAULT NULL COMMENT '租户数据库密码',
  `db_driver_class_name` varchar(128) DEFAULT NULL COMMENT '租户数据库驱动类',
  -- 其他辅助信息
  `my_version` int(4) NOT NULL DEFAULT '0' COMMENT '版本号',
  `created_time` datetime NOT NULL COMMENT '创建时间',
  `created_by` varchar(64) NOT NULL COMMENT '创建人',
  `modified_time` datetime NOT NULL COMMENT '修改时间',
  `modified_by` varchar(64) NOT NULL COMMENT '修改人',
  PRIMARY KEY (`id`) USING BTREE
) COMMENT='我的租户';


INSERT INTO my_tenant ( id, tenant_name, tenant_desc, db_url, db_username, db_password, db_driver_class_name, my_version, created_time, created_by, modified_time, modified_by )
VALUES
	( 1, '租户1', '租户1说明', 
	'jdbc:mysql://localhost:3306/multi-ds-1?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8', 'root', '123456', 'com.mysql.cj.jdbc.Driver', 
	0, '2022-08-06 10:36:31', 'luo', '2022-08-06 10:36:37', 'luo' ),
	( 2, '租户2', '租户2说明', 
	'jdbc:mysql://localhost:3306/multi-ds-2?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8', 'root', '123456', 'com.mysql.cj.jdbc.Driver', 
	0, '2022-08-06 10:36:58', 'luo', '2022-08-06 10:37:04', 'luo' );


然后在各租户业务数据库db1.schema_biz1, db1.schema_biz2中新建业务表my_data如下:

CREATE TABLE `my_data` (
  `id` bigint(20) NOT NULL COMMENT '主键ID',
  `my_name` varchar(64) NOT NULL COMMENT '名称',
  `my_type` tinyint(4) NOT NULL COMMENT '类型',
  `my_version` int(4) NOT NULL DEFAULT '0' COMMENT '版本号',
  -- 此tenant_id非必需,若有混合使用多租户模式的场景,可定义此字段,
  -- 如先按数据库实例拆分多租户,然后单数据库实例中再对租户数据进行数据分区拆分(根据tenant_id进行逻辑拆分)
  -- `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
  `created_time` datetime NOT NULL COMMENT '创建时间',
  `created_by` varchar(64) NOT NULL COMMENT '创建人',
  `modified_time` datetime NOT NULL COMMENT '修改时间',
  `modified_by` varchar(64) NOT NULL COMMENT '修改人',
  PRIMARY KEY (`id`) USING BTREE
) COMMENT='我的数据';

3.2 数据库连接池配置

由于采用 数据库实例隔离(或 Schema隔离) 的方案 需使用多套数据库连接池,即每个租户对应一套连接池,因此需要集成Mybatis-Plus及Dynamic-Datasource扩展来支持根据租户Id动态切换数据源,可以先默认仅加载主数据源(即对应租户管理),配置示例如下:

spring:
  datasource:
    dynamic:
      primary: master #设置默认数据源为主数据源
      strict: true #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
      hikari: # 全局hikariCP参数,所有值和默认保持一致。(现已支持的参数如下,不清楚含义不要乱设置)
        connection-timeout: 30000
        max-pool-size: 10
        min-idle: 5
        idle-timeout: 180000
        max-lifetime: 1800000
        connection-test-query: SELECT 1
      datasource:
        master: # 主数据源(用于管理租户信息等)
          url: jdbc:mysql://localhost:3306/multi-ds-1?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
          username: root
          password: 123456
          driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
          hikari: # 当前数据源HikariCP参数(继承全局、部分覆盖全局)
            max-pool-size: 20

3.3 多租户处理代码集成

之后在程序运行时动态添加、切换租户对应的数据源,在Mybatis-Plus及Dynamic-Datasource扩展实现中,可通过如下方式手动切换数据源:

DynamicDataSourceContextHolder.push("数据源名称");

同时可通过如下方式在程序运行时动态添加、移除数据源:

@Resource
private DynamicRoutingDataSource dataSource;
@Resource
private DefaultDataSourceCreator dataSourceCreator;
...

/** 生成数据源 */
DataSourceProperty dataSourceProperty = new DataSourceProperty();
dataSourceProperty.setUrl("jdbc:mysql://localhost:3306/multi-ds-1?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8");
dataSourceProperty.setUsername("root");
dataSourceProperty.setPassword("123456");
dataSourceProperty.setDriverClassName("com.mysql.cj.jdbc.Driver");
//使用DefaultDataSourceCreator创建数据源,
//DefaultDataSourceCreator聚合了已存在(存在对应连接池Class)的连接池创建器,
//然后依次按JNDI、DRUID、HIKARI、BEECP、DBCP2、BASIC的顺序创建数据源(使用第一个存在的连接池Class去创建)
//可通过如下HikariCpConfig自定义HikariCP连接池配置
//dataSourceProperty.setHikari(new HikariCpConfig());
//其他如自定义Druid连接池配置
//dataSourceProperty.setDruid(new DruidConfig());
DataSource dataSource = this.dataSourceCreator.createDataSource(dataSourceProperty);

/** 动态添加数据源 */
dataSource.addDataSource("newDs", newDataSource);

/** 动态移除数据源(移除时会自动调用关闭连接池) */
dataSource.removeDataSource("newDs");

在多租户架构中,需要提取调用上线文中用户对应的租户ID,然后根据租户ID查询租户信息及其对应的数据库连接信息,如登录用户将用户信息中的租户ID放到请求头X-TENANT-ID中,之后发送的每个请求都会携带此X-TENANT-ID请求头,后端服务通过拦截器提取此请求头即可获取用户对应的租户ID,然后根据此租户ID切换对应的数据源。
拦截租户ID请求头的拦截器示例代码如下:

/**
 * web相关配置
 *
 * @author luohq
 * @date 2021-12-24 12:38
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    

    @Resource
    private TenantDsInterceptor tenantDsInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    
    
        //注册租户切换数据源拦截器
        registry.addInterceptor(this.tenantDsInterceptor);
    }
}

----------------------------------------------------------------------------------------

/**
 * 租户切换数据源拦截器
 *
 * @author luohq
 * @date 2022-08-08
 */
@Slf4j
@Component
public class TenantDsInterceptor implements HandlerInterceptor {
    
    

    @Resource
    private ITenantDsService tenantDsService;

    /**
     * 在请求处理之前进行调用(Controller方法调用之前)
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    
    
        String requestURI = request.getRequestURI();
        log.info("经过多数据源Interceptor,当前路径是{}", requestURI);
        //String headerDs = request.getHeader("ds");
        //Object sessionDs = request.getSession().getAttribute("ds");
        String tenantId = request.getHeader(TenantContext.TENANT_ID_HEADER);
        //若tenantId为空,则使用默认数据源
        if (!StringUtils.hasText(tenantId)) {
    
    
            log.warn("cur request tenant id header val is null!");
            tenantId = TenantContext.DEFAULT_TENANT_ID;
        }
        //根据tenantId切换数据源
        this.tenantDsService.changeDsByTenantId(tenantId);
        return true;
    }

    /**
     * 请求处理之后进行调用,但是在视图被渲染之前(Controller方法调用之后)
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
    
    

    }

    /**
     * 在整个请求结束之后被调用,也就是在DispatcherServlet 渲染了对应的视图之后执行(主要是用于进行资源清理工作)
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    
    
        //清空当前线程数据源
        this.tenantDsService.clearDsContext();
    }

}

--------------------------------------------------------------------------------------

/**
 * 租户上下文
 *
 * @author luohq
 * @version 2022-08-08
 */
public class TenantContext {
    
    

    public static String TENANT_ID_HEADER = "X-TENANT-ID";
    public static String DEFAULT_TENANT_ID = "1";

    private static ThreadLocal<String> tenantLocal = ThreadLocal.withInitial(() -> DEFAULT_TENANT_ID);

    public TenantContext() {
    
    
    }

    public static String getTenant() {
    
    
        return tenantLocal.get();
    }

    public static void setTenant(String tenant) {
    
    
        tenantLocal.set(tenant);
    }

    public static void remove() {
    
    
        tenantLocal.remove();
    }
}

上述代码TenantDsInterceptor拦截器实现中调用了ITenantDsService来实现切换数据源,ITenantDsService根据租户ID查询租户信息及其对应的数据源连接信息,同时将租户ID作为数据源名称,并将租户信息中的数据源连接信息封装成对应的DataSource并动态切换数据源,ITenantDsService的核心示例代码如下:

/**
 * 租户切换数据源 - 服务实现类
 *
 * @author luohq
 * @date 2022-08-08 16:54
 */
@Service
@Slf4j
public class TenantDsServiceImpl implements ITenantDsService {
    
    

    @Resource
    private DynamicRoutingDataSource dataSource;
    @Resource
    private DefaultDataSourceCreator dataSourceCreator;

    @Resource
    private IMyTenantService myTenantService;

    /**
     * 根据租户ID切换数据源
     *
     * @param tenantId 租户ID
     */
    @Override
    public void changeDsByTenantId(String tenantId) {
    
    
        //当前租户ID对应的数据源已存在,则直接切换
        if (this.existDsInMemory(tenantId)) {
    
    
            //切换数据源
            this.changeTenantDs(tenantId);
            return;
        }

        //若当前租户ID对应的数据源在内存中不存在,则通过租户ID查询租户对应的数据源连接信息
        DataSource dataSource = this.convertTenantIdToDataSource(tenantId);
        //租户对应的数据源连接信息存在,则动态添加数据源并切换
        if (null != dataSource) {
    
    
            //动态添加数据源
            this.dataSource.addDataSource(tenantId, dataSource);
            //切换数据源
            this.changeTenantDs(tenantId);
            return;
        }
        //否则数据源信息不存在,则使用默认数据源 或者 抛出异常结束处理流程
        //throw new RuntimeException("租户ID[" + tenantId + "]对应的租户信息不存在!");
    }

    /**
     * 切换租户对应的数据源
     *
     * @param tenantId 租户ID即对应数据源名称
     */
    private void changeTenantDs(String tenantId) {
    
    
        log.debug("切换数据源:{}", tenantId);
        //设置租户上下文
        TenantContext.setTenant(tenantId);
        //根据tenantId切换数据源
        DynamicDataSourceContextHolder.push(tenantId);
    }

    /**
     * 根据租户ID查询数据源连接信息,并生成数据源
     *
     * @param tenantId
     * @return
     */
    private DataSource convertTenantIdToDataSource(String tenantId) {
    
    
        MyTenant myTenant = null;
        log.debug("find db tenant info by tenantId:{}, result: {}", tenantId, myTenant);
        //租户为空则直接返回空
        if (!StringUtils.hasText(tenantId)
                || null == (myTenant = this.myTenantService.getById(Long.valueOf(tenantId)))) {
    
    
            return null;
        }

        DataSourceProperty dataSourceProperty = new DataSourceProperty();
        dataSourceProperty.setUrl(myTenant.getDbUrl());
        dataSourceProperty.setUsername(myTenant.getDbUsername());
        dataSourceProperty.setPassword(myTenant.getDbPassword());
        dataSourceProperty.setDriverClassName(myTenant.getDbDriverClassName());
        //当前工程中仅提供HikariCP连接池依赖,所以默认使用DefaultDataSourceCreator -> HikariDataSourceCreator进行创建
        //可通过如下HikariCpConfig定制连接池配置
        //dataSourceProperty.setHikari(new HikariCpConfig());
        //其他如使用Druid连接池配置
        //dataSourceProperty.setDruid(new DruidConfig());
        DataSource dataSource = this.dataSourceCreator.createDataSource(dataSourceProperty);
        return dataSource;
    }

    /**
     * 当前应用是否已在内存中加载过此数据源
     *
     * @param dsName 数据源名称
     * @return
     */
    @Override
    public Boolean existDsInMemory(String dsName) {
    
    
        return StringUtils.hasText(dsName) && this.dataSource.getDataSources().containsKey(dsName);
    }

    /**
     * 清理当前调用上下文中的数据源缓存
     */
    @Override
    public void clearDsContext() {
    
    
        //清空当前线程数据源
        DynamicDataSourceContextHolder.clear();
        TenantContext.remove();
    }

    /**
     * 移除对应的数据源信息
     *
     * @param dsName 数据源名称
     */
    @Override
    public void removeDs(String dsName) {
    
    
    	//动态移除数据源
        this.dataSource.removeDataSource(dsName);
    }

}

该方案的具体实现代码可参见:
https://gitee.com/luoex/multi-datasource-demo/tree/master/mp-dynamic-ds-tenant

3.4 扩展

以上代码仅做示例用途,实际开发多租户数据源切换时还需结合具体业务需求:

  • 如以上代码中若租户ID不存在(即在DB中查询不到记录),则不手动切换数据源,即使用默认master数据源
  • 若默认数据源(主管理库)不允许租户进行操作,则需在租户不存在时抛出异常,结束业务流程
  • 同时在租户对应的数据源信息(my_tenant表信息)被修改时,之前已在应用内存中加载过的数据源信息则需要被重新加载
    • 可考虑接入MQ,在某个应用实例修改完租户数据源信息时,发送数据源同步消息,其他应用实例接收到此消息后,强制刷新数据源信息,如通过dynamicRoutingDataSource.removeDataSource(tenantId)移除内存中的数据源,后续该租户再次请求时会自动触发重新加载新的数据源。
  • 若请求携带DB中不存在的X-TENANT-ID请求头,则由于X-TENANT-ID对应的租户及数据源信息不存在,则每次都需要通过X-TENANT-ID查询数据库中的租户信息,恶意攻击 或 频繁请求 会导致DB查询压力变大,此处可考虑将myTenantService.getById集成到缓存中
    • 注意缓存同步问题,即修改、删除租户信息时需同步修改、删除缓存中的租户信息
    • 注意缓存穿透问题,可缓存Null租户信息
  • 目前是一个租户对应一套连接池,若存在混合多租户架构的场景,如租户1、租户2、租户3共用一个数据库实例、共用该数据库实例下的同一个Schema,此时这3个租户可共用同一套数据库连接池,此场景下可再单独维护数据源表,然后这3个租户关联相同的数据源ID,后续程序根据租户ID查出关联的数据源ID,后续加载及切换数据源时把数据源ID作为数据源名称即可,由于这三个租户关联的数据源ID相同,所以这三个租户最终使用的数据库连接池也为同一套。
    CREATE TABLE `my_tenant` (
    -- 租户ID,也即对应租户标识
    `id` bigint(20) NOT NULL COMMENT '主键ID',
    -- 租户相关信息
    `tenant_name` varchar(64) NOT NULL COMMENT '租户名称',
    `tenant_desc` varchar(255) NOT NULL COMMENT '租户详情',
    -- 租户关联的数据源ID
    `datasource_id` bigint(20) DEFAULT NULL COMMENT '租户关联的数据源ID',
    -- 其他辅助信息
    `my_version` int(4) NOT NULL DEFAULT '0' COMMENT '版本号',
    `created_time` datetime NOT NULL COMMENT '创建时间',
    `created_by` varchar(64) NOT NULL COMMENT '创建人',
    `modified_time` datetime NOT NULL COMMENT '修改时间',
    `modified_by` varchar(64) NOT NULL COMMENT '修改人',
    PRIMARY KEY (`id`) USING BTREE
    ) COMMENT='我的租户';
    
    CREATE TABLE `my_datasource` (
    -- 数据源ID
    `id` bigint(20) NOT NULL COMMENT '主键ID',
    -- 数据源连接信息
    `db_url` varchar(128) DEFAULT NULL COMMENT '租户数据库URL',
    `db_username` varchar(128) DEFAULT NULL COMMENT '租户数据库用户名',
    `db_password` varchar(128) DEFAULT NULL COMMENT '租户数据库密码',
    `db_driver_class_name` varchar(128) DEFAULT NULL COMMENT '租户数据库驱动类',
    -- 其他辅助信息
    `my_version` int(4) NOT NULL DEFAULT '0' COMMENT '版本号',
    `created_time` datetime NOT NULL COMMENT '创建时间',
    `created_by` varchar(64) NOT NULL COMMENT '创建人',
    `modified_time` datetime NOT NULL COMMENT '修改时间',
    `modified_by` varchar(64) NOT NULL COMMENT '修改人',
    PRIMARY KEY (`id`) USING BTREE
    ) COMMENT='我的数据源';
    
  • 数据库实例隔离、数据分区隔离也可以混合使用,例如使用上面提到的Mybatis-Plus多数据源模块Dynamic-Datasource实现数据库实例隔离(物理隔离),进入到具体的数据源后可再集成Mybatis-Plus多租户插件TenantLineInnerInterceptor实现数据分区隔离(根据tenant_id逻辑隔离),只要保证多数据源实现时的拦截器和多租户插件中获取到的租户ID相同即可,如TenantDsInterceptor拦截器获取请求头X-TENANT-ID中的租户ID,然后将此租户ID放到TenantContext(ThreadLocal实现)中,后续多租户插件TenantLineHandler实现中直接通过TenantContext获取租户ID。

参考:

Hibernate ORM:
https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#multitenacy

Mybatis-Plus:
Mybatis Plus - 多租户插件
Mybatis Plus多数据源扩展

猜你喜欢

转载自blog.csdn.net/luo15242208310/article/details/126264670