基于MyBatis-Plus Dynamic-Datasource实现 SaaS 系统动态租户数据源管理

在这里插入图片描述

🌷 古之立大事者,不惟有超世之才,亦必有坚忍不拔之志
🎐 个人CSND主页——Micro麦可乐的博客
🐥《Docker实操教程》专栏以最新的Centos版本为基础进行Docker实操教程,入门到实战
🌺《RabbitMQ》专栏主要介绍使用JAVA开发RabbitMQ的系列教程,从基础知识到项目实战
🌸《设计模式》专栏以实际的生活场景为案例进行讲解,让大家对设计模式有一个更清晰的理解
💕《Jenkins实战》专栏主要介绍Jenkins+Docker的实战教程,让你快速掌握项目CI/CD,是2024年最新的实战教程
🌞《Spring Boot》专栏主要介绍我们日常工作项目中经常应用到的功能以及技巧,代码样例完整
如果文章能够给大家带来一定的帮助!欢迎关注、评论互动~

前言

在我们开发 SaaS(软件即服务)系统中,多租户架构是核心设计之一。常见的多租户数据隔离方案包括:

  • 共享数据库,共享表(通过 tenant_id 字段隔离)
  • 共享数据库,独立表(不同租户使用不同表)
  • 独立数据库,独立库(每个租户拥有独立数据库)

上述方案中,共享表(通过 tenant_id 字段隔离)MyBatis-Plus 官方有自己的实现插件,详见官方文档:
https://baomidou.com/plugins/tenant/

独立数据库 方案虽然能提供最强的数据隔离性,但需要动态管理大量数据源。针对这个需求场景,博主将通过 MyBatis-Plus 的 dynamic-datasource 组件,实现 租户动态注册数据源 的能力,满足 SaaS 系统的灵活扩展需求。

实现思路

根据博主目前所在企业里目前也有在使用的一个方案,大致简化的一个流程图如下:
在这里插入图片描述
流程说明

  1. 用户注册租户后台审核通过 会生成一个租户ID tenant_id
  2. 通过中间件、脚本、Kubernetes等 给该租户构建一个 sass 系统独立数据库
  3. 独立数据库构建成功后,插入租户数据库连接维护表(URL、用户、密码等)
  4. 用户登陆成功后。系统会返回 tenant_id 给租户,每次访问应用都会携带这个标识
  5. 同时系统通过租户标识 tenant_id 去获取对应数据库连接,并进行动态数据源注册、删除、切换
  6. 租户正常使用系统功能

实现步骤

获取源码方式一:为了增加博客的互动性,相关完整源代码可以在评论区留下邮箱获取哦!
在这里插入图片描述

获取源码方式二:通过优快云主页关注我的个人公众号 回复关键字 - 动态租户数据源

完整代码结构如下图:
在这里插入图片描述

❶ 元数据表设计

主要针对上述第三步骤存储租户与数据源的映射关系 , 新建 master_db 表,执行如下SQL

CREATE TABLE `tenant_datasource` (
  `tenant_id` VARCHAR(32) PRIMARY KEY COMMENT '租户ID',
  `db_url` VARCHAR(200) NOT NULL COMMENT '数据库URL',
  `db_username` VARCHAR(50) NOT NULL COMMENT '用户名',
  `db_password` VARCHAR(100) NOT NULL COMMENT '密码',
  `driver_class_name` VARCHAR(100) DEFAULT 'com.mysql.cj.jdbc.Driver',
  `status` TINYINT(1) DEFAULT 1 COMMENT '状态(0:禁用 1:启用)',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP
);

注意实际生产环境中连接数据库密码不建议明文存储,建议加密存储
可以参考博主的 Spring Boot整合Jasypt 库实现配置文件和数据库字段敏感数据的加解密 进行了解

❷ 创建Springboot项目所需

博主项目环境:JDK17 + SpringBoot 3.3.4

接下来我们使用 idea 创建好我们本次项目,引入相关依赖,博主使用的 HikariCP 连接池 ,直接引入 spring-boot-starter-data-jpa即可,Spring官方默认支持。

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!--Lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

        <!-- mybatis-plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.9</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.30</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
            <version>4.3.1</version>
        </dependency>
    </dependencies>

