DynamicDatasource多数据源插件

Dynamic-Datasource是苞米豆(baomidou)团队开源的一款专为SpringBoot设计的多数据源插件,用于解决单体项目中需要对接多个数据库的场景。Dynamic-Datasource虽然也是苞米豆团队开发的,但它并不是必须集成MyBatis-Plus使用,该插件基于Spring的AbstractRoutingDataSource实现,插件通过AOP拦截注解的方式实现数据源的动态切换,支持主从分离、多业务数据库访问等多种场景,原始MyBatis框架也可以使用Dynamic-Datasource,它配置简单、侵入性低,是目前SpringBoot生态中比较主流的多数据源解决方案之一。这篇笔记我们基于SpringBoot 3.x版本介绍Dynamic-Datasource插件的使用。

多数据源的典型应用场景

实际开发中,我们的项目其实大多都是单数据源的,需要多数据源的典型场景有以下几种:

读写分离:一些数据库压力非常大的系统可能采用一主多从的数据库架构,写请求和需要事务性操作的走主库,纯读请求走从库,达到降低单库压力提升系统吞吐量的目的。

多租户架构:SaaS系统中,不同租户的数据存储在各自独立的数据库中,运行时根据租户标识动态切换对应的数据源。

数据迁移:系统升级过程中需要同时对接旧库和新库完成数据的迁移和校验,或是一个类似ETL的工程需要从多个数据源抽取数据再写入另一个数据源。

单服务多业务库:不同业务模块使用独立的数据库,例如用户中心、订单中心、支付中心各有独立的数据库,单体服务中服务层需要跨库调用数据。注意这种情况其实是非常糟糕的设计,常见于设计失误的历史遗留项目。多数据源涉及分布式事务,这会让项目的复杂度飙升,远不如单库简单可靠。实际开发中,我们要么是单服务单业务数据库,要么是彻底用微服务架构拆成不同服务,每个服务连接自己的数据库,服务之间通过RPC接口互相调用,事务则由Seata等分布式事务协调器(TC)控制。单服务多业务数据库是一种处于二者之间的奇怪架构,它既丢失了单库的简单性,也不具备微服务架构的高可用性。

Dynamic-Datasource插件使用

引入Maven依赖

SpringBoot工程pom.xml中引入如下依赖。

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
    <version>4.3.1</version>
</dependency>

工程多数据源配置

工程的application.properties中,我们添加如下数据源配置。

spring.datasource.dynamic.primary=master
spring.datasource.dynamic.datasource.master.url=jdbc:mysql://localhost:3306/netstore?serverTimezone=Asia/Shanghai&connectTimeout=5000&socketTimeout=60000&tcpKeepAlive=true
spring.datasource.dynamic.datasource.master.username=root
spring.datasource.dynamic.datasource.master.password=root
spring.datasource.dynamic.datasource.master.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.dynamic.datasource.master.hikari.minimum-idle=5
spring.datasource.dynamic.datasource.master.hikari.maximum-pool-size=20
spring.datasource.dynamic.datasource.master.hikari.max-lifetime=900000
spring.datasource.dynamic.datasource.master.hikari.idle-timeout=60000
spring.datasource.dynamic.datasource.master.hikari.keepalive-time=30000
spring.datasource.dynamic.datasource.slave.url=jdbc:mysql://localhost:3306/netstore_slave?serverTimezone=Asia/Shanghai&connectTimeout=5000&socketTimeout=60000&tcpKeepAlive=true
spring.datasource.dynamic.datasource.slave.username=root
spring.datasource.dynamic.datasource.slave.password=root
spring.datasource.dynamic.datasource.slave.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.dynamic.datasource.slave.hikari.minimum-idle=5
spring.datasource.dynamic.datasource.slave.hikari.maximum-pool-size=20
spring.datasource.dynamic.datasource.slave.hikari.max-lifetime=900000
spring.datasource.dynamic.datasource.slave.hikari.idle-timeout=60000
spring.datasource.dynamic.datasource.slave.hikari.keepalive-time=30000

