暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

深入剖析源码速通Spring多数据源问题

写在文章开头

之前写过一篇关于多数据源的实践,虽然写的很详细,但是个人认为没有把多数据源的原理和设计讲透彻,所以再次拾起这篇文章进行修饰加工,希望对你有帮助。

当一个接口请求涉及多库操作时,就涉及多数据源问题,举个例子:当我们进行下单时,涉及用户表、商品表、订单表,而这些数据表分别在基础信息库、商品管理库、详单管理库。对此我们就需要SpringBoot
完成多数据源加载和动态切换。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注  “加群”  即可和笔者和笔者的朋友们进行深入交流。

spring数据源加载原理详解

数据源的加载

为了解决上述问题,笔者特地查看了Spring数据源
加载的源码,在调试中看到一个AbstractRoutingDataSource
是决定数据源的关键。查看其类图可以发现它用到了InitializingBean
这个接口,说明在bean加载完成之后会针对数据源进一步的处理操作。

查看AbstractRoutingDataSource
InitializingBean
的实现可以看到它的逻辑,非常简单,从targetDataSources
数据源信息的键值对并将其存入resolvedDataSources
中。很明显从这个扩展点可以推断出Spring允许加载多个数据源。

@Override
 public void afterPropertiesSet() {
  if (this.targetDataSources == null) {
   throw new IllegalArgumentException("Property 'targetDataSources' is required");
  }
   
  //将targetDataSources所有的值存到resolvedDataSources中
  this.resolvedDataSources = CollectionUtils.newHashMap(this.targetDataSources.size());
  this.targetDataSources.forEach((key, value) -> {
   Object lookupKey = resolveSpecifiedLookupKey(key);
   DataSource dataSource = resolveSpecifiedDataSource(value);
   this.resolvedDataSources.put(lookupKey, dataSource);
  });
 //如果resolvedDefaultDataSource 不为空,则将当前项目的defaultTargetDataSource 设置为defaultTargetDataSource
  if (this.defaultTargetDataSource != null) {
   this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
  }
 }

复制

转换成图解就像下面这样,可以看到上述的代码会将yml
配置中的数据源信息存入resolvedDataSources
这个map
中。

数据源的切换

了解了数据源的加载,接下来我们就需要了解数据源的切换了,对此笔者专门写下一段数据库查询的业务代码并插入断点了解其内部的执行过程:

userMapper.selectByPrimaryKey(id)

复制

通过断点调试Mybatis
代理生成的mapper
的底层逻辑,笔者看到一个名为DataSourceUtils
的工具类,它会通过外部传入的数据源调用doGetConnection
返回一个SQL
连接:

public static Connection getConnection(DataSource dataSource) throws CannotGetJdbcConnectionException {
  try {
   return doGetConnection(dataSource);
  }
  //.....
  }
 }

复制

查看doGetConnection
方法,笔者得到最关键的一段代码fetchConnection
,该方法就会获取SQL方法核心方法:

Connection con = fetchConnection(dataSource);

复制

最终它又会走到我们上文提到加载数据源的抽象类AbstractRoutingDataSource
,调用其getConnection
方法,其内部会调用determineTargetDataSource
获取数据源实例,再从这个数据源中拿到一个connection
实例,从而完成一次SQL
执行。

@Override
 public Connection getConnection() throws SQLException {
 //调用下方的determineTargetDataSource获取相应数据源
  return determineTargetDataSource().getConnection();
 }


protected DataSource determineTargetDataSource() {
  Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
  //determineCurrentLookupKey获取数据源的key值
  Object lookupKey = determineCurrentLookupKey();
  DataSource dataSource = this.resolvedDataSources.get(lookupKey);
  //略
  return dataSource;
 }


复制

查看determineCurrentLookupKey
这个方法发现它是一个抽象类,也就是说这是框架允许我们自行实现的方法。

@Nullable
 protected abstract Object determineCurrentLookupKey();


复制

