摘要: 原创出处 http://www.iocoder.cn/Spring-Boot/dynamic-datasource/ 「芋道源码」欢迎转载,保留摘要,谢谢!

  • 1. 概述
  • 2. 实现方式
    • 2.1 方案一
    • 2.2 方案二
    • 2.3 方案三
  • 3. baomidou 多数据源
  • 4. baomidou 读写分离
  • 5. MyBatis 多数据源
  • 6. Spring Data JPA 多数据源
  • 7. JdbcTemplate 多数据源
  • 8. Sharding-JDBC 多数据源
  • 9. Sharding-JDBC 读写分离
  • 666. 彩蛋

1. 概述

在项目中,我们可能会碰到需要多数据源的场景。例如说:

  • 读写分离:数据库主节点压力比较大,需要增加从节点提供读操作,以减少压力。
  • 多数据源:一个复杂的单体项目,因为没有拆分成不同的服务,需要连接多个业务的数据源。

本质上,读写分离,仅仅是多数据源的一个场景,从节点是只提供读操作的数据源。所以只要实现了多数据源的功能,也就能够提供读写分离。

2. 实现方式

目前,实现多数据源有三种方案。我们逐个小节来看。

2.1 方案一

基于 Spring AbstractRoutingDataSource 做拓展

简单来说,通过继承 AbstractRoutingDataSource 抽象类,实现一个管理项目中多个 DataSource 的动态 DynamicRoutingDataSource 实现类。这样,Spring 在获取数据源时,可以通过 DynamicRoutingDataSource 返回实际的 DataSource 。

然后,我们可以自定义一个 @DS 注解,可以添加在 Service 方法、Dao 方法上,表示其实际对应的 DataSource 。

如此,整个过程就变成,执行数据操作时,通过“配置”的 @DS 注解,使用 DynamicRoutingDataSource 获得对应的实际的DataSource 。之后,在通过该 DataSource 获得 Connection 连接,最后发起数据库操作。

可能这么说,没有实现过多数据源的胖友会比较懵逼,比较大概率。所以推荐胖胖看看艿艿的基友写的 《剖析 Spring 多数据源》 文章。

不过呢,这种方式在结合 Spring 事务的时候,会存在无法切换数据源的问题。具体我们在 「3. baomidou 多数据源」 中,结合示例一起来看。

艿艿目前找了一圈开源的项目,发现比较好的是 baomidou 提供的 dynamic-datasource-spring-boot-starter 。所以我们在 「3. baomidou 多数据源」 和 「4. baomidou 读写分离」 中,会使用到它。

2.2 方案二

不同操作类,固定数据源

关于这个方案,解释起来略有点晦涩。以 MyBatis 举例子,假设有orders 和 users 两个数据源。 那么我们可以创建两个 SqlSessionTemplate ordersSqlSessionTemplate 和 usersSqlSessionTemplate ,分别使用这两个数据源。

然后,配置不同的 Mapper 使用不同的 SqlSessionTemplate 。

如此,整个过程就变成,执行数据操作时,通过 Mapper 可以对应到其  SqlSessionTemplate ,使用 SqlSessionTemplate 获得对应的实际的 DataSource 。之后,在通过该 DataSource 获得 Connection 连接,最后发起数据库操作。

咳咳咳,是不是又处于懵逼状态了?!没事,咱在 「5. MyBatis 多数据源」、「6. Spring Data JPA 多数据源」、「7. JdbcTemplate 多数据源」 中,结合案例一起看。「Talk is cheap. Show me the code」

不过呢,这种方式在结合 Spring 事务的时候,也会存在无法切换数据源的问题。淡定淡定。多数据源的情况下,这个基本是逃不掉的问题。

2.3 方案三

分库分表中间件

对于分库分表的中间件,会解析我们编写的 SQL ,路由操作到对应的数据源。那么,它们天然就支持多数据源。如此,我们仅需配置好每个表对应的数据源,中间件就可以透明的实现多数据源或者读写分离。

目前,Java 最好用的分库分表中间件,就是 Apache ShardingSphere ,没有之一。

