springBoot + mybatisPlus 多数据源 实现 saas系统,解决多数据源切换可能会出现异常

springBoot + mybatisPlus 多数据源 实现 saas系统

最近在做的his系统是saas模式,使用的医院id作为所有数据的区分条件,这个应该是目前比较好的实现办法了,毕竟好维护,开发也比较方便。

之前在开放的时候突然想到用多数据源实现saas模式试一下。

文章底部有整个demo的代码

saas主库

多数据源实现saas的大概思路是,在一个主数据库里维护一下saas的信息,比如数据库,数据库地址,账号密码什么的

create table  saas_main (
    id bigint primary key  comment '雪花id',
    name varchar(16) comment '租户名称',
    type int comment '数据库类型',
    driver_class_name varchar(256) comment '驱动',
    jdbc_url varchar(1024) comment '数据库链接地址',
    username varchar(256) comment '数据库账户',
    password varchar(256) comment '数据库密码',
    data_base varchar(256) comment '数据库'
);

具体业务逻辑数据库

使用多数据源实现不同的数据库对应不同的用户

-- 数据库1
create database saas_1;
use saas_1;
CREATE TABLE `user`
(
    `id`   int(11) primary key auto_increment,
    `name` varchar(255) comment '姓名'
)

-- 数据库2
create database saas_2;
use saas_2;
CREATE TABLE `user`
(
    `id`   int(11) primary key auto_increment,
    `name` varchar(255) comment '姓名'
)

-- 数据库3
create database saas_3;
use saas_3;
CREATE TABLE `user`
(
    `id`   int(11) primary key auto_increment,
    `name` varchar(255) comment '姓名'
)

pom

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.7.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>


    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.dromara.stream-query</groupId>
            <artifactId>stream-plugin-mybatis-plus</artifactId>
            <version>2.0.3</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>6.0.6</version>
        </dependency>

        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.4</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
</project>

yaml配置文件

这里只设置了saas_main 主saas库的地址,注意这里是 **jdbc-url ** 不是 url

参考:为什么是jdbc-url : springboot多数据源配置和使用-腾讯云开发者社区-腾讯云 (tencent.com)

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    jdbc-url: jdbc:mysql://localhost:3306/saas_main?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&verifyServerCertificate=false&useSSL=false
    username: root
    password: 123456

ThreadLocal 多数据源切换类

import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

/**
 * 动态数据源管理
 */
public class DataSourceContextHolder {
    private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>() {
        /**
         * 设置默认数据源的key
         */
        @Override
        protected String initialValue() {
            return "default";
        }
    };
		
    /**
     * 数据源key的集合
     */
    public static Set<Object> dataSourceKeys = new HashSet<>();

    /**
     * 切换数据源
     */
    public static void setDataSourceKey(String key) {
        contextHolder.set(key);
    }

    /**
     * 获取数据源
     */
    public static String getDataSourceKey() {
        return contextHolder.get();
    }

    /**
     * 重置数据源
     */
    public static void clearDataSourceKey() {
        contextHolder.remove();
    }

    /**
     * 判断数据源是否存在
     *
     * @param key 数据源key
     */
    public static boolean containDataSourceKey(String key) {
        return dataSourceKeys.contains(key);
    }

    /**
     * 添加数据源key
     */
    public static boolean addDataSourceKey(Object key) {
        return dataSourceKeys.add(key);
    }

    /**
     * 添加多个数据源keys
     */
    public static boolean addDataSourceKeys(Collection<? extends Object> keys) {
        return dataSourceKeys.addAll(keys);
    }
}

重写 AbstractRoutingDataSource

spring在准备执行sql时都会通过这个抽象的实现类来获取数据源

如果在多线程的情况下去切换数据源可能会出现异常,可以使用ConcurrentHashMap进行替换

参考

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.stereotype.Component;

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

/**
 * 自定义动态数据源
 */
public class DynamicDataSource extends AbstractRoutingDataSource {

