springboot多数据源配置实例

多数据源配置, 大多数情况下是用在读写库或者主从库之间的自动切换. 但是在有些情况下, 业务上也需要切换数据源.

而现在主流的多数据源方案, 无论是使用现有的框架还是自定义, 使用的核心原理都是类似的. 这个实例, 就是模拟业务上多数据源的使用场景, 自定义切换规则, 以实现多数据源的切换.

实例的实现逻辑是:

在有注解标注的地方, 按照优先使用注解标注的数据源;

如果注解没有指定数据源, 将解析URL来自动匹配数据源;

如果没有使用注解, 将使用默认数据源

使用的原理是线程内部维护数据源变量, 当请求进来时, 利用切面, 指定数据源, 并在线程内部传递

因为使用的变量都是维护在线程内部, 故而当有线程切换时, 会丢失数据源, 此时需要手动传递数据源, 或者依据对应技术使用可行的技术实现动态的切换, 但是原理是一致的

实例里的多处核心逻辑都是可以扩展或者自定义的, 以满足不同业务的需求.

使用的框架和技术:

springboot mybatis mysql

ThreadLocal AOP DynamicDataSource

代码展示

先展示一下实例的文件结构:

image.png

下面对主要的几个文件进行说明:

application.yml

# 应用名称
spring:
  profiles:
    # 引入application-jdbc.properties配置文件
    active: jdbc
  application:
    name: database
  # 数据库驱动
  datasource:
    db1:
      driver-class-name: com.mysql.cj.jdbc.Driver
      username: ${db1.username}
      password: ${db1.password}
      jdbc-url: ${db1.url}
    db2:
      driver-class-name: com.mysql.cj.jdbc.Driver
      username: ${db2.username}
      password: ${db2.password}
      jdbc-url: ${db2.url}

server:
  port: 80
  servlet:
    context-path: /db
