SpringBoot集成Mybatis-Plus实现多租户动态数据源

news/2025/3/12 9:50:15/

1. 概述

最近接手一个多租户系统,多租户主要的就是租户之间的数据是相互隔离的,每个租户拥有自己独立的数据,相互之间不干扰。目前实现多租户主要有三种方案:
独立数据库
每个租户拥有自己单独的数据库,从物理上隔离了自己的数据,安全性最高,但是成本比较高,容易浪费数据库资源
同一数据库,不同表
每个租户的数据都在同一个数据库里,每个租户拥有一个独立的表,同样也实现了数据的隔离,安全性和成本其次
同一数据库,同一张表,字段区分
租户使用同一个数据库和同一张表,在每张表里添加进一个字段,例如tenant来区分每个租户的数据,安全性和成本都比较低,维护性也较高,单表的数据量也比较大,给查询和数据迁移都来带了麻烦
基于以上方案,本文选择第一种方案实现多租户系统

2. 开发环境

本文使用使用的开发工具/组件如表所示:

名称版本
Idea2020
JDK11
SpringBoot2.7.10
mybatis-plus-boot-starter3.5.3.1
dynamic-datasource-spring-boot-starter3.6.1
druid-spring-boot-starter1.2.14
mapstruct1.5.3.Final
postgresql15.2
redis7.0.10

3. 搭建项目

3.1. 新建数据库和表

先建几个数据库,分别是dynamic-master、dynamic-slave-1和dynamic-slave-2,在master库中新建tenant表,在slave库中建customer表,建表sql如下:

CREATE SEQUENCE IF NOT EXISTS tenant_id_seq;
CREATE TABLE public.tenant (id bigint NOT null DEFAULT nextval('tenant_id_seq'),tenant_id varchar(30) NOT NULL,data_source_url varchar(100) NOT NULL,data_source_username varchar(30) NOT NULL,data_source_password varchar(68) NOT NULL,data_source_driver varchar(50) NOT NULL,data_source_poolname varchar(50) NOT NULL,CONSTRAINT tenant_pk PRIMARY KEY (id),CONSTRAINT tenant_un UNIQUE (tenant_id)
);
COMMENT ON TABLE "tenant" IS '租户表';
COMMENT ON COLUMN "tenant"."tenant_id" IS '租户id';
COMMENT ON COLUMN "tenant"."data_source_url" IS '数据源URL';
COMMENT ON COLUMN "tenant"."data_source_username" IS '数据源用户名';
COMMENT ON COLUMN "tenant"."data_source_password" IS '数据源密码';
COMMENT ON COLUMN "tenant"."data_source_driver" IS '数据源驱动';
COMMENT ON COLUMN "tenant"."data_source_poolname" IS '数据源池名称';CREATE SEQUENCE IF NOT exists customer_id_seq;
CREATE TABLE public.customer (id bigint NOT NULL DEFAULT nextval('customer_id_seq'),customer_name varchar(30) NOT NULL,CONSTRAINT customer_pk PRIMARY KEY (id)
);
COMMENT ON TABLE public.customer IS '客户表';
COMMENT ON COLUMN public.customer.id IS '客户ID';
COMMENT ON COLUMN public.customer.customer_name IS '客户名称';

3.2. 引入核心依赖

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><exclusions><exclusion><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-logging</artifactId></exclusion></exclusions>
</dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.3.1</version>
</dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>dynamic-datasource-spring-boot-starter</artifactId><version>3.6.1</version><exclusions><exclusion><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId></exclusion></exclusions>
</dependency>
<dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId><version>1.2.14</version>
</dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope>
</dependency>
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId><version>1.5.3.Final</version>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

3.3. 编写application.yml文件

