Blog

Make it work, make it right, make it fast

May 19, 2022 - 6 minute read - Java 编程

SpringBoot多数据源

在某些场景下,系统可能不止一个数据库,需要根据实际需要切换数据源访问不同的数据库,在Spring框架下为我们提供了org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource类用于设置多数据源和指定切换规则。

基本配置

数据库配置

首先新建两个数据库,这是直接使用docker启动两个测试数据库

docker ps
CONTAINER ID   IMAGE                COMMAND                  CREATED        STATUS                PORTS                                     NAMES
98c5b1d4ab69   postgres             "docker-entrypoint.s…"   2 days ago     Up 2 days             0.0.0.0:5432->5432/tcp                    postgres-db
88723934d79a   mysql/mysql-server   "/entrypoint.sh --ch…"   9 months ago   Up 5 days (healthy)   0.0.0.0:3306->3306/tcp, 33060-33061/tcp   mysql

分别在Mysql和Postgres新建两个测试用库master和slave,并各自新建test_table表,插入数据

-- mysql
DROP TABLE IF EXISTS `test_table`;
CREATE TABLE `test_table` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

INSERT INTO `test_table` VALUES (1, 'master');

-- postgresql
DROP TABLE IF EXISTS "public"."test_table";
CREATE TABLE "public"."test_table" (
  "id" int4 NOT NULL,
  "name" varchar(255) COLLATE "pg_catalog"."default"
);

INSERT INTO "public"."test_table" VALUES (1, 'slave');

项目配置

项目整体目录结构如下:

tree
.
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── datasource
    │   │           └── demo
    │   │               ├── DemoApplication.java
    │   │               ├── config
    │   │               │   ├── DataSourceConfig.java
    │   │               │   └── DynamicDataSource.java
    │   │               ├── entity
    │   │               │   └── DataEntity.java
    │   │               └── mapper
    │   │                   └── DataMapper.java
    │   └── resources
    │       └── application.yml
    └── test
        └── java
            └── com
                └── datasource
                    └── demo
                        └── DemoApplicationTests.java

15 directories, 11 files

pom.xml配置如下,主要引入了springboot-start、数据库驱动、lombok、druid、logback等依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.6.7</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.datasource</groupId>
	<artifactId>demo</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>demo</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>8</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>2.2.2</version>
		</dependency>

		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.postgresql</groupId>
			<artifactId>postgresql</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
			<version>1.2.8</version>
        </dependency>
		<dependency>
			<groupId>ch.qos.logback</groupId>
			<artifactId>logback-classic</artifactId>
	  </dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>

</project>

application.yml主要对数据源进行配置

spring:
    datasource:
        druid:
            master:
                url: jdbc:mysql://127.0.0.1:3306/master?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC&useAffectedRows=true
                username: root
                password: 123456
                driver-class-name: com.mysql.cj.jdbc.Driver
            slave:
                url: jdbc:postgresql://127.0.0.1:5432/slave?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC&useAffectedRows=true
                username: root
                password: 123456
                driver-class-name: org.postgresql.Driver

接下来在DataSorceConfig对数据源进行注入

package com.datasource.demo.config;

import java.util.HashMap;
import java.util.Map;

import javax.sql.DataSource;

import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class DataSourceConfig {
    
    @Bean
    @ConfigurationProperties("spring.datasource.druid.master")
    public DataSource masterDataSource() {
        return DruidDataSourceBuilder.create().build();
    }

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

    @Bean
    @Primary
    public DynamicDataSource dataSource(Map<String, DruidDataSource> druidDataSourceMap) {
        Map<String, DataSource> dataSourceMap = new HashMap<>(druidDataSourceMap);
        return new DynamicDataSource(dataSourceMap);
    }

}

这里设置ConfigurationProperties的时候很多人会新建一个配置类作为参数接受数据库配置,这是没有必要的,这里返回的DataSource对象会自动注入配置信息。我们将DynamicDataSource标注为@Primary,在Autowired等注解注入数据源首先会选择该对象,DynamicDataSource是数据源切换的关键。

DynamicDataSource配置类如下:

package com.datasource.demo.config;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

import javax.sql.DataSource;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@RequiredArgsConstructor
@Slf4j
public class DynamicDataSource extends AbstractRoutingDataSource {


    private final AtomicInteger count = new AtomicInteger();
    private final Map<String, DataSource> targetDataSources;

    @Override
    protected Object determineCurrentLookupKey() {
        int i = count.incrementAndGet();
        List<String> list = new ArrayList<>(targetDataSources.keySet());
        String dataSource = list.get(i % list.size());
        // String dataSource = list.get(1);
        log.info(">>>>>> 当前数据库: {} <<<<<<", dataSource);
        return dataSource;
    }
    