    private Map<Object, Object> dataSources = new HashMap<>();

    /**
     * 获取当前数据源的键、
     * 每次准备执行sql都会调用此方法获取连接的key
     */
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.getDataSourceKey();
    }

    /**
     * 获取当前数据源
     * 每次准备执行sql都会调用此方法获取连接
     */
    @Override
    protected DataSource determineTargetDataSource() {
        return super.determineTargetDataSource();
    }

    /**
     * 设置默认数据源
     *
     * @param defaultDataSource
     */
    public void setDefaultDataSource(Object defaultDataSource) {
        super.setDefaultTargetDataSource(defaultDataSource);
    }


    /**
     * 设置数据源
     *
     * @param dataSources
     */
    public void setDataSources(Map<Object, Object> dataSources) {
        this.dataSources = dataSources;
        super.setTargetDataSources(dataSources);
        // 保存数据源的key
        DataSourceContextHolder.addDataSourceKeys(dataSources.keySet());
    }

    /**
     * 追加数据源
     *
     * @param key
     * @param dataSource
     */
    public void addDataSource(String key, DataSource dataSource) {
        dataSources.put(key, dataSource);
        super.setTargetDataSources(dataSources);
        // 保存数据源的key
        DataSourceContextHolder.addDataSourceKey(key);
        // 加载新的数据源

        //但是多线程切换下可能会出现异常,可以把内部的 hashmap换成 ConcurrentHashMap
        super.afterPropertiesSet();
    }
}

DataSourceConfig 配置类,加载自定义的bean

import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

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

/**
 * 自定义动态数据源配置类
 */
@Configuration
public class DataSourceConfig {


    /**
     * 默认基础数据源
     *
     * @return
     */
    @Bean("defaultSource")
    @ConfigurationProperties("spring.datasource")
    @Primary
    public DataSource defaultSource() {
        return DataSourceBuilder.create().build();
    }


    /**
     * 自定义动态数据源
     *
     * @return
     */
    @Bean("dynamicDataSource")
    public DynamicDataSource dynamicDataSource() {
        DynamicDataSource dynamicDataSource = new DynamicDataSource();
        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("default", defaultSource());
        // 默认数据源
        dynamicDataSource.setDefaultDataSource(defaultSource());
        // 动态数据源
        dynamicDataSource.setDataSources(dataSourceMap);
        return dynamicDataSource;
    }


    /**
     * 修改MybatisPlus数据源配置
     *
     */
    @Bean
    public MybatisSqlSessionFactoryBean sqlSessionFactoryBean() throws IOException {
        MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean();
        // 配置自定义动态数据源
        sessionFactory.setDataSource(dynamicDataSource());
        // 开启驼峰转下划线设置
        MybatisConfiguration configuration = new MybatisConfiguration();
        configuration.setMapUnderscoreToCamelCase(true);
        sessionFactory.setConfiguration(configuration);

        sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mappers/*.xml"));
        return sessionFactory;
    }

    
    //如果是mybatis,替换一下session工厂就可以了
  /*  @Bean
    public SqlSessionFactoryBean sqlSessionFactoryBean() throws IOException {
        SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        // 配置自定义动态数据源
        sessionFactory.setDataSource(dynamicDataSource());
        // 开启驼峰转下划线设置
        MybatisConfiguration configuration = new MybatisConfiguration();
        configuration.setMapUnderscoreToCamelCase(true);
        sessionFactory.setConfiguration(configuration);

        sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mappers/*.xml"));
        return sessionFactory;
    }*/




    /**
     * 开启动态数据源@Transactional注解事务管理的支持
     * @return
     */
    @Bean
    public PlatformTransactionManager transactionManager(
            @Autowired DataSource source
    ) {
        return new DataSourceTransactionManager(source);
    }
}

具体业务使用

主要配置就是上面三个用来切换数据源的,剩下的就是业务逻辑

user 模拟saas业务

import com.app.entity.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

/**
 * @author [email protected]
 * @date 2024-01-19 - 14:50
 */
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

user 实体类

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

/**
 * @author [email protected]
 * @date 2024-01-19 - 14:49
 */
@TableName
@Data
public class User {