server:port: 8000
spring:application:name: SPRINGBOOT-TENANTdatasource:dynamic:primary: masterstrict: falsedatasource:master:url: jdbc:postgresql://xxxxx:5432/dynamic-masterusername: xxxxpassword: xxxxdriver-class-name: org.postgresql.Driverdruid:initial-size: 1max-active: 20min-idle: 1max-wait: 6000pool-prepared-statements: truemax-pool-prepared-statement-per-connection-size: 20validation-query: select 1validation-query-timeout: 10
logging:config: classpath:log4j2.xml

3.4. 初始化数据源

新建DynamicDataSource配置类,将master库tenant表中数据源初始化

@Configuration
public class DynamicDataSource {@Value("${spring.datasource.dynamic.datasource.master.driver-class-name}")private String driverName;@Value("${spring.datasource.dynamic.datasource.master.url}")private String url;@Value("${spring.datasource.dynamic.datasource.master.username}")private String username;@Value("${spring.datasource.dynamic.datasource.master.password}")private String password;@Beanpublic DynamicDataSourceProvider dynamicDataSourceProvider() {return new AbstractJdbcDataSourceProvider(driverName, url, username, password) {@Overrideprotected Map<String, DataSourceProperty> executeStmt(Statement statement) throws SQLException {Map<String, DataSourceProperty> dataSourceMap = new HashMap<>();ResultSet resultSet = statement.executeQuery("select * from tenant");while (resultSet.next()) {String tenant = resultSet.getString("tenant_id");DataSourceProperty sourceProperty = new DataSourceProperty();sourceProperty.setDriverClassName(resultSet.getString("data_source_driver"));sourceProperty.setUrl(resultSet.getString("data_source_url"));sourceProperty.setUsername(resultSet.getString("data_source_username"));sourceProperty.setPassword(resultSet.getString("data_source_password"));dataSourceMap.put(tenant, sourceProperty);}return dataSourceMap;}};}
}

3.5. 存储当前数据源

因为每次请求需要访问的数据库可能都不一样,所以需要在每次请求操作时需要指定需要访问哪个数据库,新建一个拦截器

@Log4j2
public class DynamicDataSourceInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String headerTenant = request.getHeader("X-Tenant-Id");if (StringUtils.hasText(headerTenant)) {DynamicDataSourceContextHolder.push(headerTenant);return true;}writerMessage(response, ResponseEntity.status(HttpStatus.BAD_REQUEST).body("X-Tenant-Id in request header cannot be empty!"));log.warn("X-Tenant-Id in request header cannot be empty, The path is {}", request.getRequestURL());return false;}private void writerMessage(HttpServletResponse response, ResponseEntity<String> errorMessage) {try (PrintWriter writer = response.getWriter()) {response.setStatus(errorMessage.getStatusCodeValue());response.setCharacterEncoding("UTF-8");response.setContentType("text/html; charset=utf-8");writer.print(errorMessage.getBody());} catch (Exception e) {e.printStackTrace();}}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {DynamicDataSourceContextHolder.clear();}
}

将自定义拦截器加入配置类,新建一个Web配置类

@Configuration
public class WebAutoConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(dynamicDataSourceInterceptor()).addPathPatterns("/**");}@Beanpublic DynamicDataSourceInterceptor dynamicDataSourceInterceptor() {return new DynamicDataSourceInterceptor();}
}

3.6. 编写数据源Controller

@RestController
@RequestMapping(value = "/datasource")
public class DataSourceController {@Autowiredprivate DataSource dataSource;@Autowiredprivate DefaultDataSourceCreator dataSourceCreator;@Autowiredprivate TenantService tenantService;@GetMapping(value = "/getAllDataSources")public Set<String> getAllDataSources() {DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dataSource;return routingDataSource.getDataSources().keySet();}@PostMapping(value = "/addDataSource")public ResponseEntity<String> addDataSource(@RequestBody DataSourceDto dataSourceDto) {DataSourceProperty sourceProperty = TenantMapper.TENANT_MAPPER.dataSourceDtoToDataSourceProperty(dataSourceDto);DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dataSource;DataSource propertyDataSource = dataSourceCreator.createDataSource(sourceProperty);routingDataSource.addDataSource(dataSourceDto.getTenantId(), propertyDataSource);Tenant tenant = TenantMapper.TENANT_MAPPER.dataSourceDtoToTenant(dataSourceDto);tenantService.saveOrUpdate(tenant);String dataSourceStr = routingDataSource.getDataSources().keySet().stream().collect(Collectors.joining(","));return ResponseEntity.ok(dataSourceStr);}
}