    @Override
    public void afterPropertiesSet() {
        super.setTargetDataSources(new HashMap<>(targetDataSources));
        super.afterPropertiesSet();
    }
    
}

该类主要继承AbstractRoutingDataSource类,并重写determineCurrentLookupKey和afterPropertiesSet两个方法,afterPropertiesSet将候选数据源设置到成员变量,determineCurrentLookupKey选择返回的数据源,我们选择了一个简单的轮询方式返回数据源。

测试

package com.datasource.demo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@MapperScan("com.datasource.demo.mapper")
@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}

}

在启动类配置Mapper扫描路径,自动装配Mapper,这里为了简化操作,使用注解的方式绑定SQL

package com.datasource.demo.mapper;

import com.datasource.demo.entity.DataEntity;

import org.apache.ibatis.annotations.Select;

public interface DataMapper {

    @Select("select * from test_table where id = 1")
    DataEntity getData();
    
}

上述代码只做测试,一般在实际应用中很少采用这种轮询的方式,更多的是通过注解的方式指定数据源进行返回。

package com.datasource.demo;

import java.util.stream.IntStream;

import com.datasource.demo.mapper.DataMapper;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import lombok.extern.slf4j.Slf4j;

@SpringBootTest
@Slf4j
class DemoApplicationTests {

	@Autowired
	private DataMapper dataMapper;

	@Test
	void testDataSource() throws Exception {
		IntStream.range(0, 10).forEach(i -> log.info(dataMapper.getData().toString()));
	}

}

//输出
2022-05-19 21:57:39.737  INFO 46758 --- [           main] c.datasource.demo.DemoApplicationTests   : DataEntity(id=1, name=slave)
2022-05-19 21:57:39.737  INFO 46758 --- [           main] c.d.demo.config.DynamicDataSource        : >>>>>> 当前数据库: masterDataSource <<<<<<
2022-05-19 21:57:39.740  INFO 46758 --- [           main] c.datasource.demo.DemoApplicationTests   : DataEntity(id=1, name=master)
2022-05-19 21:57:39.740  INFO 46758 --- [           main] c.d.demo.config.DynamicDataSource        : >>>>>> 当前数据库: slaveDataSource <<<<<<
2022-05-19 21:57:39.743  INFO 46758 --- [           main] c.datasource.demo.DemoApplicationTests   : DataEntity(id=1, name=slave)
2022-05-19 21:57:39.743  INFO 46758 --- [           main] c.d.demo.config.DynamicDataSource        : >>>>>> 当前数据库: masterDataSource <<<<<<
2022-05-19 21:57:39.745  INFO 46758 --- [           main] c.datasource.demo.DemoApplicationTests   : DataEntity(id=1, name=master)
2022-05-19 21:57:39.745  INFO 46758 --- [           main] c.d.demo.config.DynamicDataSource        : >>>>>> 当前数据库: slaveDataSource <<<<<<
2022-05-19 21:57:39.747  INFO 46758 --- [           main] c.datasource.demo.DemoApplicationTests   : DataEntity(id=1, name=slave)
2022-05-19 21:57:39.747  INFO 46758 --- [           main] c.d.demo.config.DynamicDataSource        : >>>>>> 当前数据库: masterDataSource <<<<<<
2022-05-19 21:57:39.749  INFO 46758 --- [           main] c.datasource.demo.DemoApplicationTests   : DataEntity(id=1, name=master)
2022-05-19 21:57:39.758  INFO 46758 --- [ionShutdownHook] com.alibaba.druid.pool.DruidDataSource   : {dataSource-1} closing ...
2022-05-19 21:57:39.760  INFO 46758 --- [ionShutdownHook] com.alibaba.druid.pool.DruidDataSource   : {dataSource-1} closed
2022-05-19 21:57:39.760  INFO 46758 --- [ionShutdownHook] com.alibaba.druid.pool.DruidDataSource   : {dataSource-2} closing ...
2022-05-19 21:57:39.761  INFO 46758 --- [ionShutdownHook] com.alibaba.druid.pool.DruidDataSource   : {dataSource-2} closed

通过注解指定数据源

新建一个@DataSouce注解,使用该注解指定数据源

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface DataSource {
   
   DataSourceType value() default DataSourceType.MASTER;

}

数据源枚举类如下

public enum DataSourceType {
    
    MASTER,

    SLAVE
}

注解指定数据源需要对DynamicDataSouce进行改造

@RequiredArgsConstructor
public class DynamicDataSource extends AbstractRoutingDataSource {

