「项目纪实」——mybatisplus多数据源读写分离
为什么有这个需求
最近我们有个线上项目因为读写数据量比较大,导致数据库压力增大,所以现在打算上读写分离的方案。
怎么解决
我们用的框架是springboot+mybatisplus,而且是线上的项目,不能大量改造,各项成本太高,这是前提条件。
目前有2种方案,
1.直接引入mybatisplus的多数据源的方式,在方法或者类层面使用注解@DS(“xxx”),这种方案呢比较灵活,但是我们线上很多代码有用了mybatisplus自己封装的单表增删改查接口,这些接口无法使用注解,单独去修改的话会导致改动的工作量比较大。
2.自定义数据源,通过mybatis的拦截器获取sql类型,查询语句走从库数据源,增删改走主库数据源。
对比了我们的需求,最终决定还是用第二个方式来做。
开始试验
创建demo
建2个库 demo_master,demo_slave
建一个表 user
插入一些数据
CREATE TABLE user
(
id BIGINT(20) NOT NULL COMMENT '主键ID',
name VARCHAR(30) NULL DEFAULT NULL COMMENT '姓名',
age INT(11) NULL DEFAULT NULL COMMENT '年龄',
email VARCHAR(50) NULL DEFAULT NULL COMMENT '邮箱',
PRIMARY KEY (id)
);
INSERT INTO user (id, name, age, email) VALUES
(1, 'Jone', 18, 'test1@baomidou.com'),
(2, 'Jack', 20, 'test2@baomidou.com'),
(3, 'Tom', 28, 'test3@baomidou.com'),
(4, 'Sandy', 21, 'test4@baomidou.com'),
(5, 'Billie', 24, 'test5@baomidou.com');
初始化一个maven项目,引入相关依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.0</version>
<relativePath />
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</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>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
配置数据源
spring:
datasource:
hikari:
master:
jdbc-url: jdbc:mysql://localhost:3306/demo01_master
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
jdbc-url: jdbc:mysql://localhost:3306/demo01_slave
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
生成一下entity,mapper,service等
接下来创建一个切换数据源的工具类DynamicDataSourceHolder
public class DynamicDataSourceHolder {
private static ThreadLocal<String> contextHolder = new ThreadLocal<>();
public static final String DB_MASTER = "master";
public static final String DB_SLAVE = "slave";
public static String getDbType() {
String db = contextHolder.get();
if (db == null) {
db = DB_MASTER;
}
return db;
}
public static void setDBType(String str) {
log.info("当前设置数据源为" + str);
contextHolder.set(str);
}
public static void clearDbType() {
contextHolder.remove();
}
}
实现一个动态数据源 继承AbstractRoutingDataSource,这个类运行我们根据定义的规则选择当前的数据源
public class DynamicDataSource extends AbstractRoutingDataSource {
@Nullable
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceHolder.getDbType();
}
}
添加一个拦截器,用来拦截执行sql
@Component
@Slf4j
//指定拦截哪些方法,update包括增删改
@Intercepts({ @Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class }),
@Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class }) })
public class DynamicDataSourceInterceptor implements Interceptor {
private static final String REGEX = ".*insert\\u0020.*|.*delete\\u0020.*|.*update\\u0020.*";
@Override
public Object intercept(Invocation invocation) throws Throwable {
boolean synchronizationActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("当前执行语句是否有事务:{}",synchronizationActive);
String lookupKey = DynamicDataSourceHolder.DB_MASTER;
if (!synchronizationActive) {
Object[] objects = invocation.getArgs();
MappedStatement ms = (MappedStatement) objects[0];
if (ms.getSqlCommandType().equals(SqlCommandType.SELECT)) {
// 如果selectKey为自增id查询主键,使用主库
if (ms.getId().contains(SelectKeyGenerator.SELECT_KEY_SUFFIX)) {
lookupKey = DynamicDataSourceHolder.DB_MASTER;
} else {
BoundSql boundSql = ms.getSqlSource().getBoundSql(objects[1]);
String sql = boundSql.getSql().toLowerCase(Locale.CHINA).replaceAll("[\\t\\n\\r]", " ");
if (sql.matches(REGEX)) {
lookupKey = DynamicDataSourceHolder.DB_MASTER;
} else {
// 这里如果有多个从数据库,则添加挑选过程
lookupKey = DynamicDataSourceHolder.DB_SLAVE;
}
}
}
} else {
lookupKey = DynamicDataSourceHolder.DB_MASTER;
}
DynamicDataSourceHolder.setDBType(lookupKey);
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
// 增删改查的拦截,然后交由intercept处理
if (target instanceof Executor) {
return Plugin.wrap(target, this);
} else {
return target;
}
}
@Override
public void setProperties(Properties properties) {
}
再创建一个配置类,用来创建数据源,配置事务管理器等
@Configuration
@MapperScan(basePackages = "demo01.mapper")
public class MyBatisPlusConfig {
/**
* 配置数据源
* @return
*/
@Bean(name = "master")
@ConfigurationProperties(prefix = "spring.datasource.hikari.master")
public DataSource master() {
return DataSourceBuilder.create().build();
}
@Bean(name = "slave")
@ConfigurationProperties(prefix = "spring.datasource.hikari.slave")
public DataSource slave() {
return DataSourceBuilder.create().build();
}
@Primary
@Bean(name = "dynamicDataSource")
public DynamicDataSource dataSource(@Qualifier("master") DataSource master,
@Qualifier("slave") DataSource slave) {
Map<Object, Object> targetDataSource = new HashMap<>();
targetDataSource.put(DynamicDataSourceHolder.DB_MASTER, master);
targetDataSource.put(DynamicDataSourceHolder.DB_SLAVE, slave);
DynamicDataSource dataSource = new DynamicDataSource();
dataSource.setTargetDataSources(targetDataSource);
return dataSource;
}
/**
* 配置事务管理器
*/
@Bean
public DataSourceTransactionManager transactionManager(DynamicDataSource dataSource) throws Exception {
return new DataSourceTransactionManager(dataSource);
}
}
开始测试
我们需要几个简单的测试用例
用例 | 期望 | 实际 |
---|---|---|
调用查询接口 | 查询的是从库 | √ |
调用一次新增数据接口,再调用一次查询接口 | 主库新增,查询的是从库,从库无新增的数据 | √ |
一个接口里面,先新增再查询,不带事务注解 | 主库新增,查询的是从库,从库无新增的数据 | √ |
一个接口里面,先新增再查询,带事务注解 | 主库新增,查询的是主库,有对应数据 | √ |