结合上下文我们由此可知,我们可以通过继承AbstractRoutingDataSource
重写determineCurrentLookupKey
告知spring
当前操作要用哪个key
的数据源,然后让其基于这个数据源获取对应的connection
从而完成SQL
操作:

需求和设计思路

通过源码了解了Spring
数据源的工作流程之后,我们不妨提出这样一个接口,该接口用于购买汽车,外部会传入汽车id、用户id,基于这两个信息完成如下操作:

  1. 从db1查询用户信息
  2. 从db2查询汽车信息
  3. 到db3完成下单

从源码中了解了spring
的设计思路之后,我们现在就不妨设计一下多数据源切换的实现思路。首先是技术实现上:

  1. maven引入相关依赖。
  2. 编写多数据源的配置。
  3. 编写配置类将数据源加载到spring容器中。
  4. 编写一个线程数据源管理类,分别存放每一个请求线程的数据源key值。
  5. 编写一个数据源管理类,负责加载项目运行时的数据源加载和存放。
  6. 继承AbstractRoutingDataSource重写determineCurrentLookupKey基于线程数据源管理类实现获取最新数据源的逻辑。

业务实现上:

  1. 编写用户信息查询功能。
  2. 编写汽车信息查询功能。
  3. 编写下单信息存储功能。

好了,话不多说,现在就开始实现这个需求。

实现

引入相关依赖这步骤笔者就不多赘述了,读者只要按需引入对应web和MySQL相关依赖即可。

然后就是编写数据源配置类将数据源加载到容器的逻辑,我们首先需要将多个数据源的信息添加到yml
文件中。

spring:
  datasource:
    druid:
      type: com.alibaba.druid.pool.DruidDataSource
      master:
        url: jdbc:mysql://rm-xxxxxxx.mysql.rds.aliyuncs.com:3306/db1?useUnicode=true&characterEncoding=utf-8&useSSL=false
        username: xxxxxx
        password: xxxxx
        driver-class-name: com.mysql.jdbc.Driver
      slave:
        url: jdbc:mysql://rm-xxxxxxxxxx.mysql.rds.aliyuncs.com:3306/db2?useUnicode=true&characterEncoding=utf-8&useSSL=false
        username: xxxxxxx
        password: xxxxxxxx
        driver-class-name: com.mysql.jdbc.Driver

复制

然后我们编写一个DruidConfig
配置类,将上面master
slave
库和数据源的bean
绑定并存到spring
容器中。

/**
 * 数据源配置类
 */

@Configuration
@MapperScan("com.example.springdatasource.mapper")
public class DruidConfig {

    /**
     * 主库数据源bean,和spring.datasource.druid.master配置绑定
     * @return
     */

    @Bean(name = CommonConstant.MASTER)
    @ConfigurationProperties("spring.datasource.druid.master")
    public DataSource masterDataSource()
    
{
        DruidDataSource master = DruidDataSourceBuilder.create().build();
        return master;
    }

    /**
     * 从库数据源bean,和spring.datasource.druid.slave绑定
     * @return
     */

    @Bean(name = CommonConstant.SLAVE)
    @ConfigurationProperties("spring.datasource.druid.slave")
    public DataSource slaveDataSource()
    
{
        DruidDataSource slave = DruidDataSourceBuilder.create().build();
        return slave;
    }

    /**
     * 动态数据源bean
     * @return
     */

    @Bean
    @Primary
    public DynamicDataSource dynamicDataSource()
    
{
        //创建一个存放数据源的map
        Map<Object, Object> dataSourceMap = new HashMap<>(2);
        //将上述两个数据源存放到map中
        dataSourceMap.put(CommonConstant.MASTER,masterDataSource());
        dataSourceMap.put(CommonConstant.SLAVE,slaveDataSource());


        //设置动态数据源,默认为master配置的数据源,并指定数据源的map
        DynamicDataSource dynamicDataSource = new DynamicDataSource();
        dynamicDataSource.setDefaultTargetDataSource(masterDataSource());
        dynamicDataSource.setTargetDataSources(dataSourceMap);


        //将数据源信息备份在defineTargetDataSources中
        dynamicDataSource.setDefineTargetDataSources(dataSourceMap);

        return dynamicDataSource;
    }
}