    private final Map<String, DataSource> targetDataSources;

    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceContextHolder.getDataSourceType();
    }
    
    @Override
    public void afterPropertiesSet() {
        super.setDefaultTargetDataSource(targetDataSources.get(DataSourceType.MASTER.name()));
        super.setTargetDataSources(new HashMap<>(targetDataSources));
        super.afterPropertiesSet();
    }
    
}

这里使用DynamicDataSourceContextHolder持有数据源,该类使用LocalThread对数据源进行保存,保证线程上下文使用同一数据源

@Slf4j
public class DynamicDataSourceContextHolder {

    /**
     * 使用ThreadLocal维护变量,ThreadLocal为每个使用该变量的线程提供独立的变量副本,
     *  所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
     */
    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();

    /**
     * 设置数据源的变量
     */
    public static void setDataSourceType(String dsType)
    {
        log.info("切换到{}数据源", dsType);
        CONTEXT_HOLDER.set(dsType);
    }

    /**
     * 获得数据源的变量
     */
    public static String getDataSourceType()
    {
        return CONTEXT_HOLDER.get();
    }

    /**
     * 清空数据源变量
     */
    public static void clearDataSourceType()
    {
        CONTEXT_HOLDER.remove();
    }
}

设置切面

最后就是要使用切面对注解进行解析,进而动态设置数据源,切面如下

@Aspect
@Order(1)
@Component
public class DataSourceAspect {
    
    @Pointcut("@annotation(com.datasource.demo.annotation.DataSource)"
            + "|| @within(com.datasource.demo.annotation.DataSource)")
    public void dsPointCut() {
        
    }

    @Around("dsPointCut()")
    public Object around(ProceedingJoinPoint pointcut) throws Throwable {

        DataSource dataSource = getDataSource(pointcut);
        DynamicDataSourceContextHolder.setDataSourceType(dataSource.value().name());

        try {
            return pointcut.proceed();
        } finally {
            DynamicDataSourceContextHolder.clearDataSourceType();
        }
    }

    private DataSource getDataSource(ProceedingJoinPoint pointcut) {
        MethodSignature signature = (MethodSignature) pointcut.getSignature();
        DataSource dataSource = AnnotationUtils.findAnnotation(signature.getMethod(), DataSource.class);
        if (Objects.nonNull(dataSource)) {
            return dataSource;
        }
        return AnnotationUtils.findAnnotation(signature.getDeclaringType(), DataSource.class);
    }
}

测试

设置DataSource注解

public interface DataMapper {

    @Select("select * from test_table where id = 1")
    @DataSource(DataSourceType.SLAVE)
    DataEntity getData();
    
}

运行单元测试

@SpringBootTest
@Slf4j
class DemoApplicationTests {

	@Autowired
	private DataMapper dataMapper;

	@Test
	void testDataSource() throws Exception {
		log.info(dataMapper.getData().toString());
	}

}

//输出
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.6.7)

2022-06-04 13:45:38.500  INFO 82721 --- [           main] c.datasource.demo.DemoApplicationTests   : Starting DemoApplicationTests using Java 1.8.0_302 on Mac-mini.local with PID 82721 (started by wag in /Users/wag/projects/for_blog)
2022-06-04 13:45:38.500  INFO 82721 --- [           main] c.datasource.demo.DemoApplicationTests   : No active profile set, falling back to 1 default profile: "default"
2022-06-04 13:45:39.726  INFO 82721 --- [           main] c.datasource.demo.DemoApplicationTests   : Started DemoApplicationTests in 1.507 seconds (JVM running for 1.977)
2022-06-04 13:45:39.846  INFO 82721 --- [           main] c.d.d.d.DynamicDataSourceContextHolder   : 切换到SLAVE数据源
2022-06-04 13:45:39.894  INFO 82721 --- [           main] com.alibaba.druid.pool.DruidDataSource   : {dataSource-1} inited
2022-06-04 13:45:40.043  INFO 82721 --- [           main] c.datasource.demo.DemoApplicationTests   : DataEntity(id=1, name=slave)
2022-06-04 13:45:40.054  INFO 82721 --- [ionShutdownHook] com.alibaba.druid.pool.DruidDataSource   : {dataSource-1} closing ...
2022-06-04 13:45:40.056  INFO 82721 --- [ionShutdownHook] com.alibaba.druid.pool.DruidDataSource   : {dataSource-1} closed
2022-06-04 13:45:40.056  INFO 82721 --- [ionShutdownHook] com.alibaba.druid.pool.DruidDataSource   : {dataSource-0} closing ...