4. 测试

在postman中输入地址http://localhost:8000/datasource/getAllDataSources,在请求头新增X-Tenant-Id=master参数,发起GET请求
初始获取数据源信息
租户张三加入系统后,只需要为张三新建一个数据库,调用新增数据源接口就行,在postman中输入地址http://localhost:8000/datasource/addDataSource,发起POST请求
新增数据源
此时租户张三就可以查询自己的数据信息了,在postman中输入地址http://localhost:8000/tenant/customer/getCustomerInfo/:id,发起GET请求
查询张三信息
注意:请求头必须携带需要操作的数据源标识,否则会提出错误
无X-Tenant-Id访问
以上示例就简单实现了单体部署多租户系统的集成,如果是多实例部署是否有问题呢?

5. 多实例部署

5.1. 存在的问题

在Idea中同时启动两个实例8000和9000,8000服务新增租户李四数据源,分别查询8000服务和9000服务的数据源信息
8000服务数据源信息
再次查询9000服务数据源信息
9000服务数据源信息
对比发下在8000服务上新增了数据源,9000服务查询不到,且无法使用新增的数据源,这是因为服务一启动就将数据源信息初始化进了内存,8000服务和9000服务内存是相互独立的,故而8000服务上操作的数据无法同步到9000服务。如果将新增后的数据源存放到8000服务和9000服务都能访问到的第三方服务上,请求进入服务后执行前先对比本地内存数据源和远程服务数据源是否相等,若不等,就先将远程服务的数据源信息同步到本地内存,这样问题是不就解决了呢!

5.2. 同步数据源信息

本示例引入redis作为第三方服务,在拦截器中增加同步数据源的操作