复制

我们都知道在spring boot
这个web
应用中,每一个请求对应一个线程,所以我们完全可以通过将每一个请求当前时刻所要用到的数据源的key
存在threadLocal
中,确保每一个请求的key
互相隔离:

所以我们编写了一个DynamicDataSourceHolder
通过ThreadLocal
实现线程间的数据源隔离。

/**
 * 数据源切换处理类
 *
 */

@Slf4j
public class DynamicDataSourceHolder {
    /**
     * 为每个线程存放当前数据源的ThreadLocal
     */

    private static final ThreadLocal<String> DYNAMIC_DATASOURCE_KEY = new ThreadLocal<>();

    /**
     * 为当前线程切换数据源
     */

    public static void setDynamicDataSourceKey(String key) {
        log.info("数据源切换key:{}", key);
        DYNAMIC_DATASOURCE_KEY.set(key);
    }

    /**
     * 获取动态数据源的名称,默认情况下使用mater数据源
     */

    public static String getDynamicDataSourceKey() {
        String key = DYNAMIC_DATASOURCE_KEY.get();
        if (ObjectUtils.isEmpty(key)) {
            key = CommonConstant.MASTER;
        }
        log.info("获取数据源,key:{}", key);
        return key;
    }

    /**
     * 将ThreadLocal置空,移除当前数据源
     */

    public static void removeDynamicDataSourceKey() {
        log.info("移除数据源:{}", DYNAMIC_DATASOURCE_KEY.get());
        DYNAMIC_DATASOURCE_KEY.remove();
    }
}


复制

我们后续可能用到不止两个的数据库,所以我们可能会将数据源的信息保存到数据源中,考虑到这一点,我们编写了一个数据源管理类,负责将用户从数据库中查出来的数据源信息存到容器中。

为了做到这一点,我们首先需要编写一个数据源的类,记录一下数据库查出来的数据源信息。

/**
 * 数据源对象类
 */

public class DataSourceInfo {

    private String userName;
    private String passWord;
    private String url;
    private String dataSourceKey;

  //get set...
}

复制

然后我们就来编写数据源管理类,实现数据源加载和保存的逻辑,代码含义笔者都已详尽注释,读者可以自行查阅。

/**
 * 数据源管理工具类
 */

@Slf4j
@Component
public class DataSourceUtil {

    @Resource
    DynamicDataSource dynamicDataSource;

    /**
     * 测试数据源是否可用,如果可用即直接返回
     * @param dataSourceInfo
     * @return
     * @throws SQLException
     */

    public DruidDataSource createDataSourceConnection(DataSourceInfo dataSourceInfo) throws SQLException {
        //创建数据源对象
        DruidDataSource druidDataSource = new DruidDataSource();
        druidDataSource.setUrl(dataSourceInfo.getUrl());
        druidDataSource.setUsername(dataSourceInfo.getUserName());
        druidDataSource.setPassword(dataSourceInfo.getPassWord());
        druidDataSource.setBreakAfterAcquireFailure(true);
        druidDataSource.setConnectionErrorRetryAttempts(0);
        try {
            //尝试连接数据源
            druidDataSource.getConnection(2000);
            log.info("数据源:{}连接成功", JSONUtils.toJSONString(dataSourceInfo));
            return druidDataSource;
        } catch (SQLException e) {
            log.error("数据源 {} 连接失败,用户名:{},密码 {}",dataSourceInfo.getUrl(),dataSourceInfo.getUserName(),dataSourceInfo.getPassWord());
            return null;
        }
    }

    /**
     * 将外部数据源存到dynamicDataSource并调用afterPropertiesSet刷新
     * @param druidDataSource
     * @param dataSourceName
     */

