相关推荐recommended
spring+Druid+mybatils多租户下动态切换数据源
作者:mmseoamin日期:2024-03-20

什么是多租户

举个例子:蔡徐坤、展亚鹏和范小勤三个人去租房子,他们因为家里经济困难所以勤工俭学,三个人决定合租一套三室一厅的房子,虽然每个人有自己的房间,但是家里的水电、厨房、卫生间和热水器都是大家一起公用的。隐私性肯定是没有单独自己租房子来的高。

在多租户的架构里,多个租户共享相同的服务器、基础设施,数据库可以是共享的也可以是隔离的,由于多租户必定在用户规模上比单租户来的大,所以多租户一般会有多个实例,共用一套实例代码。租户之间的数据隔离往往采用逻辑隔离的方式,即在代码和数据库层面隔离,所以安全性远没有单租户来的高。

就比如上面举的例子,虽然三人都租有自己单独的房间,但房子里的的厨房、卫生间和洗衣机都是大家一起公用的。从方便和隐私的角度来看,都不如自己一个房子好。

在系统中,多租户体现为,多个租户共用一个或多个服务器、基础设施,数据库可以是共享也可以是隔离的,多个租户共用一套代码,或者在微服务中共用一个或者几个模块,租户和租户之间实现数据的隔离,但是安全性远不如单租户。但是其维护、修改成本都比单租户更低,因此如果系统是对安全性要求不这么高、定制性不这么强的系统,多租户是很好的一个方案。但对于一些大型网站、或者安全性需求强的网站,最好还是不用多租户。大厂的项目更多还是定制化开发,而中小厂为了节约成本可能会采用多租户。

多租户数据隔离的实现方式

基于字段隔离

在每一个表上都添加上租户id,所有数据都在一个库,查询时动态拼接租户id到sql。

优点:开发成本低,添加租户不需要做额外逻辑,跨租户逻辑简单

缺点:隔离程度最低,安全性最差,维护成本高,各租户数据耦合严重,维护成本高,每次的sql语句都需要拼接租户id,每个租户的数据量不能过大(可以后期分库分表)

基于表隔离

在表名上添加对应的租户信息,或使用视图进行数据过滤

优点:开发成本较低,隔离性相对较好,可以拥有相对较大的数据量

缺点:跨租户逻辑复杂,维护成本相对较高

基于库隔离

优点:隔离性最强,安全性最高,后期维护或者新增需求需要成本较小,灵活性更高

缺点:开发成本大,跨租户统计困难,新增租户时逻辑较复杂

基于mybatis实现基于库的数据分离

依赖:

        
            org.springframework.boot
            spring-boot-starter-web
        
       
            org.mybatis
            mybatis
            3.5.7
        
        
            mysql
            mysql-connector-java
            8.0.27 
        
        
            com.alibaba
            druid-spring-boot-starter
            1.2.6
        

实体类:

对应数据库表,存放数据库的基本信息和租户id,可以通过驱动类型实现不同数据源使用不同的数据库,例如a使用mysql,b使用postgresql

package com.zy.saas.domian;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
/**
 * @author: Larry
 * @Date: 2024 /01 /28 / 2:58
 * @Description:
 */
@Data
@Builder
@Accessors(chain = true)
@TableName("datasource")
public class Datasource {
    private Integer id;
    /**
     * 数据库地址
     */
    private String url;
    /**
     * 数据库用户名
     */
    private String username;
    /**
     * 密码
     */
    private String password;
    /**
     * 数据库驱动
     */
    private String driverClassName;
    /**
     * 数据库key,即保存Map中的key
     */
    private String name;
    /**
     * 租户id
     */
    private Integer tenantId;
}

核心类:

存放当前用户数据库url的线程变量

package com.zy.saas.Context;
/**
 * @author: Larry
 * @description:
 **/
public class DataSourceContextHolder {
    //此类提供线程局部变量。这些变量不同于它们的正常对应关系是每个线程访问一个线程(通过get、set方法),有自己的独立初始化变量的副本。
    private static final ThreadLocal DATASOURCE_HOLDER = new ThreadLocal<>();
    /**
     * 设置数据源
     * @param dataSourceName 数据源名称
     */
    public static void setDataSource(String dataSourceName){
        DATASOURCE_HOLDER.set(dataSourceName);
    }
    /**
     * 获取当前线程的数据源
     * @return 数据源名称
     */
    public static String getDataSource(){
        return DATASOURCE_HOLDER.get();
    }
    /**
     * 删除当前数据源
     */
    public static void removeDataSource(){
        DATASOURCE_HOLDER.remove();
    }
}

 将主库的数据源信息根据yml导入,即默认数据源为yml里面配置的数据源

package com.zy.saas.config;
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import com.zy.saas.DynamicDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Primary;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
/**
 * @author: Larry
 * @description: 设置数据源
 **/
@Configuration
public class DateSourceConfig {
    @Bean
    @ConfigurationProperties("spring.datasource.druid.master")
    public DataSource masterDataSource(){
        return DruidDataSourceBuilder.create().build();
    }
    @Bean(name = "dynamicDataSource")
    @Primary
    public DynamicDataSource createDynamicDataSource(){
        Map dataSourceMap = new HashMap<>();
        DataSource defaultDataSource = masterDataSource();
        dataSourceMap.put("master",defaultDataSource);
        return new DynamicDataSource(defaultDataSource,dataSourceMap);
    }
}

  DynamicDataSource(动态数据源)是指在应用程序中根据需要动态切换数据源的机制。

  通过这个类实现了对所有数据库信息的校验,保存。