接下来是 yml 配置文件,注意使用 dynamic 多数据源的配置方式

server:
  port: 8080

spring:
  application:
    name: spring-boot-dynamic-datasource
  datasource:
    dynamic:
      datasource:
        master:
          url: jdbc:mysql://localhost:3306/master_db?useSSL=false&serverTimezone=UTC
          username: root
          password: root
          driver-class-name: com.mysql.cj.jdbc.Driver
          hikari:
            maximum-pool-size: 5

❸ 开启扫描注解并配置相关Mapper

在项目启动类上标注 mybatis 需要扫描的路径,博主的路径是 com.toher.dynamicdatasource.sass.mapper

@SpringBootApplication
@MapperScan("com.toher.dynamicdatasource.sass.mapper") // 指定Mapper接口所在包
public class DynamicDatasourceSassApplication {
    public static void main(String[] args) {
        SpringApplication.run(DynamicDatasourceSassApplication.class, args);
    }
}

新增数据映射的 TenantDatasource 对象

/**
 * @Description:
 * @Auther: Micro麦可乐
 */
@Data
@TableName("tenant_datasource") // 指定关联的表名
public class TenantDatasource {
    @TableId(value = "tenant_id", type = IdType.INPUT) // 租户ID由业务指定(非自增)
    private String tenantId;

    @TableField("db_url")
    private String dbUrl;

    @TableField("db_username")
    private String dbUsername;

    @TableField("db_password")
    private String dbPassword;

    @TableField("driver_class_name")
    private String driverClassName = "com.mysql.cj.jdbc.Driver"; // 默认值

    @TableField("status")
    private Integer status = 1; // 状态默认启用

    @TableField("create_time")
    private Date createTime;
}

新增操作数据的 TenantDatasourceMapper

public interface TenantDatasourceMapper extends BaseMapper<TenantDatasource> {

}

❹ 租户上下文管理

租户上下文 Holder,采用 ThreadLocal 存储当前租户信息、请求上下文等,方便在方法调用链中传递数据,而不必在方法参数中传递。

温馨提示:可以使用阿里巴巴开源的 TTL,代替new ThreadLocal 简单演示这里博主就不引入了

/**
 * @Description:
 * @Auther: Micro麦可乐
 */
public class TenantContext {

    private static final ThreadLocal<String> TENANT_HOLDER = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        TENANT_HOLDER.set(tenantId);
    }

    public static String getTenantId() {
        return TENANT_HOLDER.get();
    }

    public static void clear() {
        TENANT_HOLDER.remove();
    }
}

❺ 动态添加删除数据源

根据我们前面讲的流程,租户注册会自动构建对应租户的数据库,并将对应数据库连接配置入到当前数据源master_db 表中,现在我们来编写这个Controller,实现数据源的动态添加、删除

/**
 * @Description:
 * @Auther: Micro麦可乐
 */

@RestController
@RequestMapping("/tenant")
public class TenantController {

    private final TenantDatasourceMapper tenantDatasourceMapper;
    private final DefaultDataSourceCreator dataSourceCreator;
    private final DataSource dataSource;
    private final UserService userService;

    // 构造器注入
    public TenantController(
            TenantDatasourceMapper tenantDatasourceMapper,
            DefaultDataSourceCreator dataSourceCreator,
            DataSource dataSource,
            UserService userService) {
        this.tenantDatasourceMapper = tenantDatasourceMapper;
        this.dataSourceCreator = dataSourceCreator;
        this.dataSource = dataSource;
        this.userService = userService;

    }

    /**
     * 获取当前所有数据源
     */
    @GetMapping("/all")
    public Set<String> now() {
        DynamicRoutingDataSource ds = (DynamicRoutingDataSource) dataSource;
        return ds.getDataSources().keySet();
    }

    /**
     * 测试切换 模拟TenantContext上下文设值
     * @return
     */
    @GetMapping("/cut")
    public String cut(String tenantId) {
        //TODO 常见做法是是用拦截器 这里简单模拟TenantContext上下文设值
        //TenantContext.setTenantId(tenantId);

        return userService.getuserList(tenantId).toString();
    }

