🌷 古之立大事者,不惟有超世之才,亦必有坚忍不拔之志
🎐 个人CSND主页——Micro麦可乐的博客
🐥《Docker实操教程》专栏以最新的Centos版本为基础进行Docker实操教程,入门到实战
🌺《RabbitMQ》专栏主要介绍使用JAVA开发RabbitMQ的系列教程,从基础知识到项目实战
🌸《设计模式》专栏以实际的生活场景为案例进行讲解,让大家对设计模式有一个更清晰的理解
💕《Jenkins实战》专栏主要介绍Jenkins+Docker的实战教程,让你快速掌握项目CI/CD,是2024年最新的实战教程
🌞《Spring Boot》专栏主要介绍我们日常工作项目中经常应用到的功能以及技巧,代码样例完整
如果文章能够给大家带来一定的帮助!欢迎关注、评论互动~
基于MyBatis-Plus Dynamic-Datasource实现 SaaS 系统动态租户数据源管理
前言
在我们开发 SaaS
(软件即服务)系统中,多租户架构是核心设计之一。常见的多租户数据隔离方案包括:
- 共享数据库,共享表(通过 tenant_id 字段隔离)
- 共享数据库,独立表(不同租户使用不同表)
- 独立数据库,独立库(每个租户拥有独立数据库)
上述方案中,共享表(通过 tenant_id 字段隔离)MyBatis-Plus 官方有自己的实现插件,详见官方文档:
https://baomidou.com/plugins/tenant/
而 独立数据库
方案虽然能提供最强的数据隔离性,但需要动态管理大量数据源。针对这个需求场景,博主将通过 MyBatis-Plus 的 dynamic-datasource 组件,实现 租户动态注册数据源
的能力,满足 SaaS
系统的灵活扩展需求。
实现思路
根据博主目前所在企业里目前也有在使用的一个方案,大致简化的一个流程图如下:
流程说明
- 用户注册租户后台审核通过 会生成一个租户ID tenant_id
- 通过中间件、脚本、Kubernetes等 给该租户构建一个
sass
系统独立数据库 - 独立数据库构建成功后,插入租户数据库连接维护表(URL、用户、密码等)
- 用户登陆成功后。系统会返回
tenant_id
给租户,每次访问应用都会携带这个标识 - 同时系统通过租户标识
tenant_id
去获取对应数据库连接,并进行动态数据源注册、删除、切换 - 租户正常使用系统功能
实现步骤
获取源码方式一:为了增加博客的互动性,相关完整源代码可以在评论区留下邮箱获取哦!
获取源码方式二:通过优快云主页关注我的个人公众号 回复关键字 - 动态租户数据源
完整代码结构如下图:
❶ 元数据表设计
主要针对上述第三步骤存储租户与数据源的映射关系 , 新建 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 等容器化技术,实现租户数据库的自动化扩缩容,进一步提升系统弹性。如果本文对您有所帮助,希望 一键三连 给博主一点点鼓励,如果您有任何疑问或建议,请随时留言讨论!