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;
   }

最終通過了第二種方式解決了,執行順序不對的問題,通過配置讀賬號權限解決了髒數據的問題,然後就可以正常的使用了。


都看到這了喜歡的話點個關注,後續會有更多好玩的技術文章分享,感謝!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章