SpringBoot讀寫分離實戰
文章共6000字,純文字的文章寫的比較少,但是不多寫一點很難讓讀者深入瞭解,所以請耐心看完,後面會有源碼,基本上看明白原理了,複製粘貼即可
實際環境建議使用mysql5.7版本,8.0版本坑會比較多,如果使用雲環境的mysql讀寫分離版本一般也基於5.7版本,但是搭建教程基本一致
基於mysql8+docker搭建主從集羣文章地址
1 讀寫分離適用的場景
- 讀多寫少
- 併發量小
- 非強一致性場景
當併發量大時,應使用緩存架構,而非加強數據庫層吞吐能力,當大量併發進入數據庫層,cpu直接會彪滿,造成數據庫卡死的現象,讀寫分離解決讀的性能,水平擴展多臺機器提升了整體讀的能力。
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("清理 ");
}
}
到這裏第一種方式就完成了,但是由於我是對舊工程進行改造,發現了一些問題
- 最嚴重的問題,無法通過order排序,某些方法執行順序正確,某些方法永遠都是先切換數據源再進行進入切面導致determineCurrentLookupKey方法永遠返回null使用默認數據源,而且執行一個方法會切換多次,暫時不清楚具體原因可能由於shiro等過濾器導致。
- 由於最開始沒有把權限分開導致了在測試期間某些方法未加註解,其實走了從庫但是依然寫了進去造成數據髒數據過多,無法恢復主從同步(看過開頭就知道了不能反向同步,而且髒數據過多很難完全清理和主數據一致),所以開始就配置好操作從庫的賬號只有只讀權限即可解決。
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;
}
最終通過了第二種方式解決了,執行順序不對的問題,通過配置讀賬號權限解決了髒數據的問題,然後就可以正常的使用了。
都看到這了喜歡的話點個關注,後續會有更多好玩的技術文章分享,感謝!