那么,这种方式在结合 Spring 事务的时候,会不会存在无法切换数据源的问题呢?答案是不会。在上述的方案一和方案二中,在 Spring 事务中,会获得对应的 DataSource ,再获得 Connection 进行数据库操作。而获得的 Connection 以及其上的事务,会通过 ThreadLocal 的方式和当前线程进行绑定。这样,就导致我们无法切换数据源。

难道分库分表中间件不也是需要 Connection 进行这些事情么?答案是的,但是不同的是分库分表中间件返回的 Connection 返回的实际是动态的 DynamicRoutingConnection ,它管理了整个请求(逻辑)过程中,使用的所有的 Connection ,而最终执行 SQL 的时候,DynamicRoutingConnection 会解析 SQL ,获得表对应的真正的 Connection 执行 SQL 操作。

难道方案一和方案二不可以这么做吗?答案是,当然可以。前提是,他们要实现解析 SQL 的能力。

那么,分库分表中间件就是多数据源的完美方案落?从一定程度上来说,是的。但是,它需要解决多个 Connection 可能产生的多个事务的一致性问题,也就是我们常说的,分布式事务。关于这块,艿艿最近有段时间没跟进 Sharding-JDBC 的版本,所以无法给出肯定的答案。不过我相信,Sharding-JDBC 最终会解决分布式事务的难题,提供透明的多数据源的功能。

在 「8. Sharding-JDBC 多数据源」、「9. Sharding-JDBC 读写分离」 中,我们会演示这种方案。

3. baomidou 多数据源

示例代码对应仓库:lab-17-dynamic-datasource-baomidou-01 。

本小节,我们使用实现开源项目 dynamic-datasource-spring-boot-starter ,来实现多数据源的功能。我们会使用 test_orders 和 test_users 两个数据源作为两个数据源,然后实现在其上的 SQL 操作。并且,会结合在 Spring 事务的不同场景下,会发生的结果以及原因。

另外,关于 dynamic-datasource-spring-boot-starter 的介绍,胖友自己看 官方文档 。

它和 MyBatis-Plus 都是开发者 baomidou 提供的。

3.1 引入依赖

在 pom.xml 文件中,引入相关依赖。

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.3.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>lab-17-dynamic-datasource-baomidou-01</artifactId>

    <dependencies>
        <!-- 实现对数据库连接池的自动化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency> <!-- 本示例,我们使用 MySQL -->
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.48</version>
        </dependency>

        <!-- 实现对 MyBatis 的自动化配置 -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.1</version>
        </dependency>

        <!-- 实现对 dynamic-datasource 的自动化配置 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
            <version>2.5.7</version>
        </dependency>
        <!-- 不造为啥 dynamic-datasource-spring-boot-starter 会依赖这个 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-actuator</artifactId>
        </dependency>

        <!-- 方便等会写单元测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

</project>

具体每个依赖的作用,胖友自己认真看下艿艿添加的所有注释噢。

3.2 Application

创建 Application.java 类,代码如下:

// Application.java

