SpringBoot读写分离实践

SpringBoot读写分离实战

文章共6000字,纯文字的文章写的比较少,但是不多写一点很难让读者深入了解,所以请耐心看完,后面会有源码,基本上看明白原理了,复制粘贴即可

实际环境建议使用mysql5.7版本,8.0版本坑会比较多,如果使用云环境的mysql读写分离版本一般也基于5.7版本,但是搭建教程基本一致
基于mysql8+docker搭建主从集群文章地址

1 读写分离适用的场景

  1. 读多写少
  2. 并发量小
  3. 非强一致性场景

当并发量大时,应使用缓存架构,而非加强数据库层吞吐能力,当大量并发进入数据库层,cpu直接会彪满,造成数据库卡死的现象,读写分离解决读的性能,水平扩展多台机器提升了整体读的能力。

2 读写分离缺点

  1. 数据冗余
  2. 一致性问题

实现高可用的方式多以数据冗余的方式出现,这样当一台故障就可以迁移到另一台机器,而读写分离架构通过数据冗余的方式并未达到高可用,当主库故障时,仅能提供读的可用性,当读库故障时又需要重新手动配置同步,主从之间通过binlog进行同步数据,数据同步会有一定的延迟,导致读不出已写事物的数据现象,而从库不可以反向同步,当发现主从数据不一致时难以恢复一致(从集群搭建测试完成后,就应该将读写的权限分开,任何人不可以手动对从库进行写操作,否则后期会引发一系列bug问题),导致无法同步。

3 SpringBoot实现读写分离(基于MyBatis)

核心是通过Spring提供的abstractRoutingDataSource实现切换数据源的功能

1 基于@Aspect声明式实现

实现原理,首先定义两个数据源(或多个,这里讲两个,多个读数据源需要做自定义负载均衡策略决定使用哪个),定义自己的RoutingDataSource并继承abstractRoutingDataSource,并配置自定义的routingDataSource,将读写数据源放入,配置好默认数据源等之后,配置切面,通过配置切入点表达式来进行读写切换,这里又可以自己定义想使用的方式,如果service层命名的规则定义的比较好可以通过名称来切换,也可以定义注解在方法上,进行切换,原理都是扫描到切入点,在service执行之前,对数据源进行切换(所以切面的执行优先级一定要大于切换数据源),否则会无效


1 先定义一个routingDataSource,实现AbstractRoutingDataSource,并重写determineCurrentLookupKey方法即可,determineCurrentLookupKey方法是实际切换数据源的方法(感兴趣可以看下源码,很简单),下面的DbRwEnum就是一个简单枚举,定义了master和salve两种状态

@Slf4j
public class RoutingDataSource extends AbstractRoutingDataSource {

   @Override
   protected Object determineCurrentLookupKey() {
       log.info("当前数据源: {}", Objects.isNull(DBContextHolder.get())? DbRwEnum.MASTER.getType():DbRwEnum.SLAVE.getType());
       return Objects.isNull(DBContextHolder.get())? DbRwEnum.MASTER.getType():DbRwEnum.SLAVE.getType();
   }

}

2 定义读写的数据源,我这里用的hikaricp其余差别也不大

@Configuration
@Order(1)
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();
   }
   @Primary
   @Bean(name = "routingDataSource")
   public DataSource routingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource,
                                       @Qualifier("slaveDataSource") DataSource slaveDataSource) {
       Map<Object, Object> targetDataSources = new HashMap<>(2);
       targetDataSources.put(DbRwEnum.MASTER.getType(), masterDataSource);
       targetDataSources.put(DbRwEnum.SLAVE.getType(), slaveDataSource);
       RoutingDataSource routingDataSource = new RoutingDataSource();
       //默认数据源,当切面没有切到对应的方法或者其他情况会默认使用主数据源
       routingDataSource.setDefaultTargetDataSource(masterDataSource);
       routingDataSource.setTargetDataSources(targetDataSources);
       DBContextHolder.set(DbRwEnum.MASTER.getType());
       return routingDataSource;
   }}

3 配置mybatis的sqlSessionFactory和事物

    @Bean
   public MybatisSqlSessionFactoryBean sqlSessionFactory() throws Exception {
       MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
       sqlSessionFactoryBean.setDataSource(routingDataSource);
       return sqlSessionFactoryBean;
   }
   @Bean
   @Primary
   public PlatformTransactionManager platformTransactionManager() {
       return new DataSourceTransactionManager(routingDataSource);
   }

4 配置切换的上下文

public class DBContextHolder {
   //记录当前请求线程所持有的数据源信息
   private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();

   public static void set(String dbType) {
       contextHolder.set(dbType);
   }

   public static String get() {
       return contextHolder.get();
   }
   //防止ThreadLocal内存泄漏,垃圾回收时弱引用只回收了key对应那块内存value的那块内存依然占有且不会被回收
   public static void clear() {
        contextHolder.remove();
   }

