目录
-
- 引言
- 方式1 - 使用原生Mybatis分包的方式
- 方式2 - 使用Mybatis-Plus及对应的Dynamic-Datasource扩展【推荐】
-
- 2.1 @DSTransactional
- 2.2 多数据源事务扩展
- 2.3 多租户
引言
最近有项目需要支持多租户(多租户之后会单独开一篇文章说),多租户架构中需要用到多数据源,即物理隔离,需要不同租户对应不同的RMDB数据库实例,故本篇文章先行对多数据源的进行探讨。
通常我们的工程仅存在唯一数据源以及对应的一套数据库连接池,如SpringBoot应用中如下配置:
# 基础配置
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/my_db?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: 123456
# Hikari 连接池配置
hikari:
# 最小空闲连接数量
minimum-idle: 5
# 空闲连接存活最大时间,默认600000(10分钟)
idle-timeout: 180000
# 连接池最大连接数,默认是10
maximum-pool-size: 10
# 此属性控制从池返回的连接的默认自动提交行为,默认值:true
auto-commit: true
# 连接池名称
pool-name: MyHikariCP
# 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认1800000即30分钟
max-lifetime: 1800000
# 数据库连接超时时间,默认30秒,即30000
connection-timeout: 30000
connection-test-query: SELECT 1
以Mybatis生态为例,支持多数据源的方式有如下2种。
方式1 - 使用原生Mybatis分包的方式
此种方式需按照数据源对Mapper接口及mapper.xml进行分包,
如下图存在2个数据源,则需要分成2个包,如ds1和ds2:
同时比较重要的是需要对Mybatis中不同包下的Mapper注入不同的DataSource,因此每个数据源都需要单独进行配置,如截图中存在2个数据源分别对应DataSourceConfig1和DataSourceConfig2两个配置类,同时需要将一个数据源设置为主数据源,避免Spring启动无法注入数据源报错。
多数据源配置application.yml规划如下:
spring:
# DataSource Config
datasource:
ds1: # 数据源1
# Hikari 连接池配置,具体配置属性同spring.datasource.hikari.*
jdbc-url: jdbc:mysql://localhost:3306/multi-ds-1?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.zaxxer.hikari.HikariDataSource
# 最小空闲连接数量
minimum-idle: 5
# 空闲连接存活最大时间,默认600000(10分钟)
idle-timeout: 180000
# 连接池最大连接数,默认是10
maximum-pool-size: 10
# 此属性控制从池返回的连接的默认自动提交行为,默认值:true
auto-commit: true
# 连接池名称
pool-name: DS1-POOL
# 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认1800000即30分钟
max-lifetime: 1800000
# 数据库连接超时时间,默认30秒,即30000
connection-timeout: 30000
connection-test-query: SELECT 1
ds2: # 数据源2
# Hikari 连接池配置,具体配置属性同spring.datasource.hikari.*
jdbc-url: jdbc:mysql://localhost:3306/multi-ds-2?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.zaxxer.hikari.HikariDataSource
# 最小空闲连接数量
minimum-idle: 5
# 空闲连接存活最大时间,默认600000(10分钟)
idle-timeout: 180000
# 连接池最大连接数,默认是10
maximum-pool-size: 10
# 此属性控制从池返回的连接的默认自动提交行为,默认值:true
auto-commit: true
# 连接池名称
pool-name: DS2-POOL
# 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认1800000即30分钟
max-lifetime: 1800000
# 数据库连接超时时间,默认30秒,即30000
connection-timeout: 30000
connection-test-query: SELECT 1
多数据源配置类定义如下:
/**
* 数据源1 - 配置
*
* 注:默认仅@Primary主数据源支持事务@Transactional
*
* @author luohq
* @date 2022-08-06
*/
@Configuration
//注意此处需扫描对应数据源包下的mapper接口,且sqlSessionFactory为当前类中定义的SqlSessionFactory
@MapperScan(basePackageClasses = {MyDataMapper1.class}, sqlSessionFactoryRef = "ds1SqlSessionFactory")
public class DataSourceConfig1 {
@Primary // 表示这个数据源是默认数据源, 这个注解必须要加,因为不加的话spring将分不清楚那个为主数据源(默认数据源)
@Bean("ds1DataSource")
@ConfigurationProperties(prefix = "spring.datasource.ds1") //读取application.yml中的配置参数映射成为一个对象
public DataSource ds1DataSource1() {
return DataSourceBuilder.create().build();
}
@Primary
@Bean("ds1SqlSessionFactory")
public SqlSessionFactory ds1SqlSessionFactory(@Qualifier("ds1DataSource") DataSource dataSource) throws Exception {
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dataSource);
// mapper的xml形式文件位置必须要配置,不然将报错:no statement (这种错误也可能是mapper的xml中,namespace与项目的路径不一致导致)
bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:mapper/ds1/*.xml"));
return bean.getObject();
}
@Primary
@Bean("ds1SqlSessionTemplate")
public SqlSessionTemplate ds1SqlSessionTemplate(@Qualifier("ds1SqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
}
----------------------------------------------------------------------------
/**
* 数据源2 - 配置
* 注:默认仅@Primary主数据源支持事务@Transactional,当前非主数据源不支持事务
* @author luohq
* @date 2022-08-06
*/
@Configuration
//注意此处需扫描对应数据源包下的mapper接口,且sqlSessionFactory为当前类中定义的SqlSessionFactory
@MapperScan(basePackageClasses = {MyDataMapper2.class}, sqlSessionFactoryRef = "ds2SqlSessionFactory")
public class DataSourceConfig2 {
@Bean("ds2DataSource")
@ConfigurationProperties(prefix = "spring.datasource.ds2")
public DataSource ds2DataSource(){
return DataSourceBuilder.create().build();
}
@Bean("ds2SqlSessionFactory")
public SqlSessionFactory ds2SqlSessionFactory(@Qualifier("ds2DataSource") DataSource dataSource) throws Exception {
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dataSource);
bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:mapper/ds2/*.xml"));
return bean.getObject();
}
@Bean("ds2SqlSessionTemplate")
public SqlSessionTemplate ds2SqlSessionTemplate(@Qualifier("ds2SqlSessionFactory") SqlSessionFactory sqlSessionFactory){
return new SqlSessionTemplate(sqlSessionFactory);
}
}
在使用多数据源时,仅需将不同数据源包下的Mapper接口注入使用即可,如:
/**
* <p>
* 我的数据 服务实现类
* </p>
*
* @author luohq
* @since 2022-08-06
*/
@Service
public class MyDataServiceImpl implements IMyDataService {
@Resource
private MyDataMapper1 myDataMapper1;
@Resource
private MyDataMapper2 myDataMapper2;
@Override
public MyData findByIdFromDs1(Long id) {
return this.myDataMapper1.selectById(id);
}
@Override
public MyData findByIdFromDs2(Long id) {
return this.myDataMapper2.selectById(id);
}
/**
* 仅@Primary主数据源ds1支持事务,非主数据源ds2不支持事务
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Integer addBothData(MyData myData1, MyData myData2) {
Integer retCount1 = this.myDataMapper1.insert(myData1);
Integer retCount2 = this.myDataMapper2.insert(myData2);
if (true) {
throw new RuntimeException("业务异常 - 制造数据库回滚!");
}
return retCount1 + retCount2;
}
/**
* 仅@Primary主数据源支持事务
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Integer addData1(MyData myData) {
Integer retCount = this.myDataMapper1.insert(myData);
return retCount;
}
/**
* 非主数据源不支持事务
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Integer addData2(MyData myData) {
Integer retCount = this.myDataMapper2.insert(myData);
return retCount;
}
}
以上方式确实可以实现多数据源,但是此种方式存在如下问题:
- @Transactional开启的事务 仅支持之前声明为
@Primary的主数据源
,不支持其他非@Primary数据源
- 单独调用
@Primary数据源
支持事务,参见上例代码中的MyDataServiceImpl.addData1 - 单独调用
非@Primary数据源
不支持事务,参见上例代码中的MyDataServiceImpl.addData2 - 组合调用多数据源也仅
@Primary数据源
支持事务,如上例代码中addBothData同时调用myDataMapper1和myDataMapper2,实际测试myDataMapper1的操作支持事务,而myDataMapper2完全脱离了当前事务的管理。 - 综上此种事务控制场景比较适合读写分离的场景(一主一从),
@Primary主数据源
仅作写操作,如MyDataWriteMapper.java,而其他非@Primary数据源
仅作读操作,如MyDataReadMapper.java。
- 单独调用
- 若不同数据源对应不同的DB数据结构,又或者之前提到的读写SQL分离,则不同数据源包下定义不同的Mapper接口、Mapper.xml这种没有问题,但是对于类似多租户场景下,仅是对数据存储位置进行隔离,而不同数据源间的数据结构都是一样的,这时再在不同数据源包下维护多套相同的Mapper接口、Mapper.xml显然是不合理的。
- 综上通过原生Mybatis分包划分多数据源的架构也不适合多租户架构
以上示例源码参见:
https://gitee.com/luoex/multi-datasource-demo/tree/master/mb-package-multi-ds
方式2 - 使用Mybatis-Plus及对应的Dynamic-Datasource扩展【推荐】
在实际开发时,我这边多数都是直接使用Mybatis-Plus作为DAO层,Mybatis-Plus作为Mybatis的增强,提供了很多开箱即用的方便特性,比如内建的CRUD操作、强大的基于Wrapper的条件构造器、分页、ID生成等等。在Mybatis-Plus生态中作者也提供了多数据源方案,即基于dynamic-datasource-spring-boot-starter的实现:
<!-- Mybatis-Plus依赖 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<!-- Dynamic-DataSource多数据源依赖 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
该dynamic-datasource扩展代码是开源的:
https://github.com/baomidou/dynamic-datasource-spring-boot-starter
https://gitee.com/baomidou/dynamic-datasource-spring-boot-starter
但是文档是付费的:
https://www.kancloud.cn/tracy5546/dynamic-datasource/2264611
我看的文档是公司小伙伴付费买的,公司内是可以传播的,但不可以在网络上传播。
dynamic-datasource集成还是比较方便的,同时支持Druid、HikariCP等诸多连接池。
以集成HikariCP连接池为例,application.yml配置如下:
# dynamic-datasource多数据源配置
spring:
datasource:
dynamic:
primary: ds1 #设置默认的数据源或者数据源组,默认值即为master
strict: false #严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源
hikari: # 全局hikariCP参数,所有值和默认保持一致。(现已支持的参数如下,不清楚含义不要乱设置)
connection-timeout: 30000
max-pool-size: 10
min-idle: 5
idle-timeout: 180000
max-lifetime: 1800000
connection-test-query: SELECT 1
datasource:
ds1: # 数据源名称即对应连接池名称
url: jdbc:mysql://localhost:3306/multi-ds-1?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
hikari: # 当前数据源HikariCP参数(继承全局、部分覆盖全局)
max-pool-size: 20
ds2:
url: jdbc:mysql://localhost:3306/multi-ds-2?characterEncoding=utf8&useUnicode=true&useSSL=false&serverTimezone=GMT%2B8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
max-pool-size: 15
# Mybatis-Plus相关配置
mybatis-plus:
global-config:
db-config:
id-type: assign_id
代码结构如下图,对比之前提到的基于原生Mybatis分包的方式,此种方式不需要对Mapper接口、Mapper.xml进行分包:
切换数据源时,可通过在Service实现类中对应方法通过@DS("具体配置中的数据源名称")
指定对应的数据源:
import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.luo.demo.multi.ds.dynamic.dto.MyDataQueryDto;
import com.luo.demo.multi.ds.dynamic.entity.MyData;
import com.luo.demo.multi.ds.dynamic.mapper.MyDataMapper;
import com.luo.demo.multi.ds.dynamic.service.IMyDataService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.Objects;
/**
* <p>
* 我的数据 服务实现类
* </p>
*
* @author luohq
* @since 2022-08-07
*/
@Service
public class MyDataServiceImpl implements IMyDataService {
@Resource
private MyDataMapper myDataMapper;
@Override
@DS("ds1")
public MyData findByIdFromDs1(Long id) {
//selectById - 支持自动拼接租户Id参数
return this.myDataMapper.selectById(id);
}
@Override
@DS("ds1")
public MyData findByQueryFromDs1(MyDataQueryDto myDataQueryDto) {
//QueryWrapper - 支持自动拼接租户Id参数
return this.myDataMapper.selectOne(Wrappers.<MyData>lambdaQuery()
.eq(Objects.nonNull(myDataQueryDto.getId()), MyData::getId, myDataQueryDto.getId())
.like(StringUtils.hasText(myDataQueryDto.getMyName()), MyData::getMyName, myDataQueryDto.getMyName()));
}
@Override
public MyData findByName(String myName) {
//mapper.xml自定义查询 - 支持自动拼接租户Id参数
return this.myDataMapper.selectByName(myName);
}
@Override
@DS("ds2")
public MyData findByIdFromDs2(Long id) {
return this.myDataMapper.selectById(id);
}
/**
* 单@Transactional内不支持切换数据源,
* 即先使用ds1,则后续一直使用同一ds1连接,
* 当前事务生效,但都会插入ds1中
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Integer addBothData(MyData myData1, MyData myData2) {
Integer retCount1 = this.addData1(myData1);
Integer retCount2 = this.addData2(myData2);
//if (true) {
// throw new RuntimeException("业务异常 - 制造数据库回滚!");
//}
return retCount1 + retCount2;
}
/**
* 支持事务
*/
@Override
@Transactional(rollbackFor = Exception.class)
@DS("ds1")
public Integer addData1(MyData myData) {
//支持自动设置tenantId
Integer retCount = this.myDataMapper.insert(myData);
return retCount;
}
/**
* 支持事务
*/
@Override
@Transactional(rollbackFor = Exception.class)
@DS("ds2")
public Integer addData2(MyData myData) {
//支持自动设置tenantId
Integer retCount = this.myDataMapper.insert(myData);
return retCount;
}
}
关于@DS注解需要注意:
- @DS注解基于AOP实现
- @DS推荐放在Service实现类的方法上,亦可以注解在Mapper接口上(不是Mapper方法上)
- @DS+@Transactional支持事务,但 @Transactional方法内不支持切换数据源,参见上面示例代码中的MyDataServiceImpl.addBothData,即先使用ds1,则后续一直使用同一ds1连接,当前事务生效,但都会插入ds1中。
- 开启了事务后,spring事务管理器会保证在事务下整个线程后续拿到的都是同一个connection。
以上示例源码参见:
https://gitee.com/luoex/multi-datasource-demo/tree/master/mp-dynamic-ds
2.1 @DSTransactional
dynamic-datasource提供自定义的 @DSTransactional 注解,
- 支持多数据源间的本地事务
- @DSTransactional方法内支持切换数据源,需跨服务调用切换DS,否则仅使用第一个数据源
- @DSTransactional核心原理就是代理connection,并根据不同数据库获取到一个connection放入ConnectionFactory。 如果成功了整体提交,失败了整体回滚。
关于使用@DSTransactional支持多数据源本地事务的示例代码如下:
/**
* 本地多数据源事务 - 测试服务实现类<br/>
*
* @author luohq
* @date 2022-08-09 13:42
*/
@Service
public class MyDataMultiDsLocalTxServiceImpl implements IMyDataMultiDsLocalTxService {
@Resource
private IMyDataService myDataService;
/**
* 此处需使用@DSTransactional,需注意不是Spring @Transactional,
* 使用@DSTransactional支持切换数据源,而@Transactional方法中无法切换数据源
* 注:需跨服务调用切换DS,否则仅使用第一个数据源,即2条记录都插入到ds1中
*/
@Override
@DSTransactional
//@DS("ds1") //如果ds1是默认数据源则不需要DS注解。
public Integer addBothData(MyData myData1, MyData myData2) {
Integer retCount1 = this.myDataService.addData1(myData1);
Integer retCount2 = this.myDataService.addData2(myData2);
//if (true) {
// throw new RuntimeException("测试多数据源异常回滚!");
//}
return retCount1 + retCount2;
}
}
------------------------------------------------------------------------------------
/**
* <p>
* 我的数据 服务实现类
* </p>
*
* @author luohq
* @since 2022-08-07
*/
@Service
public class MyDataServiceImpl extends ServiceImpl<MyDataMapper, MyData> implements IMyDataService {
@Resource
private MyDataMapper myDataMapper;
/**
* 支持事务 - @DSTransactional区别于Spring @Transactional
*/
@Override
@DSTransactional
@DS("ds1")
public Integer addData1(MyData myData) {
//支持自动设置tenantId
Integer retCount = this.myDataMapper.insert(myData);
return retCount;
}
/**
* 支持事务 - @DSTransactional区别于Spring @Transactional
*/
@DS("ds2")
@Override
@DSTransactional
public Integer addData2(MyData myData) {
//支持自动设置tenantId
Integer retCount = this.myDataMapper.insert(myData);
return retCount;
}
}
示例代码中MyDataMultiDsLocalTxServiceImpl主服务,该主服务中addBothData方法跨服务调用MyDataServiceImpl中的使用@DS(“ds1)的addData1和使用@DS(“ds2”)的addData2,即主服务使用默认数据源(主服务方法亦可通过@DS(”…")指定数据源,未指定则使用默认),调用不同数据源的服务。
关于各服务方法上事务注解及最终效果总结为下表:
主服务 MyDataMultiDsLocalTxServiceImpl.addBothData |
ds1服务 @DS(“ds1”) MyDataServiceImpl.addData1 |
ds2服务 @DS(“ds1”) MyDataServiceImpl.addData1 |
效果 |
---|---|---|---|
@DSTransactional | @DSTransactional | @DSTransactional | 调用主服务支持全局事务提交、回滚, 单独调用ds服务各自支持事务 |
@DSTransactional | @Transactional | @Transactional | 调用主服务支持全局事务提交、回滚, 单独调用ds服务各自支持事务 |
@DSTransactional | 无 | 无 | 调用主服务支持全局事务提交、回滚, 单独调用ds服务不支持事务 |
无 | @DSTransactional | @DSTransactional | 不支持全局事务, 调用ds服务各自管理自身事务 |
@Transactional | Spring @Transactional不支持切换数据源 |
2.2 多数据源事务扩展
上面提到的 @DSTransactional 支持多数据源本地事务,如何定义多数据源本地事务?
参考如下服务分布图:
其中橙黄色的ServiceA|B|C为服务实例,而蓝色Resource即为各自服务实例对应数据库存储实例,
整个服务调用链组成了一个分布式事务,而各自Service实例自身的事务管理即为本地事务,
如下图中的绿框即标记出了各自的本地事务,其中ServiceA和ServiceC仅包含唯一的数据源,而ServiceB同时使用2个数据源,即ServiceB的需要管理的事务即为多数据源本地事务。
之前提到Dynamic-Datasource中的 @DSTransactional 注解,
- 单独使用此注解是可以解决 单体应用 或 不涉及分布式事务的单个微服务应用的多数据源本地事务这种场景的
而如上图中的完整的 分布式事务(且 存在单个服务包含多个数据源的情况) 场景,可以结合Seata:
- 使用Dynamic-Datasource @DSTransactional 解决本地事务、多数据源本地事务管理
- 使用Seata @GlobalTransaction解决全局分布式事务管理
- 支持AT、XA模式
- 具体文档参见(需付费):https://www.kancloud.cn/tracy5546/dynamic-datasource/2268607
注:
在引入多数据源时需谨慎,尤其是微服务场景下,可以尽早考虑将多个数据源各自拆分到不同的单个服务中,
- 拆分后单个服务中仅包含唯一数据源,降低开发难度,便于针对单数据源进行优化,
- 此时可直接使用Spring原生@Transactional管理单数据源本地事务(即无需引入dynamic-datasource扩展),
- 若有分布式事务管理场景,可再引入Seata。
2.3 多租户
使用Mybatis-Plus及对应的Dynamic-Datasource扩展,
- 可以共用一套Mapper接口和Mapper.xml文件,
- 而且其除了上面提到的支持使用@DS注解切换数据源,
- 还支持程序在运行时手动切换数据源
DynamicDataSourceContextHolder.push("ds1")
- 还支持内建的基于@DS的动态解析数据源的能力
- @DS(“#session.tenantName”) - 从session中获取
- @DS(“#header.tenantName”) - 从请求头中获取
- @DS(“#user.tenantName”) - 使用SPEL从参数中获取
- 还支持内建的基于@DS的动态解析数据源的能力
- 最重要的是还支持动态添加、移除数据源
通过上述特性描述,不难发现其和多租户架构相当契合:
- 单个租户对应单独的数据源,可通过租户ID(如对应请求头TENANT-ID)获取对应的数据源,如
- 租户ID直接对应数据源名称
- 或者通过租户ID关联出对应的数据源名称
- 不同租户用户访问系统时支持根据租户ID动态切换数据源
- 租户支持添加或移除,同时租户对应的数据源在应用中也支持动态添或移除
- 需考虑分布式部署中的数据源同步问题
- …
以上基于Mybatis-Plus及Dynamic-Datasource扩展的方案即可实现一套多租户(物理隔离)架构,多租户实现方案后续会单开一遍文章介绍,本文不做重点描述。
以上仅介绍Mybatis-Plus及Dynamic-Datasource扩展的部分功能,感兴趣的小伙伴可以继续深入研究:
- 如基于数据源分组的读写分离(一主多从)实现
- master, slave_1, slave_2
- MasterSlaveAutoRoutingPlugin
- 自定义实现
- 集成P6spy、Quratz、ShardingJdbc等
- 集成Seata
- …
参考:
Mybatis分包:
springboot-整合多数据源配置(MapperScan分包、mybatis plus - dynamic-datasource-spring-boot-starter)
Mybatis-Plus Dynamic Datasource:
https://baomidou.com/pages/a61e1b/
https://www.kancloud.cn/tracy5546/dynamic-datasource/2264611