@SpringBootApplication
@MapperScan(basePackages = "cn.iocoder.springboot.lab17.dynamicdatasource.mapper")
@EnableAspectJAutoProxy(exposeProxy = true// http://www.voidcn.com/article/p-zddcuyii-bpt.html
public class Application {
}
  • 添加 @MapperScan 注解,cn.iocoder.springboot.lab17.dynamicdatasource.mapper 包路径下,就是我们 Mapper 接口所在的包路径。
  • 添加 @EnableAspectJAutoProxy 注解,重点是配置 exposeProxy = true ,因为我们希望 Spring AOP 能将当前代理对象设置到 AopContext 中。具体用途,我们会在下文看到。想要提前看的胖友,可以看看 《Spring AOP 通过获取代理对象实现事务切换》 文章。

3.3 应用配置文件

在 resources 目录下,创建 application.yaml 配置文件。配置如下:

spring:
  datasource:
    # dynamic-datasource-spring-boot-starter 动态数据源的配置内容
    dynamic:
      primary: users # 设置默认的数据源或者数据源组,默认值即为 master
      datasource:
        # 订单 orders 数据源配置
        orders:
          url: jdbc:mysql://127.0.0.1:3306/test_orders?useSSL=false&useUnicode=true&characterEncoding=UTF-8
          driver-class-name: com.mysql.jdbc.Driver
          username: root
          password:
        # 用户 users 数据源配置
        users:
          url: jdbc:mysql://127.0.0.1:3306/test_users?useSSL=false&useUnicode=true&characterEncoding=UTF-8
          driver-class-name: com.mysql.jdbc.Driver
          username: root
          password:

# mybatis 配置内容
mybatis:
  config-location: classpath:mybatis-config.xml # 配置 MyBatis 配置文件路径
  mapper-locations: classpath:mapper/*.xml # 配置 Mapper XML 地址
  type-aliases-package: cn.iocoder.springboot.lab17.dynamicdatasource.dataobject # 配置数据库实体包路径
  • spring.datasource.dynamic 配置项,设置 dynamic-datasource-spring-boot-starter 动态数据源的配置内容。
    • primary 配置项,设置默认的数据源或者数据源组,默认值即为 master 。
    • datasource 配置项,配置每个动态数据源。这里,我们配置了 ordersusers 两个动态数据源。
  • mybatis 配置项,设置 mybatis-spring-boot-starterMyBatis 的配置内容。

3.4 MyBatis 配置文件

在 resources 目录下,创建 mybatis-config.xml 配置文件。配置如下:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>

    <settings>
        <!-- 使用驼峰命名法转换字段。 -->
        <setting name="mapUnderscoreToCamelCase" value="true"/>
    </settings>

    <typeAliases>
        <typeAlias alias="Integer" type="java.lang.Integer"/>
        <typeAlias alias="Long" type="java.lang.Long"/>
        <typeAlias alias="HashMap" type="java.util.HashMap"/>
        <typeAlias alias="LinkedHashMap" type="java.util.LinkedHashMap"/>
        <typeAlias alias="ArrayList" type="java.util.ArrayList"/>
        <typeAlias alias="LinkedList" type="java.util.LinkedList"/>
    </typeAliases>

</configuration>

因为在数据库中的表的字段,我们是使用下划线风格,而数据库实体的字段使用驼峰风格,所以通过 mapUnderscoreToCamelCase = true 来自动转换。

3.5 实体类

在 cn.iocoder.springboot.lab17.dynamicdatasource.dataobject包路径下,创建 UserDO.java 和 OrderDO.java 类。代码如下:

// OrderDO.java
/**
 * 订单 DO
 */

public class OrderDO {

    /**
     * 订单编号
     */

    private Integer id;
    /**
     * 用户编号
     */

    private Integer userId;

    // 省略 setting/getting 方法

}

// UserDO.java
/**
 * 用户 DO
 */

public class UserDO {

    /**
     * 用户编号
     */

    private Integer id;
    /**
     * 账号
     */

    private String username;

    // 省略 setting/getting 方法
}

对应的创建表的 SQL 如下:

-- 在 `test_orders` 库中。
CREATE TABLE `orders` (
  `id` int(11DEFAULT NULL COMMENT '订单编号',
  `user_id` int(16DEFAULT NULL COMMENT '用户编号'
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='订单表';

-- 在 `test_users` 库中。
CREATE TABLE `users` (
  `id` int(11NOT NULL AUTO_INCREMENT COMMENT '用户编号',
  `username` varchar(64COLLATE utf8mb4_bin DEFAULT NULL COMMENT '账号',
  `password` varchar(32COLLATE utf8mb4_bin DEFAULT NULL COMMENT '密码',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_username` (`username`)
ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

3.6 Mapper

在 cn.iocoder.springboot.lab17.dynamicdatasource.mapper 包路径下,创建 UserDO.java 和 UserMapper.java 接口。代码如下:

// OrderMapper.java
@Repository
@DS(DBConstants.DATASOURCE_ORDERS)
public interface OrderMapper {

    OrderDO selectById(@Param("id") Integer id);

}

// UserMapper.java
@Repository
@DS(DBConstants.DATASOURCE_USERS)
public interface UserMapper {

    UserDO selectById(@Param("id") Integer id);

}
  • DBConstants.java 类,枚举了 DATASOURCE_ORDERS 和 DATASOURCE_USERS 两个数据源。
  • @DS 注解,是 dynamic-datasource-spring-boot-starter 提供,可添加在 Service 或 Mapper 的类/接口上,或者方法上。在其 value 属性种,填写数据源的名字。
    • OrderMapper 接口上,我们添加了 @DS(DBConstants.DATASOURCE_ORDERS) 注解,访问 orders 数据源。
    • UserMapper 接口上,我们添加了 @DS(DBConstants.DATASOURCE_USERS) 注解,访问 users 数据源。
  • 为了让整个测试用例精简,我们在 OrderMapper 和 UserMapper 中,只添加了根据编号查询单条记录的方法。

在 resources/mapper 路径下,创建 OrderMapper.xml 和 UserMapper.xml 配置文件。代码如下:

<!-- OrderMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.iocoder.springboot.lab17.dynamicdatasource.mapper.OrderMapper">

    <sql id="FIELDS">
        id, user_id
    </sql>

    <select id="selectById" parameterType="Integer" resultType="OrderDO">
        SELECT
            <include refid="FIELDS" />
        FROM orders
        WHERE id = #{id}
    </select>

</mapper>

<!-- UserMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.iocoder.springboot.lab17.dynamicdatasource.mapper.UserMapper">

    <sql id="FIELDS">
        id, username
    </sql>

    <select id="selectById" parameterType="Integer" resultType="UserDO">
        SELECT
            <include refid="FIELDS" />
        FROM users
        WHERE id = #{id}
    </select>

</mapper>

3.7 简单测试

创建 UserMapperTest 和 OrderMapperTest 测试类,我们来测试一下简单的 UserMapper 和 OrderMapper 的每个操作。代码如下:

// OrderMapperTest.java
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
public class OrderMapperTest {

    @Autowired
    private OrderMapper orderMapper;

    @Test
    public void testSelectById() {
        OrderDO order = orderMapper.selectById(1);
        System.out.println(order);
    }

}

// UserMapperTest.java
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
public class UserMapperTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    public void testSelectById() {
        UserDO user = userMapper.selectById(1);
        System.out.println(user);
    }

}

胖友自己跑下测试用例。如果跑通,说明配置就算成功了。

3.8 详细测试

在本小节,我们会编写 5 个测试用例,尝试阐述 dynamic-datasource-spring-boot-starter 在和 Spring 事务结合碰到的情况,以便胖友更好的使用。当然,这个不仅仅是 dynamic-datasource-spring-boot-starter 独有的,而是方案一【基于 Spring AbstractRoutingDataSource 做拓展】都存在的情况。

在 cn.iocoder.springboot.lab17.dynamicdatasource.service 包路径下,创建 OrderService.java 类。代码如下:

// OrderService.java

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private UserMapper userMapper;

    private OrderService self() {
        return (OrderService) AopContext.currentProxy();
    }

    public void method01() {
        // ... 省略代码
    }

    @Transactional
    public void method02() {
        // ... 省略代码
    }

    public void method03() {
        // ... 省略代码
    }

    public void method04() {
        // ... 省略代码
    }

    @Transactional
    @DS(DBConstants.DATASOURCE_ORDERS)
    public void method05() {
        // ... 省略代码
    }

}
  • #self() 方法,通过 AopContext 获得自己这个代理对象。举个例子,在 #method01() 方法中,如果直接使用this.method02() 方法进行调用,因为 this 代表的是 OrderService Bean 自身,而不是其 AOP 代理对象。这样会导致,无法触发 AOP 的逻辑,在此处,就是 Spring 事务的逻辑。因此,我们通过 AopContext 获得自己这个代理对象。
  • 每一个 #methodXX() 方法,都代表一个测试用例,胖友可以使用 OrderServiceTest 进行测试。

下面,我们来一个一个看。

场景一:#method01()

// OrderService.java

public void method01() {
    // 查询订单
    OrderDO order = orderMapper.selectById(1);
    System.out.println(order);
    // 查询用户
    UserDO user = userMapper.selectById(1);
    System.out.println(user);
}
  • 方法未使用 @Transactional 注解,不会开启事务。
  • 对于 OrderMapper 和 UserMapper 的查询操作,分别使用其接口上的 @DS 注解,找到对应的数据源,执行操作。
  • 这样一看,在未开启事务的情况下,我们已经能够自由的使用多数据源落。

场景二:#method02()

// OrderService.java

@Transactional
public void method02() {
    // 查询订单
    OrderDO order = orderMapper.selectById(1);
    System.out.println(order);
    // 查询用户
    UserDO user = userMapper.selectById(1);
    System.out.println(user);
}
  • 和 #method01() 方法,差异在于,方法上增加了 @Transactional 注解,声明要使用 Spring 事务。

  • 执行方法,抛出如下异常:

    Caused by: com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: Table 'test_users.orders' doesn't exist
    • 在执行 OrderMapper 查询订单操作时,抛出在 test_users库中,不存在 orders 表。
  • 这是为什么呢?咱不是在 OrderMapper 上,声明使用 orders数据源了么?结果为什么会使用 users 数据库,路由到 test_users 库上呢。

    • 这里,就和 Spring 事务的实现机制有关系。因为方法添加了@Transactional 注解,Spring 事务就会生效。此时,Spring TransactionInterceptor 会通过 AOP 拦截该方法,创建事务。而创建事务,势必就会获得数据源。那么,TransactionInterceptor 会使用 Spring DataSourceTransactionManager 创建事务,并将事务信息通过 ThreadLocal 绑定在当前线程。
    • 而事务信息,就包括事务对应的 Connection 连接。那也就意味着,还没走到 OrderMapper 的查询操作,Connection 就已经被创建出来了。并且,因为事务信息会和当前线程绑定在一起,在 OrderMapper 在查询操作需要获得 Connection 时,就直接拿到当前线程绑定的 Connection ,而不是 OrderMapper 添加 @DS 注解所对应的 DataSource 所对应的 Connection 。
    • OK ,那么我们现在可以把问题聚焦到 DataSourceTransactionManager 是怎么获取 DataSource 从而获得 Connection 的了。对于每个 DataSourceTransactionManager 数据库事务管理器,创建时都会传入其需要管理的 DataSource 数据源。在使用 dynamic-datasource-spring-boot-starter 时,它创建了一个 DynamicRoutingDataSource ,传入到 DataSourceTransactionManager 中。
    • 而 DynamicRoutingDataSource 负责管理我们配置的多个数据源。例如说,本示例中就管理了 ordersusers 两个数据源,并且默认使用 users 数据源。那么在当前场景下,DynamicRoutingDataSource 需要基于 @DS 获得数据源名,从而获得对应的 DataSource ,结果因为我们在 Service 方法上,并没有添加 @DS 注解,所以它只好返回默认数据源,也就是 users 。故此,就发生了 Table 'test_users.orders' doesn't exist 的异常。
    • 咳咳咳,这里涉及 Spring 事务的实现机制,如果胖友不是很了解源码会比较懵逼,推荐可以尝试将 TransactionInterceptor 作为入口,进行调试。当然,也欢迎胖友给艿艿留言。

场景三:#method03()

// OrderService.java

public void method03() {
    // 查询订单
    self().method031();
    // 查询用户
    self().method032();
}

@Transactional // 报错,因为此时获取的是 primary 对应的 DataSource ,即 users 。
public void method031() {
    OrderDO order = orderMapper.selectById(1);
    System.out.println(order);
}

@Transactional
public void method032() {
    UserDO user = userMapper.selectById(1);
    System.out.println(user);
}
  • 执行方法,抛出如下异常:

    Table 'test_users.orders' doesn't exist
  • 按照艿艿在场景二的解释,胖友可以思考下原因'


其实,场景三和场景二是等价的

如果此时,我们将 #self() 代码替换成 this 之后,诶,结果就正常执行。这又是为什么呢?胖友在思考一波。


其实,这样调整后,因为 this 不是代理对象,所以 #method031() 和 #method032() 方法上的 @Transactional 直接没有作用,Spring 事务根本没有生效。所以,最终结果和场景一是等价的。



场景四:#method04()

// OrderService.java

public void method04() {
    // 查询订单
    self().method041();
    // 查询用户
    self().method042();
}

@Transactional
@DS(DBConstants.DATASOURCE_ORDERS)
public void method041() {
    OrderDO order = orderMapper.selectById(1);
    System.out.println(order);
}

@Transactional
@DS(DBConstants.DATASOURCE_USERS)
public void method042() {
    UserDO user = userMapper.selectById(1);
    System.out.println(user);
}