    /**
     * 删除数据源
     */
    @GetMapping("/del")
    public String remove(String tenantId) {
        DynamicRoutingDataSource ds = (DynamicRoutingDataSource) dataSource;
        ds.removeDataSource(tenantId);
        return "删除成功";
    }

    @GetMapping("/register")
    public String registerTenant(String tenantId) {

        //模拟一个用户注册后配置数据库连接
        TenantDatasource tenant = new TenantDatasource();
        tenant.setTenantId(tenantId);
        tenant.setDbUrl("jdbc:mysql://localhost:3306/tenant_db_"+tenantId+"?useSSL=false&serverTimezone=UTC");
        tenant.setDbUsername("root");
        tenant.setDbPassword("root");

        //TODO 模拟中间件对租户注册自动创建数据库
        createTenantDatabase(tenant);

        //构建完成载插入租户数据库配置表
        tenantDatasourceMapper.insert(tenant);

        // 3. 动态注册数据源
        DataSourceProperty dataSourceProperty = convertToProp(tenant);
        DynamicRoutingDataSource ds = (DynamicRoutingDataSource) dataSource;
        DataSource newDataSource = dataSourceCreator.createDataSource(dataSourceProperty);
        ds.addDataSource(tenant.getTenantId(), newDataSource);
        return "租户注册成功";
    }

    /**
     * 将 DTO 转换为 DataSourceProperty
     */
    private DataSourceProperty convertToProp(TenantDatasource dto) {
        DataSourceProperty prop = new DataSourceProperty();
        prop.setUrl(dto.getDbUrl());
        prop.setUsername(dto.getDbUsername());
        prop.setPassword(dto.getDbPassword());
        prop.setDriverClassName(dto.getDriverClassName());
        prop.setPoolName(dto.getTenantId() + "-pool"); // 连接池命名(便于监控)

        // 可扩展 Hikari 连接池参数(示例)
        prop.setHikari(new HikariCpConfig());
        prop.getHikari().setMaximumPoolSize(10);
        prop.getHikari().setConnectionTimeout(30000L);
        return prop;
    }

    /** 模拟中间件对租户注册自动创建数据库
     *
     * @param dto
     */
    private void createTenantDatabase(TenantDatasource dto) {
       String MASTER_DB_URL = "jdbc:mysql://localhost:3306";
       String MASTER_DB_USER = "root";
       String MASTER_DB_PWD = "root";
        Connection conn = null;
        try {
            conn = DriverManager.getConnection(MASTER_DB_URL, MASTER_DB_USER, MASTER_DB_PWD);
            String sql = String.format("CREATE DATABASE IF NOT EXISTS `%s`", "tenant_db_" + dto.getTenantId());
            conn.createStatement().executeUpdate(sql);
        } catch (SQLException e) {
            throw new RuntimeException("创建租户数据库失败", e);
        }
    }
}

现在我们来访问 register 接口,传递 tenantId = 1,你会发现数据库中已经自动构建了一个 tenant_db_1 的数据库,依次类推传递 tenantId = 2,我们构建两个(主要是为了租户数据源切换演示)
在这里插入图片描述
同时并记录了两条租户数据库连接的配置

