读写分离/多数据源配置
技术选型
实现关键点
使用springboot实现mysql的读写分离,或者说多数据源配置,最关键的一点就是实现:sql的动态路由
即对于一个要执行的sql,系统自动判断这个sql将要在哪个mysql服务器上执行,也就是需要实现数据源的动态切换。
spring提供了一个用于切换数据源的抽象类:
org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
可以集成这个抽象类,通过实现其determineCurrentLookupKey方法来返回一个 可用的数据源唯一标识,即数据源的key,以下是该抽象类的源码:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package org.springframework.jdbc.datasource.lookup;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import javax.sql.DataSource;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.jdbc.datasource.AbstractDataSource;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
@Nullable
private Map
其中:determineTargetDataSource方法是真正返回数据源的方法,内部执行了以下一段代码:
Object lookupKey = this.determineCurrentLookupKey();
DataSource dataSource = (DataSource)this.resolvedDataSources.get(lookupKey);
if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
dataSource = this.resolvedDefaultDataSource;
}
调用了determineCurrentLookupKey方法来获取数据源的key,从resolvedDataSources中拿到真正的数据源
而resolvedDataSources的赋值来自哪里呢?继续看源码发现以下这个方法中操作了赋值:
public void afterPropertiesSet() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
} else {
this.resolvedDataSources = new HashMap(this.targetDataSources.size());
this.targetDataSources.forEach((key, value) -> {
Object lookupKey = this.resolveSpecifiedLookupKey(key);
DataSource dataSource = this.resolveSpecifiedDataSource(value);
this.resolvedDataSources.put(lookupKey, dataSource);
});
if (this.defaultTargetDataSource != null) {
this.resolvedDefaultDataSource = this.resolveSpecifiedDataSource(this.defaultTargetDataSource);
}
}
}
该方法顾名思义:就是在类参数得到set后执行的,使用一个循环来将targetDataSources的内容放到`resolvedDataSources中,以及默认的数据源resolvedDefaultDataSource来源于defaultTargetDataSource,到这里就很明了,如果我们要实现自定义的多数据源切换,重点就是:
代码如下
新建一个实现类
public class MyRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DBContextHolder.get();
}
}
DBContextHolder持有一个数据源的key,通过get方法来获取
@Slf4j
public class DBContextHolder {
private static final ThreadLocal contextHolder = new ThreadLocal<>();
public static void set(DBTypeEnum dbTypeEnum) {
contextHolder.set(dbTypeEnum);
}
public static DBTypeEnum get(){
return contextHolder.get();
}
public static void master() {
set(DBTypeEnum.MASTER);
log.info("切换到主库");
}
public static void slave() {
set(DBTypeEnum.SLAVE);
log.info("切换到从库");
}
}
其中DBTypeEnum是一个标志不同数据源的枚举
public enum DBTypeEnum {
MASTER,SLAVE;
}
在DBContextHolder中使用了ThreadLocal来存储数据源的key,是为了保证并发情况下每一个线程都有自己独立的数据源的key,互不干扰,同时提供了master(),salve()两个方法用于数据源标识的切换
接下来是最重要的一部分:数据源的配置
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource myRoutingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource,
@Qualifier("slaveDataSource") DataSource slaveDataSource) {
Map dataSourceMap = new HashMap<>();
dataSourceMap.put(DBTypeEnum.MASTER,masterDataSource);
dataSourceMap.put(DBTypeEnum.SLAVE,slaveDataSource);
MyRoutingDataSource myRoutingDataSource = new MyRoutingDataSource();
myRoutingDataSource.setTargetDataSources(dataSourceMap);
myRoutingDataSource.setDefaultTargetDataSource(masterDataSource);
return myRoutingDataSource;
}
}
这里就是通过两个set方法实现了多个数据源的注入以及默认数据源的注入
如果要实现读写分离,可以使用aop进行拦截,将要执行的sql分为两类,一类是修改数据库的,一类是读取数据库的,对不同的分组执行相应的数据源切换操作
@Aspect
@Component
public class DataSourceAop {
@Pointcut("!@annotation(com.cb.separation.annotation.Master) " +
"&& (execution(* com.cb.separation.service..*.select*(..)) " +
"|| execution(* com.cb.separation.service..*.get*(..))" +
"|| execution(* com.cb.separation.service..*.find*(..))" +
"|| execution(* com.cb.separation.service..*.query*(..)))")
public void slavePointcut() {
}
/*主库的切点,或者标注了Master注解或者方法名为insert、update等开头的方法,走主库*/
@Pointcut("@annotation(com.cb.separation.annotation.Master) " +
"|| execution(* com.cb.separation.service..*.insert*(..)) " +
"|| execution(* com.cb.separation.service..*.add*(..)) " +
"|| execution(* com.cb.separation.service..*.update*(..)) " +
"|| execution(* com.cb.separation.service..*.edit*(..)) " +
"|| execution(* com.cb.separation.service..*.delete*(..)) " +
"|| execution(* com.cb.separation.service..*.remove*(..))")
public void masterPointcut() {
}
@Before("slavePointcut()")
public void slave() {
DBContextHolder.slave();
}
@Before("masterPointcut()")
public void master() {
DBContextHolder.master();
}
}
由于读写分离的mysql架构,会存在一定的时效问题,即从库的数据可能比主库的数据要晚一些达到同步,所以对于一些时效性要求特别高的sql,可以继续在主库上执行,实现方式就是增加一个注解@Master,需要在主库上执行的方法,在其上面加上该注解,aop对其进行拦截即可
数据库配置:
spring:
datasource:
master:
jdbc-url: jdbc:mysql://localhost:3306/order_master?serverTimezone=GMT%2b8&autoReconnect=true&useUnicode=true&characterEncoding=utf-8&autocommit=false
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
slave:
jdbc-url: jdbc:mysql://localhost:3306/order_slave?serverTimezone=GMT%2b8&autoReconnect=true&useUnicode=true&characterEncoding=utf-8&autocommit=false
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
logging:
level:
cn.enjoyedu.rwseparation: DEBUG
root: INFO
发现的问题:
对于带有@Transactional的方法,其内部的查询方法,虽然得到了拦截,但是读sql的执行还是在主库上进行
@Transactional
public void insertOrders(int orderNumber){
Random r = new Random();
OrderExp orderExp ;
for(int i=0;i
从库中是空表,这里读取sql的执行结果不应该是30,而应该是0,说明是在主库上进行的
发现数据源进行了成功的切换而且执行也是在从库中
由此联想到,@Transactional注解标识的方法在执行完毕前始终使用的是同一个数据库连接,直到方法执行完毕。可以使用编程式事务来代替该注解
@Autowired
private TransactionTemplate transactionTemplate;
public void insertOrders(final int orderNumber){
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
Random r = new Random();
OrderExp orderExp ;
int num = orderNumber;
try {
for (int i = 0; i < num; i++) {
long expireTime = r.nextInt(20) + 5;//订单的超时时长,单位秒5~25
orderExp = new OrderExp();
String orderNo = "DD00_" + expireTime + "S";//订单的编号
orderExp.setOrderNo(orderNo);
orderExp.setOrderNote("海王5排" + expireTime + "号,过期时长:" + orderNo);
orderExp.setOrderStatus(UNPAY);//订单的状态,目前为未支付
orderExpMapper.insertDelayOrder(orderExp, expireTime);
log.info("保存订单到DB:" + orderNo);
if (i == 3) {
throw new RuntimeException("ceshi");
}
}
} catch (Exception e) {
e.printStackTrace();
transactionStatus.setRollbackOnly();
}
}
});
//查询
this.findOrders();
}
这样需要事务的那部分代码并不会一直占用数据库连接,从而可以达到目的
完整代码地址:
https://github.com/kkll1314/mysql-rw-seperation