从零实现 SpringBoot 简易读写分离,也不难嘛!

最近在学习 Spring boot,写了个读写分离。并未照搬网文,而是独立思考后的成果,写完以后发现从零开始写读写分离并不难!

我最初的想法是:读方法走读库,写方法走写库(一般是主库),保证在 Spring 提交事务之前确定数据源.

保证在 Spring 提交事务之前确定数据源,这个简单,利用 AOP 写个切换数据源的切面,让<typo id="typo-168" data-origin="他" ignoretag="true">他</typo>的优先级高于 Spring 事务切面的优先级。至于读,写方法的区分可以用 2 个注解。

但是如何切换数据库呢?我完全不知道!多年经验告诉我

当完全不了解一个技术时,先搜索学习必要知识,之后再动手尝试。

我搜索了一些网文,发现都提到了一个 AbstractRoutingDataSource 类。查看源码注释如下

/**
Abstract {@link javax.sql.DataSource} implementation that routes {@link #getConnection()}
 * calls to one of various target DataSources based on a lookup key. The latter is usually
 * (but not necessarily) determined through some thread-bound transaction context.
 *
 * @author Juergen Hoeller
 * @since 2.0.1
 * @see #setTargetDataSources
 * @see #setDefaultTargetDataSource
 * @see #determineCurrentLookupKey()
 */

AbstractRoutingDataSource 就是 DataSource 的抽象,基于 lookup key 的方式在多个数据库中进行切换。重点关注 setTargetDataSources,setDefaultTargetDataSource,determineCurrentLookupKey 三个方法。那么 AbstractRoutingDataSource 就是 Spring 读写分离的关键了。

仔细阅读了三个方法,基本上跟方法名的意思一致。setTargetDataSources 设置备选的数据源集合。setDefaultTargetDataSource 设置默认数据源,determineCurrentLookupKey 决定当前数据源的对应的 key。

但是我很好奇这 3 个方法都没有包含切换数据库的逻辑啊!我仔细阅读源码发现一个方法,determineTargetDataSource 方法,其实它才是获取数据源的实现。源码如下:

//切换数据库的核心逻辑
protected DataSource determineTargetDataSource() {
 Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
 Object lookupKey = determineCurrentLookupKey();
 DataSource dataSource = this.resolvedDataSources.get(lookupKey);
 if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
  dataSource = this.resolvedDefaultDataSource;
 }
 if (dataSource == null) {
  throw new IllegalStateException
          ("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
 }
 return dataSource;
}
//之前的2个核心方法
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
 this.targetDataSources = targetDataSources;
}
public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
 this.defaultTargetDataSource = defaultTargetDataSource;
}

简单说就是,根据 determineCurrentLookupKey 获取的 key, 在 resolvedDataSources 这个 Map 中查找对应的 datasource!,注意 determineTargetDataSource 方法竟然不使用的 targetDataSources!

那一定存在 resolvedDataSources 与 targetDataSources 的对应关系。我接着翻阅代码,发现一个 afterPropertiesSet 方法(Spring 源码中 InitializingBean 接口中的方法),这个方法将 targetDataSources 的值赋予了 resolvedDataSources。源码如下:

@Override
public void afterPropertiesSet() {
 if (this.targetDataSources == null) {
  throw new IllegalArgumentException("Property 'targetDataSources' is required");
 }
 this.resolvedDataSources = new HashMap<Object, DataSource>(this.targetDataSources.size());
 for (Map.Entry<Object, Object> entry : this.targetDataSources.entrySet()) {
  Object lookupKey = resolveSpecifiedLookupKey(entry.getKey());
  DataSource dataSource = resolveSpecifiedDataSource(entry.getValue());
  this.resolvedDataSources.put(lookupKey, dataSource);
 }
 if (this.defaultTargetDataSource != null) {
  this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
 }
}

afterPropertiesSet 方法,熟悉 Spring 的都知道,它在 bean 实例已经创建好,且属性值和依赖的其他 bean 实例都已经注入以后执行。