package com.zy.saas;
import com.alibaba.druid.pool.DruidDataSource;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.zy.saas.Context.DataSourceContextHolder;
import com.zy.saas.domian.Datasource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
 * @author: Larry
 * @description: 实现动态数据源,根据AbstractRoutingDataSource路由到不同数据源中
 **/
@Slf4j
public class DynamicDataSource extends AbstractRoutingDataSource {
    private final Map targetDataSourceMap;
    public DynamicDataSource(DataSource defaultDataSource, Map targetDataSources){
        super.setDefaultTargetDataSource(defaultDataSource);
        super.setTargetDataSources(targetDataSources);
        this.targetDataSourceMap = targetDataSources;
    }
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.getDataSource();
    }
    /**
     * 添加数据源信息
     *
     * @param dataSources 数据源实体集合
     */
    public void createDataSource(List dataSources){
        try {
            if (CollectionUtils.isNotEmpty(dataSources)){
                for (Datasource ds : dataSources) {
                    //校验数据库是否可以连接
                    Class.forName(ds.getDriverClassName());
                    DriverManager.getConnection(ds.getUrl(),ds.getUsername(),ds.getPassword());
                    //定义数据源
                    DruidDataSource dataSource = new DruidDataSource();
                    BeanUtils.copyProperties(ds,dataSource);
                    //申请连接时执行validationQuery检测连接是否有效,这里建议配置为TRUE,防止取到的连接不可用
                    dataSource.setTestOnBorrow(true);
                    //建议配置为true,不影响性能,并且保证安全性。
                    //申请连接的时候检测,如果空闲时间大于timeBetweenEvictionRunsMillis,执行validationQuery检测连接是否有效。
                    dataSource.setTestWhileIdle(true);
                    dataSource.init();
                    this.targetDataSourceMap.put(ds.getName(),dataSource);
                }
                super.setTargetDataSources(this.targetDataSourceMap);
                // 将TargetDataSources中的连接信息放入resolvedDataSources管理
                super.afterPropertiesSet();
            }
        }catch (ClassNotFoundException | SQLException e) {
            log.error("---程序报错---:{}", e.getMessage());
        }
    }
    /**
     * 校验数据源是否存在
     * @param key 数据源保存的key
     * @return 返回结果,true:存在,false:不存在
     */
    public boolean existsDataSource(String key){
        return Objects.nonNull(this.targetDataSourceMap.get(key));
    }
}

  spring监听器,在spring后启动时自动触发一次,调用DynamicDataSource将数据源信息添加到

  targetDataSourceMap里面

package com.zy.saas.config;
import com.zy.saas.DynamicDataSource;
import com.zy.saas.Mapper.DataSourceMapper;
import com.zy.saas.Service.UserService;
import com.zy.saas.domian.Datasource;
import com.zy.saas.util.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import javax.sql.DataSource;
import java.util.List;
/**
 *
 * @author Larry
 */
@Component
@Slf4j
public class ContentRefreshedEventListener implements ApplicationListener {
    @Resource
    private DynamicDataSource dynamicDataSource;
    @Resource
    private DataSourceMapper dataSourceMapper;
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        List dataourceList = dataSourceMapper.getListAll();
        System.out.println(dataourceList);
        if (!CollectionUtils.isEmpty(dataourceList)) {
               dynamicDataSource.createDataSource(dataourceList);
        }
    }
}

 主要思路

首先项目启动后,将所有租户的数据源信息通过Listener调用一次DynamicDataSource的createDataSource方法,将数据源信息存储到targetDataSourceMap里面,登陆时拦截器首先判断用户具体属于哪一个租户,获取租户id后,根据租户id判断出其所属的数据源,然后调用线程变量的set方法实现切换(默认数据源是在配置类里面配置的主库),当切换完成后调用remove防止不同线程变量出现访问错误。

注意事项

启动类上需要加上

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)

否则会报循环依赖,如下

spring+Druid+mybatils多租户下动态切换数据源,a4297472685d482ea051293c9cca450b.png,第1张

原因是org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration会引入一个Registrar注册了一个后置处理器,这个注册过程其实org.springframework.context.annotation.ConfigurationClassPostProcessor#postProcessBeanDefinitionRegistry中完成的。这个后置处理器会在所有DataSource类型的Bean的初始化后进行处理,此时会去获取DataSourceInitializerInvoker类型的bean.而这个DataSourceInitializerInvoker类型的bean又会依赖DataSource,导致循环依赖。而这个bean其实作用是执行一些脚本的,可以不要,注册一个BeanDefinitionRegistryPostProcessor移除对应的后置处理器,这样在数据源初始化的时候就不会去获取DataSourceInitializerInvoker了。

这个当时卡了我很长时间

结语

本人能力有限,实现方式可能不是最优方法,希望有更好的方法的大佬可以在评论区提出来,大家发现我的错误或者有疑问的地方,可以在评论区@我