# 指定Mybatis的Mapper文件
mybatis:
  mapper-locations: classpath:mappers/*xml
  type-aliases-package: jin.panpan.database.entity

#日志配置
logging:
  config: classpath:log4j2.xml
复制代码

配置文件, 除了基本的应用名称、端口、路径、日志等配置以外, 主要看数据库驱动的配置, 这里我们不像平时的单数据源的配置, 我们需要配置多个数据源, 上面示例的配置文件里, 由两个注意点:

  • db1和db2是我们自定义的数据源名称, 这个我们在后面的MybatisConfig文件里会用到
  • 数据源里的配置jdbc-url对应单数据源配置里的url, 这里使用url会注入失败
  • ${db1.username}等是导入的application-jdbc.properties文件里的配置值

application-jdbc.properties

db1.username = user
db1.password = 
db1.url = jdbc:mysql://xx.xx.xx.xx:3306/db1

db2.username = user
db2.password = 
db2.url = jdbc:mysql://xx.xx.xx.xx:3306/db2
复制代码

key-value结构, key可以在引入此文件的配置里使用${key}引用

这里我们使用两个数据库, 但是数据库的表结构一致(至少多数据源涉及到的表需要结构一致), 以避免不能共用一套代码

DataSourceType

public enum DataSourceType {
    //NONE用来返回默认
    NONE(""),
    DB1("db1"),
    DB2("db2"),
    ;
    //省略部分代码
}
复制代码

此文件是数据源的枚举, 这里的枚举我们只配置里一个参数, 可以依据业务扩展. 本枚举的值代表数据源的名称, 是和配置文件里的配置对应上的

另外说明一下, NONE是用来代表默认数据源的, 是为了方便编程添加的, 不是必须的

DataSourceUtil

public class DataSourceUtil {

    private static final ThreadLocal<DataSourceType> localDataSource = new ThreadLocal<>();

    private DataSourceUtil(){
    }

    public static DataSourceType get() {
        return localDataSource.get();
    }

    public static void set(DataSourceType type){
        localDataSource.set(type);
    }

    public static void remove() {
        localDataSource.remove();
    }

}
复制代码

数据源切换工具类, 这里的核心是ThreadLocal<DataSourceType>变量, ThreadLocal以前有过文档分析, 主要是用来维护线程内部变量的, 其中:

  • get()方法用来获取数据源

  • set()方法用来设置数据源

  • remove()用来清除数据源

MybatisConfig*

@Configuration
@MapperScan("jin.panpan.database.dao")
public class MybatisConfig {
    //默认数据源
    @Primary
    @Bean("db1")
    @ConfigurationProperties(prefix = "spring.datasource.db1")
    public DataSource dataSource1(){
        return DataSourceBuilder.create().build();
    }
    @Bean("db2")
    @ConfigurationProperties(prefix = "spring.datasource.db2")
    public DataSource dataSource2(){
        return DataSourceBuilder.create().build();
    }
    //动态数据源选择
    @Bean
    public DynamicDataSource dynamicDataSource(@Qualifier("db1") DataSource db1,
                                               @Qualifier("db2") DataSource db2){
        Map<Object, Object> map = new HashMap<>();
        //此处的key 要和DynamicDataSource类的determineCurrentLookupKey方法返回值一致
        map.put(DataSourceType.DB1.getDatabase(), db1);
        map.put(DataSourceType.DB2.getDatabase(), db2);

        DynamicDataSource dynamicDataSource = new DynamicDataSource();
        dynamicDataSource.setTargetDataSources(map);
        //设置默认数据库, 选择的数据库Bean注入时要带@Primary注释
        dynamicDataSource.setDefaultTargetDataSource(db1);

        return dynamicDataSource;
    }
    //会话工厂配置
    @Bean
    public SqlSessionFactory sqlSessionFactory(DynamicDataSource dynamicDataSource) throws Exception {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dynamicDataSource);
        Resource[] resources = new PathMatchingResourcePatternResolver()
                                    .getResources("classpath:mappers/*xml");
        factoryBean.setMapperLocations(resources);
        return factoryBean.getObject();
    }
    //事务管理配置
    @Bean
    public PlatformTransactionManager transactionManager(DynamicDataSource dynamicDataSource){
        return new DataSourceTransactionManager(dynamicDataSource);
    }

}
复制代码

本类主要用来配置和注入mybatis相关配置bean, 主要有下面几步:

  • 自动扫描mapper接口配置

    @MapperScan("jin.panpan.database.dao")

    一般常规配置, 如果不配置多数据源, 一般是在入口类上注解

  • 数据源注入

    @ConfigurationProperties 导入配置文件里的配置

    @Bean 指定数据源名称

    @Primary 标记默认数据源

  • 数据源动态选择Bean注入

    加载多个数据源, 如果需要的话, 指定默认数据源

    需要注意数据源容器Map的key要和DataSourceType里的字段值一致, 否则无法切换

  • 会话工厂Bean配置

    单数据源时, 一般无需手动配置; 多数据源时, 需要手动指定会话工厂, 但是配置是模式化的

  • 事务管理Bean配置

    事务管理的配置也是模式化的, 一般无特殊处理之处

DynamicDataSource*

public class DynamicDataSource extends AbstractRoutingDataSource {
    private static final Logger logger = LoggerFactory.getLogger(DynamicDataSource.class);

    @Override
    protected Object determineCurrentLookupKey() {
        String database = DataSourceUtil.get() == null ? null : DataSourceUtil.get().getDatabase();
        logger.info("DynamicDataSource, 动态数据源返回={}", database);
        return database;
    }
}
复制代码

此类其实是动态路由数据源的核心类, 扩展AbstractRoutingDataSource类, 重写determineCurrentLookupKey方法, determineCurrentLookupKey方法的返回值就是动态数据源, 返回值需要和配置文件及MybatisConfig里的一一对应.

DataSource和DataSourceAspect*

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface DataSource {

    DataSourceType value() default DataSourceType.NONE;

}
复制代码

DataSource是一个方法注解, 用来作为数据源切换切入点, 此处可以使用类注解或者直接正则织入想监控的点

DataSourceAspect是注解的实现, 是核心实现

@Aspect
@Component
public class DataSourceAspect {
    private static final Logger logger = LoggerFactory.getLogger(DataSourceAspect.class);

    @Pointcut("@annotation(jin.panpan.database.dynamic.datasource.annotate.DataSource)")
    public void pointCut() {
    }

    @SneakyThrows
    @Around("pointCut()")
    public Object around(ProceedingJoinPoint pjp) {
        //数据源
        DataSourceType dataSourceType = null;

        //方法对象
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Method method = signature.getMethod();
        //注释
        DataSource dynamicDataSource = method.getAnnotation(DataSource.class);
        String path = null;
        //注释中指定数据源, 则使用数据库中的数据源
        if(dynamicDataSource.value()!=null && dynamicDataSource.value() != DataSourceType.NONE){
            dataSourceType = dynamicDataSource.value();
        }

        //否则从请求路径中获取
        if(dataSourceType == null){
            HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
            path = request.getRequestURL().toString();
            //TODO 待优化 需截取域名进行检查
            if(path.contains(DataSourceType.DB1.getDatabase())){
                dataSourceType = DataSourceType.DB1;
            }
            if(path.contains(DataSourceType.DB2.getDatabase())){
                dataSourceType = DataSourceType.DB2;
            }
        }

        logger.info("DataSource, 方法={}, 数据源={}, 路径={}, 注释自带数据源={}",
                method.getName(), dataSourceType==null ? null : dataSourceType.getDatabase(),
                path, dynamicDataSource.value());

        if(dataSourceType == null){
            throw new Exception("非法数据源");
        }

        //指定数据库
        DataSourceUtil.set(dataSourceType);

        Object result = pjp.proceed();

        //清除数据库
        logger.info("DataSource, 清除数据源, 方法={}, 数据源={}, 路径={}, 注释自带数据源={}",
                method.getName(), dataSourceType.getDatabase(),
                path, dynamicDataSource.value());
        DataSourceUtil.remove();

        return result;
    }

}
复制代码

重要的步骤已经加了注解, 完成的工作是:

  • 获取数据源 从注解/从路径
  • 指定数据源
  • 调用结束后清除数据源

核心步骤是通过DataSourceUtil切换数据源

此方法是实现切换逻辑的核心方法, 具体的切换逻辑, 应该由业务逻辑决定, 这里只是示范

逻辑验证

这里说明一下测试数据:

两个数据库: db1 db2

db1中有一条数据 id=1

db2中没有数据

核心代码也有日志, 可以依据日志观察数据源

为了区分路径, 在hosts里加了如下配置, 以区分不同环境路径:

127.0.0.1       db1.db.com
127.0.0.1       db2.db.com
127.0.0.1       db.com
复制代码

不指定数据源, 也不启用数据源注解

@GetMapping("queryById/{id}")
public Result<BasTableEntity> selectById(@PathVariable("id") Long id){
    BasTableEntity entity;
    try {
        entity = basTableService.queryById(id);
    }catch (Exception e){
        logger.error("查询异常, 错误={}", e.getMessage(), e);
        return Result.fail(null, "查询异常");
    }
    return Result.success(entity);
}
复制代码

按预期使用默认数据源db1,可以查询到一条数据

image.png

启用注解, 不指定数据源

@DataSource
@GetMapping("queryByIdj/{id}")
public Result<BasTableEntity> selectByIdj(@PathVariable("id") Long id){
    BasTableEntity entity;
    try {
        entity = basTableService.queryById(id);
    }catch (Exception e){
        logger.error("查询异常, 错误={}", e.getMessage(), e);
        return Result.fail(null, "查询异常");
    }
    return Result.success(entity);
}
复制代码

按预期将启用地址匹配数据源

image.png

image.png

启用注解, 指定数据源

@DataSource(DataSourceType.DB2)
@GetMapping("queryByIdp/{id}")
public Result<BasTableEntity> selectByIdp(@PathVariable("id") Long id){
    BasTableEntity entity;
    try {
        entity = basTableService.queryById(id);
    }catch (Exception e){
        logger.error("查询异常, 错误={}", e.getMessage(), e);
        return Result.fail(null, "查询异常");
    }
    return Result.success(entity);
}
复制代码

按预期将使用指定的数据源2

image.png

测试在线程间切换时, 导致的数据源丢失

@SneakyThrows
@DataSource(DataSourceType.DB2)
@GetMapping("queryByIda/{id}")
public Result<BasTableEntity> selectByIda(@PathVariable("id") Long id){
    logger.info("1.basTable={}", DataSourceUtil.get());
    //线程池, 模拟要在多线程环境下进行的业务动作
    ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
    Future<BasTableEntity> future;
    BasTableEntity entity;
    try {
        future = newCachedThreadPool.submit(() -> {
            logger.info("2.basTable={}", DataSourceUtil.get());
            return basTableService.queryById(id);
        });
    }catch (Exception e){
        logger.error("查询异常, 错误={}", e.getMessage(), e);
        return Result.fail(null, "查询异常");
    }
    logger.info("3.basTable={}", DataSourceUtil.get());
    entity = future.get();
    newCachedThreadPool.shutdown();
    return Result.success(entity);
}
复制代码

image.png

可以看出, 虽然指定了db2, 但是还是查询出了数据, 说明走了默认数据源, 有日志也可以看出

image.png 在线程里(2.basTable=)数据源丢失了, 因为线程已经不是原先的线程了

在线程间切换时, 导致的数据源丢失问题解决

@SneakyThrows
@DataSource(DataSourceType.DB2)
@GetMapping("queryByIdt/{id}")
public Result<BasTableEntity> selectByIdt(@PathVariable("id") Long id){
    //获取数据源
    DataSourceType type = DataSourceUtil.get();//****核心代码1****
    logger.info("1.basTable={}", type);
    //线程池, 模拟要在多线程环境下进行的业务动作
    ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
    Future<BasTableEntity> future;
    BasTableEntity entity;
    try {
        future = newCachedThreadPool.submit(() -> {
            //进入新线程, 传递数据源
            DataSourceUtil.set(type);//****核心代码2****
            logger.info("2.basTable={}", DataSourceUtil.get());
            return basTableService.queryById(id);
        });
    }catch (Exception e){
        logger.error("查询异常, 错误={}", e.getMessage(), e);
        return Result.fail(null, "查询异常");
    }
    logger.info("3.basTable={}", DataSourceUtil.get());
    entity = future.get();
    newCachedThreadPool.shutdown();
    return Result.success(entity);
}
复制代码

image.png 看日志:

image.png

数据源传递成功

在其他的数据源可能丢失的情况, 都可以使用此方法实现


资源清单: database-dev.zip

Guess you like

Origin juejin.im/post/7031940424713371655