    @TableId
    private Integer id;


    private String name;
}

SaasMainMapper

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

/**
 * @author [email protected]
 * @date 2024-01-19 - 11:46
 */
@Mapper
public interface SaasMainMapper extends BaseMapper<SaasMain> {
    
}

saasMain

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;

/**
 * 租户类
 */
@Data
@TableName
public class SaasMain implements Serializable {

    @TableId
    private Long id;
    /**
     * 租户名
     */
    private String name;
    /**
     * 数据库类型
     */
    private Integer type;
    /**
     * 驱动类
     */
    private String driverClassName;
    /**
     * 数据库连接
     */
    private String jdbcUrl;
    /**
     * 数据库用户名
     */
    private String username;
    /**
     * 数据库密码
     */
    private String password;
    /**
     * 数据库名
     */
    private String dataBase;
}

SaasMainService

import cn.hutool.core.util.RandomUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.app.config.DataSourceContextHolder;
import com.app.config.DynamicDataSource;
import com.app.entity.SaasMain;
import com.app.entity.User;
import com.app.mapper.SaasMainMapper;
import com.app.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;

import javax.annotation.PostConstruct;
import javax.sql.DataSource;
import java.util.List;

/**
 * @author [email protected]
 * @date 2024-01-19 - 14:08
 */
@Service
public class SaasMainService {

    @Autowired
    private DynamicDataSource dynamicDataSource;

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private SaasMainMapper saasMainMapper;


    @Autowired
    private TransactionTemplate transactionTemplate;

    @PostConstruct
    public void a() {
        List<SaasMain> saasMains1 = SpringUtil.getBean(SaasMainMapper.class).selectList(null);
        System.out.println(saasMains1);


        for (SaasMain saasMain : saasMains1) {

            //动态构建数据源
            DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create();
            dataSourceBuilder.driverClassName(saasMain.getDriverClassName());
            dataSourceBuilder.url(saasMain.getJdbcUrl());
            dataSourceBuilder.username(saasMain.getUsername());
            dataSourceBuilder.password(saasMain.getPassword());
            DataSource source = dataSourceBuilder.build();

            //id作为这个数据源的key
            dynamicDataSource.addDataSource(saasMain.getId().toString(), source);
            //设置当前数据源
            DataSourceContextHolder.setDataSourceKey(saasMain.getId().toString());


            //获取当前数据源
            System.out.println(DataSourceContextHolder.getDataSourceKey());
            //查询数据库
            System.out.println(userMapper.selectList(null));

/*            try {
                //也可以使用事务,每个数据源事务互相隔离
                transactionTemplate.execute(status -> {
                    User user = new User();
                    user.setId(RandomUtil.randomInt());
                    user.setName("张三");
                    this.userMapper.insert(user);
                    int a = 1 / 0;
                    return null;
                });
            } catch (Exception e) {
                e.printStackTrace();
            }*/


           /* //因为使用的threadlocal 多线程的情况下这个数据源就会失效
            //解决办法需要提前把变量传递到线程内部
            String dbkey = DataSourceContextHolder.getDataSourceKey();
            new Thread(() ->{
                //重新设置数据源
                DataSourceContextHolder.setDataSourceKey(dbkey);
                System.out.println(userMapper.selectList(null));
            }).start();*/
        }
        System.out.println();

    }
}

项目代码地址

参考

【正确姿势】完全理解 Spring AbstractRoutingDataSource 实现动态(多)数据源切换(避免踩坑)-CSDN博客

springboot多数据源配置和使用-腾讯云开发者社区-腾讯云 (tencent.com)

重写AbstractRoutingDataSource替换为ConcurrentHashMap