   public static void switchMaster() {
       set(DbRwEnum.MASTER.getType());
       log.info("数据源切换到master");
   }

   public static void switchSlave() {
       set(DbRwEnum.SLAVE.getType());
       log.info("数据源切换到slave");
   }
}

5 配置切面以及表达式

@Aspect
@Order(-999)
public class SwitchDataSourceAspect {

   @Pointcut("@annotation(com.*.*.annotations.UseMaster)")
   public void masterPointcut() {}
   //提示一下这里注解不要加在接口上要加在实现类上
   @Pointcut("execution(public * com.*.*.service.impl.*.*(..)))")
   public void point(){}

   @Before("!masterPointcut() && point()")
   public void read() {
       DBContextHolder.switchSlave();
   }

   @Before("masterPointcut() && point()")
   public void write() {
       DBContextHolder.switchMaster();
   }
   @After("point() || masterPointcut()")
   public void after(JoinPoint p) {
       DBContextHolder.clear();
       log.info("清理 ");
   }
}

到这里第一种方式就完成了,但是由于我是对旧工程进行改造,发现了一些问题

  1. 最严重的问题,无法通过order排序,某些方法执行顺序正确,某些方法永远都是先切换数据源再进行进入切面导致determineCurrentLookupKey方法永远返回null使用默认数据源,而且执行一个方法会切换多次,暂时不清楚具体原因可能由于shiro等过滤器导致。
  2. 由于最开始没有把权限分开导致了在测试期间某些方法未加注解,其实走了从库但是依然写了进去造成数据脏数据过多,无法恢复主从同步(看过开头就知道了不能反向同步,而且脏数据过多很难完全清理和主数据一致),所以开始就配置好操作从库的账号只有只读权限即可解决。

4 上面的问题可以通过编程式aop解决

我怀疑是否是因为由于项目使用了MyBatisPlus框架导致有一些功能无法运作,最后官网提供了一种读写分离方案,我带着怀疑的态度看了一下源码,本质上和上面的实现方式并无过多差别,但是我用了一下发现居然好用?,见鬼了,所以我就借鉴了其中一部分源码,在对原读写分离架构的源码不过多修改的情况下,进行改造,最终可以达到想要的效果。

2 基于编程式aop实现

1 实现MethodInterceptor,实现代理切面(MethodInterceptor?如果玩过动态代理一看就知道这是cglib的实现方式,当然这里的MethodInterceptor是aopalliance提供的)

public class DataSourceInterceptor implements MethodInterceptor {
   @Override
   public Object invoke(MethodInvocation methodInvocation) throws Throwable {
       Method method = methodInvocation.getMethod();
       UseMaster annotation = method.getAnnotation(UseMaster.class);
       try {
           if(DbRwEnum.MASTER.equals(annotation.value())){
               DBContextHolder.switchMaster();
               return methodInvocation.proceed();
           }else{
               DBContextHolder.switchSlave();
               return methodInvocation.proceed();
           }
       }finally {
           DBContextHolder.clear();
       }
   }
}

2 有了切面肯定要有切入点,既然我们已经使用了注解,那么就按照注解的方式继续实现,通过继承AbstractPointcutAdvisor来实现aop,这里定义了注解切入点AnnotationMatchingPointcut(平时我们用注解方式比较多很少使用这种方式,正好可以了解下这种方式,打开脑洞可以实现很多好玩的功能)

public class DataSourceAdvisor extends AbstractPointcutAdvisor {
   Advice advice;
   Pointcut pointcut;
   public DataSourceAdvisor(Advice advice){
       this.advice = advice;
       this.pointcut = buildPointcut();
   }
   @Override
   public Pointcut getPointcut() {
       return pointcut;
   }

   @Override
   public Advice getAdvice() {
       return advice;
   }

   private Pointcut buildPointcut() {
       Pointcut cpc = new AnnotationMatchingPointcut(UseMaster.class, true);
       Pointcut mpc = AnnotationMatchingPointcut.forMethodAnnotation(UseMaster.class);
       //组合切入点
       return new ComposablePointcut(cpc).union(mpc);
   }
}

3 最后交给spring去管理就好了,设置一下执行优先级

 @Bean
   public DataSourceAdvisor dynamicDatasourceAnnotationAdvisor() {
       DataSourceInterceptor interceptor = new DataSourceInterceptor();
       DataSourceAdvisor advisor = new DataSourceAdvisor(interceptor);
       advisor.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
       return advisor;
   }

最终通过了第二种方式解决了,执行顺序不对的问题,通过配置读账号权限解决了脏数据的问题,然后就可以正常的使用了。


都看到这了喜欢的话点个关注,后续会有更多好玩的技术文章分享,感谢!

你可能感兴趣的:(Mysql,运维,mysql,读写分离,SpringBoot)