在这里插入图片描述
接下来调用 all 接口(http://localhost:8080/tenant/all)查看当前所有数据源
在这里插入图片描述

❻ 动态切换数据源

为了测试不同租户访问到不同的租户数据库,我们在刚才构建的两个租户表中分别创建一个 user 表,数据自行模拟主要能区分两个表数据不同即可(博主是根据name字段 -1 和 -2来区分)

CREATE TABLE `user` (
  `id` bigint NOT NULL COMMENT '主键ID',
  `name` varchar(30) DEFAULT NULL COMMENT '姓名',
  `age` int DEFAULT NULL COMMENT '年龄',
  `email` varchar(50) DEFAULT NULL COMMENT '邮箱',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

在这里插入图片描述
编写相关租户访问 user表的业务代码,UserMapper 和 UserService
UserMapper

public interface UserMapper extends BaseMapper<User> {

}

UserService

/**
 * @Description:
 * @Auther: Micro麦可乐
 */
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    @DS("#tenantId") //参数传递
    public List<User> getuserList(String tenantId){
        // 手动切换
        //DynamicDataSourceContextHolder.push(tenantId);
        List<User> users = userMapper.selectList(null);
        DynamicDataSourceContextHolder.clear();
        return users;
    }
}

在这里插入图片描述
最后访问 http://localhost:8080/tenant/cut?tenantId=1 调整租户ID测试是否切换成功
在这里插入图片描述
访问http://localhost:8080/tenant/cut?tenantId=2 浏览器显示

在这里插入图片描述
至此,我们已经完成了对应数据源的切换,实现了不同租户之间使用不同数据源访问对应租户数据库的功能

❼ 项目启动自动读取数据库添加数据源

最后我们需要处理一个问题,当系统重启之前注册的数据源就丢失了,现在我们需要进行最后一步,当 项目启动自动读取租户数据库连接配置添加数据源

自定义 TenantDataSourceProvider 继承 AbstractJdbcDataSourceProvider 重写 executeStmt 方法来实现

/**
 * @Description:
 * @Auther: Micro麦可乐
 */
public class TenantDataSourceProvider extends AbstractJdbcDataSourceProvider {

    private final HikariDataSourceProperties properties;

    // 构造器注入
    public TenantDataSourceProvider(DefaultDataSourceCreator defaultDataSourceCreator,
                                    HikariDataSourceProperties properties) {
        super(defaultDataSourceCreator, properties.getDriverClassName(), properties.getUrl(), properties.getUsername(),
                properties.getPassword());
        this.properties = properties;
    }

    @Override
    protected Map<String, DataSourceProperty> executeStmt(Statement statement) throws SQLException {
        ResultSet rs = statement.executeQuery("SELECT * FROM tenant_datasource WHERE status = 1");
        Map<String, DataSourceProperty> map = new HashMap<>(10);
        // 设置默认主数据源
        DataSourceProperty property = new DataSourceProperty();
        property.setUsername(properties.getUsername());
        property.setPassword(properties.getPassword());
        property.setUrl(properties.getUrl());
        map.put("master", property);
        //从数据库读取
        while (rs.next()) {
            String name = rs.getString("tenant_id");
            String username = rs.getString("db_username");
            String password = rs.getString("db_password");
            String url = rs.getString("db_url");
            property = new DataSourceProperty();
            property.setUsername(username);
            property.setPassword(password);
            property.setUrl(url);
            map.put(name, property);
        }
        return map;
    }
}

添加读取当前系统数据源的配置 HikariDataSourceProperties

/**
 * @Description:
 * @Auther: Micro麦可乐
 */
@Data
@ConfigurationProperties("spring.datasource.dynamic.datasource.master")
public class HikariDataSourceProperties {
    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    /**
     * jdbcurl
     */
    private String url;

    /**
     * 驱动类型
     */
    private String driverClassName;
}

最后创建一个配置类 TenantDataSourceConfig 注入自定义 TenantDataSourceProvider

/**
 * @Description:
 * @Auther: Micro麦可乐
 */
@Configuration
@EnableConfigurationProperties(HikariDataSourceProperties.class)
public class TenantDataSourceConfig {

 @Bean
 public TenantDataSourceProvider tenantDataSourceProvider(DefaultDataSourceCreator dataSourceCreator,
                                                          HikariDataSourceProperties properties) {
  return new TenantDataSourceProvider(dataSourceCreator,properties);
 }

}

最后我们重启系统,在没有走 register 接口前,访问 http://localhost:8080/tenant/all 接口确认数据源是否已经自动追加!

结语

通过 MyBatis-Plus Dynamic-Datasource 的动态数据源能力,结合 SaaS 系统的租户上下文管理,可以高效实现多租户独立数据库的架构。此方案具备以下优势:

  • 灵活性:支持租户动态注册,无需重启服务。
  • 隔离性:物理层面实现数据隔离。
  • 扩展性:轻松应对租户数量增长。

未来可结合 Kubernetes 等容器化技术,实现租户数据库的自动化扩缩容,进一步提升系统弹性。如果本文对您有所帮助,希望 一键三连 给博主一点点鼓励,如果您有任何疑问或建议,请随时留言讨论!

在这里插入图片描述

评论 52
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Micro麦可乐

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值