最近遇到一个项目需要实现主从库读写分离,网上找了很多资料,基本上都大同小异,参考网上的代码,也能够实现读写分离,本地测试都没有问题,但是一发布到测试环境,时不时就会出现在做保存操作的时候就会报错read-only,但是后台日志又显示已经切换到主表了,后来经过层层排查,终于找到问题了。为了记录这次问题,特地写下这篇文章和大家一起分享。
话不多说,代码走起。
大体上也就是下面这些常规的:
org.springframework.boot
spring-boot-starter-jdbc
org.springframework.boot
spring-boot-starter-aop
org.springframework.boot
spring-boot-starter-test
test
org.mybatis.spring.boot
mybatis-spring-boot-starter
1.1.1
com.alibaba
fastjson
1.2.58
mysql
mysql-connector-java
8.0.16
com.alibaba
druid
1.1.9
org.projectlombok
lombok
true
在application.yml中添加
spring:
#数据库
datasource:
#主库
mastersource:
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
type-aliases-package: com.org.test.**.entity
#从库
slavesource:
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3306/test?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: readonly
password: readonly
type-aliases-package: com.org.test.**.entity
driver-class-name这里要注意一下,如果是mysql5以上的版本是com.mysql.cj.jdbc.Driver,假如使用的是mysql5及以下版本,则换成com.mysql.jdbc.Driver。
创建一个枚举类,用于存放我们多个数据源。
/**
* 主从数据库
*/
public enum DBTypeEnum {
/**主库**/
MASTER,
/**从库**/
SLAVE
}
创建DBContextHolder类,里面主要提供一些切换数据源的方法。
/**
* 动态切换数据源
*/
public class DBContextHolder {
private static final Logger log = LoggerFactory.getLogger(DBContextHolder.class);
//线程局部变量
private static final ThreadLocal CONTEXT_HOLDER = new ThreadLocal<>();
/**
* 原子操作类,确保线程安全
*/
private static final AtomicInteger COUNTER = new AtomicInteger(0);
//往线程里边set数据类型
public static void set(DBTypeEnum dbType) {
if(dbType == null) throw new NullPointerException();
CONTEXT_HOLDER.set(dbType);
}
/**
* 容器中获取数据类型,默认主库
* @return
*/
public static DBTypeEnum get(){
return CONTEXT_HOLDER.get() == null ? DBTypeEnum.MASTER : CONTEXT_HOLDER.get();
}
/**
* 清空容器中的数据类型
*/
public static void remove(){
CONTEXT_HOLDER.remove();
}
/**
* 切换到主库
*/
public static void master(){
set(DBTypeEnum.MASTER);
log.info("数据源切换到" +DBTypeEnum.MASTER);
}
/**
* 切换到从库
*/
public static void slave(){
if(COUNTER.get() > 9999){
COUNTER.set(0);
}
set(DBTypeEnum.SLAVE);
log.info("数据源切换到" +DBTypeEnum.SLAVE);
}
}
创建RoutingDataSource类,这个类要继承AbstractRoutingDataSource类,也是实现切换数据源的一个核心代码,我们需要重新determineCurrentLookupKey这个方法,从而实现动态的切换数据源。
public class RoutingDataSource extends AbstractRoutingDataSource {
private final Logger log = LoggerFactory.getLogger(getClass());
@Nullable
@Override
protected Object determineCurrentLookupKey() {
DBTypeEnum dbTypeEnum = DBContextHolder.get();
log.info("当前数据源:" + dbTypeEnum );
return dbTypeEnum;
}
}
创建DataSourceConfig类,初始化主从库的数据源,同时把多个数据源都放到targetDataSource里面。
@Configuration
public class DataSourceConfig {
/**
* 加载主数据源
* @return
*/
@Bean("masterDataSource")
@ConfigurationProperties("spring.datasource.mastersource")
public DataSource masterDataSource(){
return DataSourceBuilder.create().build();
}
/**
* 加载从数据源
* @return
*/
@Bean("slaveDataSource")
@ConfigurationProperties("spring.datasource.slavesource")
public DataSource slaveDataSource(){
return DataSourceBuilder.create().build();
}
/**
* 将多个数据源加载到 AcstractRoutingDataSource中的targetDataSource
* @param masterDataSource
* @param slaveDataSource
* @return
*/
@Bean("targetDataSource")
public DataSource myRoutinDataSource(@Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("slaveDataSource") DataSource slaveDataSource){
Map
创建SqlSessionFactoryConfig类,根据targetDataSource初始化SqlSessionFactory。
@Configuration
@EnableTransactionManagement
@MapperScan(basePackages = "com.org.test.**.mapper")
public class SqlSessionFactoryConfig {
/**
* 分页
* @return
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
@Resource(name = "targetDataSource")
private DataSource targetDataSource;
/**
* 初始化SqlSessionFactory,指定mapper的路径
* @return
* @throws Exception
*/
@Bean(name = "SqlSessionFactory")
public SqlSessionFactory sqlSessionFactory() throws Exception {
/** 这个为mybatis的配置**/
// SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
// sessionFactoryBean.setDataSource(targetDataSource);
// //sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResource("classpath:/mapper/*Mapper.xml"));
// return sessionFactoryBean.getObject();
/** 这个为MyBatis Plus的配置,此处注意mybatis与mybatisPlus的配置不同,不然扫描不到对数据操作的方法。会报未绑定错误*/
MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(targetDataSource);
sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:/mapper/**/*Mapper.xml"));
MybatisConfiguration mybatisConfiguration = new MybatisConfiguration();
sqlSessionFactoryBean.setConfiguration(mybatisConfiguration);
//手动设置分页
Interceptor[] plugins = new Interceptor[1];
plugins[0] = mybatisPlusInterceptor();
sqlSessionFactoryBean.setPlugins(plugins);
return sqlSessionFactoryBean.getObject();
}
/**
* 事务处理器
* @return
*/
@Bean
public PlatformTransactionManager platformTransactionManager(){
return new DataSourceTransactionManager(targetDataSource);
}
}
创建DataSourceAop类,里面主要是对service方法进行拦截,当是以save、insert、update、delete等方法开头,则使用主库数据源,当是以query开头的方法,则使用从库。
@Aspect
@Component
public class DataSourceAop {
private final Logger log = LoggerFactory.getLogger(getClass());
@Pointcut("execution(* com.org.test..*.service..*.query*(..))")
public void readPonitcut(){}
@Pointcut("execution(* com.org.test..*.service..*.check*(..)) " +
"|| execution(* com.org.test..*.service..*.save*(..)) " +
"|| execution(* com.org.test..*.service..*.insert*(..)) " +
"|| execution(* com.org.test..*.service..*.update*(..)) " +
"|| execution(* com.org.test..*.service..*.add*(..)) " +
"|| execution(* com.org.test..*.service..*.edit*(..)) " +
"|| execution(* com.org.test..*.service..*.delete*(..)) " +
"|| execution(* com.org.test..*.service..*.remove*(..))")
public void writePointcut(){}
@Before("readPonitcut()")
public void read(){
DBContextHolder.slave();
log.info("使用的是从库");
}
@Before("writePointcut()")
public void write(){
DBContextHolder.master();
log.info("使用的是主库");
}
/**
* 这里是重点
*/
@After("readPonitcut()")
public void remove(){
DBContextHolder.remove();
log.info("清空数据源");
}
}
上面是对service层的拦截,当然大家也可以根据需要对dao层进行拦截,或者使用@DS注释,或者自己新增一个注解,在这里进行拦截,这种拦截方式网上还是很多的,这里就不多做说明。这对我开发时遇到的两个难题做一下分享。正如我开头说的那样,在实际使用中,明明日志已经显示切换成了主库,但是在做保存等操作时,还是会报read-only的错误,显示我们在做保存操作时实际使用的竟然是从库(从库只读)。后来在排查问题中,发现主要是因为下面2个原因造成的:1、我在save的service里面因为有权限校验,所以先调用了别的service的query方法,然后再去保存数据,同时save方法上加@Transactional注解,这就导致在调用别的service的query方式,数据源已经切换到了从库,然后因为加了@Transactional注解,导致该save方法不会再切换数据源了,所以出现了上面那种在做保存操作的时候,报read-only的错误。这里有2个解决方案,一个是把权限校验的service调用去掉,把里面的实现方法替换掉service调用,这样在一个service 方法里,不会有其他service查询方法,也就不会把数据源切换到从库了;另一个解决方案就是把这个权限校验的service方法,原来是query开头的,我改成了check开头,同时把check开头的方法放到主库里,这样就算save方法里面切换了数据源也是切换到主库,所以不会再有问题了。2、在解决了这个问题之后,我们又发现,当我直接做新增的时候,不会报错,但是当我做编辑的时候,如果我操作太快,就又会报read-only的错误,奇怪的是,后台日志依然显示数据源切换是正确的。但是后来我又尝试一下,如果我编辑的时候,打开编辑信息,等一会儿再提交,就不会报错。下面就是划重点了,DataSourceAop里面的remove方法一定要加上。当时我怀疑是因为数据源缓存的问题造成上面那种情况。在网上找了很多资料,都没有找到。后来我就想在aop拦截器对数据源进行了切换,那么在操作玩查询之后,我能不能把数据源缓存给清空掉,所以就有了@After("readPonitcut()")的处理。
到这里使用springboot+mybatis或者mybatis-plus实现主从库分离的方法基本上就结束了。主要是为了记录这次遇到的难题,同时也跟大家一起分享。