    public void addDefineDynamicDataSource(DruidDataSource druidDataSource, String dataSourceName){
        Map<Object, Object> defineTargetDataSources = dynamicDataSource.getDefineTargetDataSources();
        //存到defineTargetDataSources这个map中
        defineTargetDataSources.put(dataSourceName, druidDataSource);
        dynamicDataSource.setTargetDataSources(defineTargetDataSources);
        //调用afterPropertiesSet重新遍历map中的数据源键值对存到resolvedDataSources中
        dynamicDataSource.afterPropertiesSet();
    }
}

复制

到上述步骤为止,我们已经编写动态数据源应用的行为,那么我们又该如何获取最新的数据源呢?还记得我们上文编写的DynamicDataSourceHolder
吗?它通过ThreadLocal
将可以得到当前线程的数据源的key
。 基于这点我们直接继承AbstractRoutingDataSource
重写determineCurrentLookupKey
方法,让这个方法从DynamicDataSourceHolder
获取当前sql
操作要用到哪个datasource
key

@Data
@AllArgsConstructor
@NoArgsConstructor
public class DynamicDataSource extends AbstractRoutingDataSource {
    //备份所有数据源信息,
    private Map<Object, Object> defineTargetDataSources;

    /**
     * 返回当前线程需要用到的数据源bean
     */

    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceHolder.getDynamicDataSourceKey();
    }
}


复制

逻辑实现

自此我们所有的工作都准备好了,接下来就开始写业务代码了,我们要写一个用户下单购买汽车的流程:

  1. 查询用户。
  2. 查询汽车。
  3. 如果都存在则创建订单,并保存到数据库。
  4. 返回成功标识。

代码如下,虽然调试一下功能确实没问题。但不难看到笔者都是手动实现数据源切换,将业务和非业务代码耦合在一起,非常不方便。

 @Resource
    private DataSourceUtil dataSourceUtil;
    @Resource
    private CommonMapper commonMapper;


    @PostMapping("/orderCar")
    public boolean dynamicDataSourceTest(@RequestBody Map<String,Object> params) throws SQLException {
        Map<String, Object> map = new HashMap<>();
        //在主库中查询汽车信息列表
        User user = commonMapper.getUserInfo((String) params.get("uid"));
        if (user==null){
            throw new RuntimeException("用户不存在");
        }


        //在从库中查询db3数据源信息
        DynamicDataSourceHolder.setDynamicDataSourceKey(CommonConstant.SLAVE);
        DataSourceInfo dataSourceInfo = commonMapper.getNewDataSourceInfo("slave2");
        map.put("dataSource", dataSourceInfo);
        log.info("数据源信息:{}", dataSourceInfo);
        //尝试db3的连接是否可用
        DruidDataSource druidDataSource = dataSourceUtil.createDataSourceConnection(dataSourceInfo);


        Car car=null;

        if (Objects.nonNull(druidDataSource)) {
            //如果db3可用则直接将db3存到动态数据源map中
            dataSourceUtil.addDefineDynamicDataSource(druidDataSource, dataSourceInfo.getDataSourceKey());
            //切换当前数据源为db3
            DynamicDataSourceHolder.setDynamicDataSourceKey(dataSourceInfo.getDataSourceKey());
            //在新的数据源中查询用户信息
             car = commonMapper.getCarInfo((String) params.get("cid"));
            if (car==null){
                throw new RuntimeException("汽车不存在");
            }
        }

        //切回数据源源2
        DynamicDataSourceHolder.setDynamicDataSourceKey(CommonConstant.SLAVE);
        Map<String,Object> orderInfo=new HashMap<>();
        orderInfo.put("uid",user.getId());
        orderInfo.put("cid",car.getId());
        orderInfo.put("total",car.getPrice());
        commonMapper.saveOrderInfo(orderInfo);

        return true;
    }

复制

优化

所以我们希望用AOP
来优化上述手动切换数据源的情况,实现思路如下:

  1. 定义一个注解,记录数据源信息。
  2. 编写一个切面,拦截有该注解的类或者方法根据注解的值自动切换数据源。