也就是说调用,targetDataSources,defaultTargetDataSource 的赋值一定要在 afterPropertiesSet 前边执行。

AbstractRoutingDataSource 简单总结:

  • AbstractRoutingDataSource,内部有一个Map<Object,DataSource>的域 resolvedDataSources
  • determineTargetDataSource 方法通过 determineCurrentLookupKey 方法获得 key,进而从 map 中取得对应的 DataSource。
  • setTargetDataSources 设置 targetDataSources
  • setDefaultTargetDataSource 设置 defaultTargetDataSource,
  • targetDataSources 和 defaultTargetDataSource 在 afterPropertiesSet 分别转换为 resolvedDataSources 和 resolvedDefaultDataSource。
  • targetDataSources,defaultTargetDataSource 的赋值一定要在 afterPropertiesSet 前边执行。

进一步了解理论后,读写分离的方式则基本上出现在眼前了。(“下列方法不唯一”)

先写一个类继承AbstractRoutingDataSource,实现determineCurrentLookupKey方法,和afterPropertiesSet方法。afterPropertiesSet方法中调用setDefaultTargetDataSource和setTargetDataSources方法之后调用super.afterPropertiesSet。

之后定义一个切面在事务切面之前执行,确定真实数据源对应的 key。但是这又出现了一个问题,如何线程安全的情况下传递每个线程独立的 key 呢? 没错使用 ThreadLocal 传递真实数据源对应的 key。

ThreadLocal,Thread 的局部变量,确保每一个线程都维护变量的一个副本

到这里基本逻辑就想通了,之后就是写了。

DataSourceContextHolder 使用 ThreadLocal 存储真实数据源对应的 key

public class DataSourceContextHolder {  
    private static Logger log = LoggerFactory.getLogger(DataSourceContextHolder.class);
 //线程本地环境  
    private static final ThreadLocal<String> local = new ThreadLocal<String>();   
    public static void setRead() {  
        local.set(DataSourceType.read.name());  
        log.info("数据库切换到读库...");  
    }  
    public static void setWrite() {  
        local.set(DataSourceType.write.name());  
        log.info("数据库切换到写库...");  
    }  
    public static String getReadOrWrite() {  
        return local.get();  
    }  
}

DataSourceAopAspect 切面切换真实数据源对应的 key,并设置优先级保证高于事务切面

@Aspect  
@EnableAspectJAutoProxy(exposeProxy=true,proxyTargetClass=true)  
@Component  
public class DataSourceAopAspect implements PriorityOrdered{

  @Before("execution(* com.springboot.demo.mybatis.service.readorwrite..*.*(..)) "  
            + " and @annotation(com.springboot.demo.mybatis.readorwrite.annatation.ReadDataSource) ")  
    public void setReadDataSourceType() {  
        //如果已经开启写事务了,那之后的所有读都从写库读  
            DataSourceContextHolder.setRead();    
    }  
    @Before("execution(* com.springboot.demo.mybatis.service.readorwrite..*.*(..)) "  
            + " and @annotation(com.springboot.demo.mybatis.readorwrite.annatation.WriteDataSource) ")  
    public void setWriteDataSourceType() {  
        DataSourceContextHolder.setWrite();  
    }  
 @Override
 public int getOrder() {
  /** 
         * 值越小,越优先执行 要优于事务的执行 
         * 在启动类中加上了@EnableTransactionManagement(order = 10)  
         */  
  return 1;
 }
}

RoutingDataSouceImpl 实现 AbstractRoutingDataSource 的逻辑

@Component
public class RoutingDataSouceImpl extends AbstractRoutingDataSource {