@Log4j2
public class DynamicDataSourceInterceptor implements HandlerInterceptor {@Autowiredprivate TenantService tenantService;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if (StringUtils.hasText(headerTenant)) {tenantService.reloadDataSource();//其他代码省略...}}
}

同步数据源的代码如下:

public void reloadDataSource() {DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dataSource;Set<String> dataSourceTypeSet = routingDataSource.getDataSources().keySet();String dataSourceType = dataSourceTypeSet.stream().collect(Collectors.joining(","));String redisDataSourceType = redisTemplate.opsForValue().get("dataSourceType");if (!dataSourceType.equals(redisDataSourceType)) {dataSourceTypeSet.stream().filter(sourceType -> !sourceType.equals("master")).forEach(routingDataSource::removeDataSource);List<Tenant> tenantList = this.list();tenantList.stream().filter(tenant -> !tenant.getTenantId().equals("master")).forEach(tenant -> {DataSourceProperty sourceProperty = new DataSourceProperty();sourceProperty.setDriverClassName(tenant.getDataSourceDriver());sourceProperty.setUrl(tenant.getDataSourceUrl());sourceProperty.setUsername(tenant.getDataSourceUsername());sourceProperty.setPoolName(tenant.getDataSourcePoolname());sourceProperty.setPassword(tenant.getDataSourcePassword());DataSource propertyDataSource = dataSourceCreator.createDataSource(sourceProperty);routingDataSource.addDataSource(tenant.getTenantId(), propertyDataSource);});redisTemplate.opsForValue().set("dataSourceType", tenantList.stream().map(tenant -> tenant.getTenantId()).collect(Collectors.joining(",")));}
}

同时需要在新增数据源的地方将数据源信息set进redis

@PostMapping(value = "/addDataSource")
public ResponseEntity<String> addDataSource(@RequestBody DataSourceDto dataSourceDto) {//其他代码省略....redisTemplate.opsForValue().set("dataSourceType", dataSourceStr);//......
}

重启两个示例,再次新增数据源和查询数据源信息
同步收查询9000服务数据源信息

后记

由于作者能力有限,文中难免会出现一些错误,欢迎各位大佬不吝赐教,也希望各位大佬就多实例部署如何同步数据源问题在评论处留言讨论


http://www.ppmy.cn/news/46179.html

相关文章

2023年安全岗面试题及经验分享

如果你正在面试安全岗&#xff0c;那么恭喜你及时看到了这篇文章~ 写在前面 本篇为大家整理了上百道网络安全面试题&#xff0c;主要方向有 网络基础、渗透测试、安全工具 等&#xff0c;其中还包括 HVV面试、CISP备考 等&#xff0c;希望在求职期可以帮到大家​&#xff01;…

代码随想录算法训练营第三十四天-贪心算法3| 1005.K次取反后最大化的数组和 134. 加油站 135. 分发糖果

1005. Maximize Sum Of Array After K Negations 参考视频&#xff1a;贪心算法&#xff0c;这不就是常识&#xff1f;还能叫贪心&#xff1f;LeetCode&#xff1a;1005.K次取反后最大化的数组和_哔哩哔哩_bilibili 贪心&#x1f50d; 的思路&#xff0c;局部最优&#xff…

MySQL 主键自增也有坑?

在上篇文章中&#xff0c;松哥和小伙伴们分享了 MySQL 的聚簇索引&#xff0c;也顺便和小伙伴们分析了为什么在 MySQL 中主键不应该使用随机字符串。但是主键不用随机字符串用什么&#xff1f;主键自增&#xff1f;主键自增就是最佳方案吗&#xff1f;有没有其他坑&#xff1f;…

sublime text的snippet介绍,提高编程效率

自定义Snippet Sublime Text 的 Snippet 是一种快捷方式&#xff0c;它允许您使用自定义模板或代码片段更快地编写代码。以下是创建 Snippet 的步骤&#xff1a; 打开 Sublime Text 编辑器并创建一个新文件。菜单栏选择 “Tools” -> “Developer” -> “New Snippet”…

文心一言眼里的SQL世界

目录 一、Java基础教程系列二、先听听文心一言怎么说&#xff1f;三、话不多说&#xff0c;开干。1、要有一个正确的数据库学习路线&#xff0c;做一个细致的MySQL学习规划。2、学习资料推荐 四、MySQL基础知识总结五、MySQL进阶六、Redis和MongoDB需要学吗&#xff1f;七、如何…

[个人笔记] Windows系列常用Shell命令工具集合

Windows - 运维篇 第四章 Windows系列常用Shell命令工具集合 Windows - 运维篇系列文章回顾CMD常用命令集网络相关命令文件管理相关命令系统相关命令其他命令 CMD常用工具集网络监控相关工具系统相关实用工具其他辅助工具 PowerShell常用命令工具集更多命令工具集参考来源 系列…

机器学习 day04(梯度下降算法,学习率,偏导数,执行过程示意图)

1. 梯度下降 我们可以用一种更系统的方法&#xff0c;来找到一组w&#xff0c;b&#xff0c;使成本函数的值最小。这个方法叫梯度下降算法&#xff0c;它可用于最小化任何函数&#xff0c;不仅仅包括线性回归的成本函数&#xff0c;也包括两个以上参数的其他成本函数在线性回…

如何使用ref和reactive你必须要知道的场景和差异

在Vue 3中&#xff0c;ref和reactive是两种不同的数据响应式处理方式。本文将介绍它们的使用场景和差异&#xff0c;并提供相关代码示例。 ref的使用场景 ref通常用于处理简单的数据类型&#xff0c;例如数字、布尔值、字符串等。它可以让我们在模板中直接使用数据&#xff0…