springboot+mybatis实现主从库读写分离

        最近遇到一个项目需要实现主从库读写分离,网上找了很多资料,基本上都大同小异,参考网上的代码,也能够实现读写分离,本地测试都没有问题,但是一发布到测试环境,时不时就会出现在做保存操作的时候就会报错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 targetDataSource = new HashMap<>();
        targetDataSource.put(DBTypeEnum.MASTER, masterDataSource);
        targetDataSource.put(DBTypeEnum.SLAVE, slaveDataSource);
        RoutingDataSource routingDataSource = new RoutingDataSource();
        routingDataSource.setDefaultTargetDataSource(masterDataSource);
        routingDataSource.setTargetDataSources(targetDataSource);
        return  routingDataSource;
    }

}

创建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);
    }

}
  • 六、aop拦截实现自动切换数据源

创建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实现主从库分离的方法基本上就结束了。主要是为了记录这次遇到的难题,同时也跟大家一起分享。

你可能感兴趣的:(spring,boot,mybatis,mysql)