 @Override
 public void afterPropertiesSet() {
  //初始化bean的时候执行,可以针对某个具体的bean进行配置
  //afterPropertiesSet 早于init-method
  //将datasource注入到targetDataSources中,可以为后续路由用到的key
  this.setDefaultTargetDataSource(writeDataSource);
  Map<Object,Object>targetDataSources=new HashMap<Object,Object>();
  targetDataSources.put( DataSourceType.write.name(), writeDataSource);
  targetDataSources.put( DataSourceType.read.name(),  readDataSource);
  this.setTargetDataSources(targetDataSources);
  //执行原有afterPropertiesSet逻辑,
  //即将targetDataSources中的DataSource加载到resolvedDataSources
  super.afterPropertiesSet();
 }
 @Override
 protected Object determineCurrentLookupKey() {
  //这里边就是读写分离逻辑,最后返回的是setTargetDataSources保存的Map对应的key
  String typeKey = DataSourceContextHolder.getReadOrWrite();  
  Assert.notNull(typeKey, "数据库路由发现typeKey is null,无法抉择使用哪个库");
  log.info("使用"+typeKey+"数据库.............");  
  return typeKey;
 }
   private static Logger log = LoggerFactory.getLogger(RoutingDataSouceImpl.class); 
 @Autowired  
 @Qualifier("writeDataSource")  
 private DataSource writeDataSource;  
 @Autowired  
 @Qualifier("readDataSource")  
 private DataSource readDataSource;  
}

基本逻辑实现完毕了就进行,通用设置, 设置数据源,事务,SqlSessionFactory 等

@Primary
@Bean(name = "writeDataSource", destroyMethod = "close")
@ConfigurationProperties(prefix = "test_write")
public DataSource writeDataSource() {
 return new DruidDataSource();
}

@Bean(name = "readDataSource", destroyMethod = "close")
@ConfigurationProperties(prefix = "test_read")
public DataSource readDataSource() {
 return new DruidDataSource();
}

 @Bean(name = "writeOrReadsqlSessionFactory")
public SqlSessionFactory 
       sqlSessionFactorys(RoutingDataSouceImpl roundRobinDataSouceProxy) 
                                                       throws Exception {
 try {
  SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
  bean.setDataSource(roundRobinDataSouceProxy);
  ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
  // 实体类对应的位置
  bean.setTypeAliasesPackage("com.springboot.demo.mybatis.model");
  // mybatis的XML的配置
  bean.setMapperLocations(resolver.getResources("classpath:mapper/*.xml"));
  return bean.getObject();
 } catch (IOException e) {
  log.error("" + e);
  return null;
 } catch (Exception e) {
  log.error("" + e);
  return null;
 }
}

@Bean(name = "writeOrReadTransactionManager")
public DataSourceTransactionManager transactionManager(RoutingDataSouceImpl 
          roundRobinDataSouceProxy) {
 //Spring 的jdbc事务管理器
 DataSourceTransactionManager transactionManager = new 
              DataSourceTransactionManager(roundRobinDataSouceProxy);
 return transactionManager;
}

其他代码,就不在这里赘述了,有兴趣可以移步完整代码。

gitee.com/WLjava/spri…

使用 Spring 写读写分离,其核心就是 AbstractRoutingDataSource,源码不难,读懂之后,写个读写分离就简单了!

AbstractRoutingDataSource 重点回顾:

  • AbstractRoutingDataSource,内部有一个Map<Object,DataSource>的域 resolvedDataSources
  • determineTargetDataSource 方法通过 determineCurrentLookupKey 方法获得 key,进而从 map 中取得对应的 DataSource。
  • setTargetDataSources 设置 targetDataSources
  • setDefaultTargetDataSource 设置 defaultTargetDataSource,
  • targetDataSources 和 defaultTargetDataSource 在 afterPropertiesSet 分别转换为 resolvedDataSources 和 resolvedDefaultDataSource。
  • targetDataSources,defaultTargetDataSource 的赋值一定要在 afterPropertiesSet 前边执行。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,295评论 6 512
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,928评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,682评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,209评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,237评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,965评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,586评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,487评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,016评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,136评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,271评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,948评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,619评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,139评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,252评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,598评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,267评论 2 358

推荐阅读更多精彩内容