所以首先编写一个注解,该注解专门记录当前方法或者类用到了数据源的key

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface Ds
{
    /**
     * 切换数据源名称
     */

    public String value() default CommonConstant.MASTER;
}

复制

然后我们针对注解,编写一个切面,通过获取注解的value决定切换到哪个数据源。

@Aspect
@Component
public class DataSourceAspect {

    // 设置Ds注解的切点表达式,所有Ds都会触发当前环绕通知
    @Pointcut("@annotation(com.example.springdatasource.annotation.Ds)")
    public void dynamicDataSourcePointCut(){

    }

    //环绕通知
    @Around("dynamicDataSourcePointCut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
        //获取数据源的key
        String key = getDefineAnnotation(joinPoint).value();
        //将数据源设置为该key的数据源
        DynamicDataSourceHolder.setDynamicDataSourceKey(key);
        try {
            return joinPoint.proceed();
        } finally {
            //使用完成后切回master
            DynamicDataSourceHolder.removeDynamicDataSourceKey();
        }
    }

    /**
     * 先判断方法的注解,后判断类的注解,以方法的注解为准
     * @param joinPoint
     * @return
     */

    private Ds getDefineAnnotation(ProceedingJoinPoint joinPoint){
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Ds dataSourceAnnotation = methodSignature.getMethod().getAnnotation(Ds.class);
        if (Objects.nonNull(methodSignature)) {
            return dataSourceAnnotation;
        } else {
            Class<?> dsClass = joinPoint.getTarget().getClass();
            return dsClass.getAnnotation(Ds.class);
        }
    }

}

复制

这样一来,我们的mapper
只需添加一个注解即可完成数据源切换:

@Mapper
public interface CommonMapper {



    @Ds("master")
    User getUserInfo(String id);


    @Ds("slave")
    DataSourceInfo getNewDataSourceInfo(String sourceKey);
    @Ds("slave")
    int saveOrderInfo(Map<String,Object> orderInfo);

    @Ds("slave2")
    Car getCarInfo(String id);
}


复制

然后我们的业务代码就可以简化了。

@PostMapping("/orderCar2")
    public boolean orderCar2(@RequestBody Map<String,Object> params) throws SQLException {
        Map<String, Object> map = new HashMap<>();
        //在主库中查询汽车信息列表
        User user = commonMapper.getUserInfo((String) params.get("uid"));
        if (user==null){
            throw new RuntimeException("用户不存在");
        }


        //在从库中查询db3数据源信息
        DataSourceInfo dataSourceInfo = commonMapper.getNewDataSourceInfo("slave2");
        map.put("dataSource", dataSourceInfo);
        log.info("数据源信息:{}", dataSourceInfo);
        //尝试db3的连接是否可用
        DruidDataSource druidDataSource = dataSourceUtil.createDataSourceConnection(dataSourceInfo);



        Car car=null;

        if (Objects.nonNull(druidDataSource)) {
            dataSourceUtil.addDefineDynamicDataSource(druidDataSource,dataSourceInfo.getDataSourceKey());
            //在新的数据源中查询用户信息
            car = commonMapper.getCarInfo((String) params.get("cid"));
            if (car==null){
                throw new RuntimeException("汽车不存在");
            }
        }

        //切回数据源源2
        Map<String,Object> orderInfo=new HashMap<>();
        orderInfo.put("uid",user.getId());
        orderInfo.put("cid",car.getId());
        orderInfo.put("total",car.getPrice());
        commonMapper.saveOrderInfo(orderInfo);

        return true;
    }

复制

自此我们就完成了一个只需一个注解解决多数据源问题。

小结

我是 sharkchiliCSDN Java 领域博客专家开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注  “加群”  即可和笔者和笔者的朋友们进行深入交流。

参考文献

SpringBoot整合多数据源,动态添加新数据源并切换(保姆级教程):https://juejin.cn/post/7222186286563737655#heading-5


文章转载自写代码的SharkChili,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论