配置中实际上添加了两个数据源,master数据源连接netstore数据库,slave数据源连接netstore_slave数据库。这个masterslave名字不是固定的,我们可以随意起名,主数据源的名字通过spring.datasource.dynamic.primary配置项指定,不过它的默认值就是master,因此我们通常习惯将主数据源命名为master

编写测试代码

下面例子代码我们访问的是master数据源。

package com.gacfox.demo.service.impl;

import com.gacfox.demo.mapper.UserMapper;
import com.gacfox.demo.model.User;
import com.gacfox.demo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl implements UserService {
    private final UserMapper userMapper;

    @Autowired
    public UserServiceImpl(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    @Override
    public User getById(Long id) {
        return userMapper.selectById(id);
    }
}

下面例子代码我们访问的是slave数据源。

package com.gacfox.demo.service.impl;

import com.baomidou.dynamic.datasource.annotation.DS;
import com.gacfox.demo.mapper.UserMapper;
import com.gacfox.demo.model.User;
import com.gacfox.demo.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl implements UserService {
    private final UserMapper userMapper;

    @Autowired
    public UserServiceImpl(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    @DS("slave")
    @Override
    public User getById(Long id) {
        return userMapper.selectById(id);
    }
}

切换数据源

@DS注解切换数据源

@DS是Dynamic-Datasource插件动态数据源切换的核心注解,它的使用规则非常灵活,它可以被放在方法、类或MyBatis的Mapper接口上,它的底层工作方式类似一个栈,优先级是就近原则的,方法上的注解优先级高于类上的注解,被嵌套调用的方法会使用最近调用链路上的数据源。除此之外,@DS注解还支持SpEL表达式,这可以实现动态的数据源路由,典型场景是多租户系统中根据请求参数中的租户ID动态切换数据源。

@DS("#tenantId")
public User getUserByTenant(Long id) {
    return userMapper.selectById(id);
}

@DS注解底层是基于Spring的AOP和ThreadLocal的,这引入了两个问题,类内互相调用不生效问题和异步任务、线程池场景下的上下文丢失问题,这些问题都可以通过编程方式手动切换数据源来解决。

编程方式切换数据源

Dynamic-Datasource插件也支持通过代码的方式切换数据源,下面是一个例子。

try {
    User user1 = new User();
    user1.setUserId(1L);
    user1.setUsername("主库");
    userMapper.updateById(user1);

    DynamicDataSourceContextHolder.push("slave");
    User user2 = new User();
    user2.setUserId(1L);
    user2.setUsername("从库");
    userMapper.updateById(user2);
} finally {
    DynamicDataSourceContextHolder.poll();
}

DynamicDataSourceContextHolder.push()用于切换当前上下文中的数据源,DynamicDataSourceContextHolder.poll()用于删除前面push()的上下文,避免污染上下文。

一主多从负载均衡

对于读写分离场景,我们还有可能配置一个主库和多个从库,Dynamic-Datasource插件内置了从库的负载均衡,支持轮询、随机等策略。下面例子配置中,我们的从库使用了类似slave_n的写法,Dynamic-Datasource插件会自动解析这种格式,将它们看作同一组从库维护,此外我们还指定了spring.datasource.dynamic.strategy,取值LoadBalanceDynamicDataSourceStrategy是Dynamic-Datasource插件默认的轮询负载均衡策略。

spring.datasource.dynamic.primary=master
spring.datasource.dynamic.strategy=com.baomidou.dynamic.datasource.strategy.LoadBalanceDynamicDataSourceStrategy
spring.datasource.dynamic.datasource.master.url=jdbc:mysql://localhost:3306/netstore?serverTimezone=Asia/Shanghai&connectTimeout=5000&socketTimeout=60000&tcpKeepAlive=true
spring.datasource.dynamic.datasource.master.username=root
spring.datasource.dynamic.datasource.master.password=root
spring.datasource.dynamic.datasource.master.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.dynamic.datasource.master.hikari.minimum-idle=5
spring.datasource.dynamic.datasource.master.hikari.maximum-pool-size=20
spring.datasource.dynamic.datasource.master.hikari.max-lifetime=900000
spring.datasource.dynamic.datasource.master.hikari.idle-timeout=60000
spring.datasource.dynamic.datasource.master.hikari.keepalive-time=30000
spring.datasource.dynamic.datasource.slave_1.url=jdbc:mysql://localhost:3306/netstore_slave1?serverTimezone=Asia/Shanghai&connectTimeout=5000&socketTimeout=60000&tcpKeepAlive=true
spring.datasource.dynamic.datasource.slave_1.username=root
spring.datasource.dynamic.datasource.slave_1.password=root
spring.datasource.dynamic.datasource.slave_1.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.dynamic.datasource.slave_1.hikari.minimum-idle=5
spring.datasource.dynamic.datasource.slave_1.hikari.maximum-pool-size=20
spring.datasource.dynamic.datasource.slave_1.hikari.max-lifetime=900000
spring.datasource.dynamic.datasource.slave_1.hikari.idle-timeout=60000
spring.datasource.dynamic.datasource.slave_1.hikari.keepalive-time=30000
spring.datasource.dynamic.datasource.slave_2.url=jdbc:mysql://localhost:3306/netstore_slave2?serverTimezone=Asia/Shanghai&connectTimeout=5000&socketTimeout=60000&tcpKeepAlive=true
spring.datasource.dynamic.datasource.slave_2.username=root
spring.datasource.dynamic.datasource.slave_2.password=root
spring.datasource.dynamic.datasource.slave_2.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.dynamic.datasource.slave_2.hikari.minimum-idle=5
spring.datasource.dynamic.datasource.slave_2.hikari.maximum-pool-size=20
spring.datasource.dynamic.datasource.slave_2.hikari.max-lifetime=900000
spring.datasource.dynamic.datasource.slave_2.hikari.idle-timeout=60000
spring.datasource.dynamic.datasource.slave_2.hikari.keepalive-time=30000

代码中,注解仍像下面标注即可,框架会自动帮我们以负载均衡的方式访问数据库。

@DS("slave")
@Override
public User getById(Long id) {
    return userMapper.selectById(id);
}

事务管理

SpringBoot中,Spring内置的事务管理器只对主数据源生效,这是因为框架内部注册事务管理器时固定只取一个数据源Bean,它默认就是主数据源的,如果你操作的是非主数据源数据库但仍试图使用默认注册的事务管理器,事务是不会生效的。Dynamic-Datasource插件其实提供了专属的事务注解@DSTransactional,它会管理方法中所有涉及的数据源的事务。

值得注意的是@DSTransactional并不实现强一致性的分布式事务,这里千万不要混淆!@DSTransactional所做的只是多数据源本地事务注解,框架内部会对多个数据源分别开启事务,如果出错则全部回滚,这其实是1PC(一阶段提交),数据可能存在不一致。举例来说,假设我们有事务A和B位于两个数据库,@DSTransactional的做法如下:

  1. 开启事务A并执行操作
  2. 开启事务B并执行操作
  3. 提交事务A
  4. 提交事务B

如果一切正常,即使某一步出错,事务A和B都会回滚,这没问题,问题在于如果提交了事务A后,正要提交事务B时,服务突然宕机了,那么事务A提交了事务B没提交,这就出现了数据不一致的情况。在真正的分布式事务场景下,如果要实现更好的数据一致性其实有多种方案,例如基于2PC(两阶段提交)的XA强一致性分布式事务等,但@DSTransactional不具备这些功能。

下面例子代码我们基于@DSTransactional实现了1PC事务。

@DSTransactional
public void updateBothDb() {
    try {
        User user1 = new User();
        user1.setUserId(1L);
        user1.setUsername("主库");
        userMapper.updateById(user1);

        DynamicDataSourceContextHolder.push("slave");
        User user2 = new User();
        user2.setUserId(1L);
        user2.setUsername("从库");
        userMapper.updateById(user2);
    } finally {
        DynamicDataSourceContextHolder.poll();
    }
}
作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。