SpringBoot 之多数据源动态切换数据源

感谢原创
SpringBoot 之多数据源动态切换数据源

【SpringBoot2.0 系列 01】初识 SpringBoot
【SpringBoot2.0 系列 02】SpringBoot 之使用 Thymeleaf 视图模板
【SpringBoot2.0 系列 03】SpringBoot 之使用 freemark 视图模板
【SpringBoot2.0 系列 04】SpringBoot 之使用 JPA 完成简单的 rest api
【SpringBoot2.0 系列 05】SpringBoot 之整合 Mybatis
【SpringBoot2.0 系列 06】SpringBoot 之多数据源动态切换数据源

前言

在前面两节我们已经完成 springboot 操作 mysql 数据库,但是在实际业务场景中,数据量迅速增长,一个库一个表已经满足不了我们的需求的时候,我们就会考虑分库分表的操作,那么接下来我们就去学习一下,在 springboot 中如何实现多数据源,动态数据源切换,读写分离等操作。

实现

1、建库建表

首先,我们在本地新建三个数据库名分别为master,slave1,slave2,我们的目前就是写入操作都是在master,查询是 slave1,slave2
因此我们在上一篇也就是【SpringBoot2.0 系列 05】SpringBoot 之整合 Mybatis 基础上进行改动,
我们在master slave1 slave2中都创建user表 其中初始化slave1库的user表数据为

[图片上传失败...(image-22ffa9-1632203505883)]

初始化
slave2库的user

[图片上传失败...(image-1f3306-1632203505883)]

具体的数据库脚本如下

create table master.user
(
    id bigint auto_increment comment '主键'
        primary key,
    age int null comment '年龄',
    password varchar(32) null comment '密码',
    sex int null comment '性别',
    username varchar(32) null comment '用户名'
)
engine=MyISAM collate=utf8mb4_bin
;

create table slave1.user
(
    id bigint auto_increment comment '主键'
        primary key,
    age int null comment '年龄',
    password varchar(32) null comment '密码',
    sex int null comment '性别',
    username varchar(32) null comment '用户名'
)
engine=MyISAM collate=utf8mb4_bin
;

INSERT INTO slave1.user (id, age, password, sex, username) VALUES (2, 22, 'admin', 1, 'admin');

create table slave2.user
(
    id bigint auto_increment comment '主键'
        primary key,
    age int null comment '年龄',
    password varchar(32) null comment '密码',
    sex int null comment '性别',
    username varchar(32) null comment '用户名'
)
engine=MyISAM collate=utf8mb4_bin
;
INSERT INTO slave2.user (id, age, password, sex, username) VALUES (3, 19, 'uuu', 2, 'user');
INSERT INTO slave2.user (id, age, password, sex, username) VALUES (4, 18, 'bbbb', 1, 'zzzz');

2、配置多数据源

经过上面初始化 我们的master.user是一张空表,我们等下的插入与更新操作就在这上面,那么我们的查询操作就是在slave1.user跟slave2.user上面了。
上面我们的数据库初始化工作完成了,接下来就是实现动态数据源的过程
首先我们需要在我们的application.yml配置我们的三个数据源

server:
  port: 8989
spring:
  datasource:
    master:
      password: root
      url: jdbc:mysql://127.0.0.1:3306/master?useUnicode=true&characterEncoding=UTF-8
      driver-class-name: com.mysql.jdbc.Driver
      username: root
      type: com.zaxxer.hikari.HikariDataSource
    cluster:
    - key: slave1
      password: root
      url: jdbc:mysql://127.0.0.1:3306/slave1?useUnicode=true&characterEncoding=UTF-8
      idle-timeout: 20000
      driver-class-name: com.mysql.jdbc.Driver
      username: root
      type: com.zaxxer.hikari.HikariDataSource
    - key: slave2
      password: root
      url: jdbc:mysql://127.0.0.1:3306/slave2?useUnicode=true&characterEncoding=UTF-8
      driver-class-name: com.mysql.jdbc.Driver
      username: root
mybatis:
  mapper-locations: classpath:/mybatis/mapper/*.xml
  config-location:  classpath:/mybatis/config/mybatis-config.xml

在上面我们配置了三个数据,其中第一个作为默认数据源也就是我们的master数据源。主要是写操作,那么读操作交给我们的slave1跟slave2
其中 master 数据源一定是要配置 作为我们的默认数据源,其次 cluster 集群中,其他的数据不配置也不会影响程序员运行,如果你想添加新的一个数据源 就在 cluster 下新增一个数据源即可,其中 key 为必须项,用于数据源的唯一标识,以及接下来切换数据源的标识。

3、注册数据源

在上面我们已经配置了三个数据源,但是这是我们自定义的配置,springboot 是无法给我们自动配置,所以需要我们自己注册数据源.
那么就要实现 EnvironmentAware用于读取上下文环境变量用于构建数据源,同时也需要实现 ImportBeanDefinitionRegistrar接口注册我们构建的数据源。com.yukong.chapter5.register.DynamicDataSourceRegister具体代码如下

/**
 * 动态数据源注册
 * 实现 ImportBeanDefinitionRegistrar 实现数据源注册
 * 实现 EnvironmentAware 用于读取application.yml配置
 */
public class DynamicDataSourceRegister implements ImportBeanDefinitionRegistrar, EnvironmentAware {

    private static final Logger logger = LoggerFactory.getLogger(DynamicDataSourceRegister.class);

    /**
     * 配置上下文(也可以理解为配置文件的获取工具)
     */
    private Environment evn;

    /**
     * 别名
     */
    private final static ConfigurationPropertyNameAliases aliases = new ConfigurationPropertyNameAliases();

    /**
     * 由于部分数据源配置不同,所以在此处添加别名,避免切换数据源出现某些参数无法注入的情况
     */
    static {
        aliases.addAliases("url", new String[]{"jdbc-url"});
        aliases.addAliases("username", new String[]{"user"});
    }

    /**
     * 存储我们注册的数据源
     */
    private Map<String, DataSource> customDataSources = new HashMap<String, DataSource>();

    /**
     * 参数绑定工具 springboot2.0新推出
     */
    private Binder binder;

    /**
     * ImportBeanDefinitionRegistrar接口的实现方法,通过该方法可以按照自己的方式注册bean
     *
     * @param annotationMetadata
     * @param beanDefinitionRegistry
     */
    @Override
    public void registerBeanDefinitions(AnnotationMetadata annotationMetadata, BeanDefinitionRegistry beanDefinitionRegistry) {
        // 获取所有数据源配置
        Map config, defauleDataSourceProperties;
        defauleDataSourceProperties = binder.bind("spring.datasource.master", Map.class).get();
        // 获取数据源类型
        String typeStr = evn.getProperty("spring.datasource.master.type");
        // 获取数据源类型
        Class<? extends DataSource> clazz = getDataSourceType(typeStr);
        // 绑定默认数据源参数 也就是主数据源
        DataSource consumerDatasource, defaultDatasource = bind(clazz, defauleDataSourceProperties);
        DynamicDataSourceContextHolder.dataSourceIds.add("master");
        logger.info("注册默认数据源成功");
        // 获取其他数据源配置
        List<Map> configs = binder.bind("spring.datasource.cluster", Bindable.listOf(Map.class)).get();
        // 遍历从数据源
        for (int i = 0; i < configs.size(); i++) {
            config = configs.get(i);
            clazz = getDataSourceType((String) config.get("type"));
            defauleDataSourceProperties = config;
            // 绑定参数
            consumerDatasource = bind(clazz, defauleDataSourceProperties);
            // 获取数据源的key,以便通过该key可以定位到数据源
            String key = config.get("key").toString();
            customDataSources.put(key, consumerDatasource);
            // 数据源上下文,用于管理数据源与记录已经注册的数据源key
            DynamicDataSourceContextHolder.dataSourceIds.add(key);
            logger.info("注册数据源{}成功", key);
        }
        // bean定义类
        GenericBeanDefinition define = new GenericBeanDefinition();
        // 设置bean的类型,此处DynamicRoutingDataSource是继承AbstractRoutingDataSource的实现类
        define.setBeanClass(DynamicRoutingDataSource.class);
        // 需要注入的参数
        MutablePropertyValues mpv = define.getPropertyValues();
        // 添加默认数据源,避免key不存在的情况没有数据源可用
        mpv.add("defaultTargetDataSource", defaultDatasource);
        // 添加其他数据源
        mpv.add("targetDataSources", customDataSources);
        // 将该bean注册为datasource,不使用springboot自动生成的datasource
        beanDefinitionRegistry.registerBeanDefinition("datasource", define);
        logger.info("注册数据源成功,一共注册{}个数据源", customDataSources.keySet().size() + 1);
    }

    /**
     * 通过字符串获取数据源class对象
     *
     * @param typeStr
     * @return
     */
    private Class<? extends DataSource> getDataSourceType(String typeStr) {
        Class<? extends DataSource> type;
        try {
            if (StringUtils.hasLength(typeStr)) {
                // 字符串不为空则通过反射获取class对象
                type = (Class<? extends DataSource>) Class.forName(typeStr);
            } else {
                // 默认为hikariCP数据源,与springboot默认数据源保持一致
                type = HikariDataSource.class;
            }
            return type;
        } catch (Exception e) {
            throw new IllegalArgumentException("can not resolve class with type: " + typeStr); //无法通过反射获取class对象的情况则抛出异常,该情况一般是写错了,所以此次抛出一个runtimeexception
        }
    }

    /**
     * 绑定参数,以下三个方法都是参考DataSourceBuilder的bind方法实现的,目的是尽量保证我们自己添加的数据源构造过程与springboot保持一致
     *
     * @param result
     * @param properties
     */
    private void bind(DataSource result, Map properties) {
        ConfigurationPropertySource source = new MapConfigurationPropertySource(properties);
        Binder binder = new Binder(new ConfigurationPropertySource[]{source.withAliases(aliases)});
        // 将参数绑定到对象
        binder.bind(ConfigurationPropertyName.EMPTY, Bindable.ofInstance(result));
    }

    private <T extends DataSource> T bind(Class<T> clazz, Map properties) {
        ConfigurationPropertySource source = new MapConfigurationPropertySource(properties);
        Binder binder = new Binder(new ConfigurationPropertySource[]{source.withAliases(aliases)});
        // 通过类型绑定参数并获得实例对象
        return binder.bind(ConfigurationPropertyName.EMPTY, Bindable.of(clazz)).get();
    }

    /**
     * @param clazz
     * @param sourcePath 参数路径,对应配置文件中的值,如: spring.datasource
     * @param <T>
     * @return
     */
    private <T extends DataSource> T bind(Class<T> clazz, String sourcePath) {
        Map properties = binder.bind(sourcePath, Map.class).get();
        return bind(clazz, properties);
    }

    /**
     * EnvironmentAware接口的实现方法,通过aware的方式注入,此处是environment对象
     *
     * @param environment
     */
    @Override
    public void setEnvironment(Environment environment) {
        logger.info("开始注册数据源");
        this.evn = environment;
        // 绑定配置器
        binder = Binder.get(evn);
    }
}

上面代码需要注意的是在springboot2.x系列中用于绑定的工具类如 RelaxedPropertyResolver 已经无法现在使用Binder代替。上面代码主要是读取 application 中数据源的配置,先读取spring.datasource.master 构建默认数据源, 然后在构建cluster中的数据源。
在这里注册完数据源之后,我们需要通过 @import 注解把我们的数据源注册器导入到 spring 中 在启动类Chapter5Application.java加上如下注解@Import(DynamicDataSourceRegister.class)
其中我们用到了一个DynamicDataSourceContextHolder 中的静态变量来保存我们已经注册成功的数据源的key, 至此我们的数据源注册就已经完成了。

4、配置数据源上下文

我们需要新建一个数据源上下文,用户记录当前线程使用的数据源的 key 是什么,以及记录所有注册成功的数据源的 key 的集合。对于线程级别的私有变量,我们首先ThreadLocal来实现。
com.yukong.chapter5.config.DynamicDataSourceContextHolder代码取下

/**
 * @Auther: yukong
 * @Date: 2018/8/15 10:49
 * @Description: 数据源上下文
 */
public class DynamicDataSourceContextHolder {

    private static Logger logger = LoggerFactory.getLogger(DynamicDataSourceContextHolder.class);

    /**
     * 存储已经注册的数据源的key
     */
    public static List<String> dataSourceIds = new ArrayList<>();

    /**
     * 线程级别的私有变量
     */
    private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();

    public static String getDataSourceRouterKey () {
        return HOLDER.get();
    }

    public static void setDataSourceRouterKey (String dataSourceRouterKey) {
        logger.info("切换至{}数据源", dataSourceRouterKey);
        HOLDER.set(dataSourceRouterKey);
    }

    /**
     * 设置数据源之前一定要先移除
     */
    public static void removeDataSourceRouterKey () {
        HOLDER.remove();
    }

    /**
     * 判断指定DataSrouce当前是否存在
     *
     * @param dataSourceId
     * @return
     */
    public static boolean containsDataSource(String dataSourceId){
        return dataSourceIds.contains(dataSourceId);
    }

}

5、动态数据源路由

前面我们以及新建了数据源上下文,用于存储我们当前线程的数据源 key 那么怎么通知spring用 key 当前的数据源呢,查阅资料可知,spring提供一个接口,名为AbstractRoutingDataSource的抽象类,我们只需要重写determineCurrentLookupKey方法就可以,这个方法看名字就知道,就是返回当前线程的数据源的 key,那我们只需要从我们刚刚的数据源上下文中取出我们的 key 即可,那么具体代码取下。
com.yukong.chapter5.config.DynamicRoutingDataSource

/**
 * @Auther: yukong
 * @Date: 2018/8/15 10:47
 * @Description: 动态数据源路由配置
 */
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {

    private static Logger logger = LoggerFactory.getLogger(DynamicRoutingDataSource.class);

    @Override
    protected Object determineCurrentLookupKey() {
        String dataSourceName = DynamicDataSourceContextHolder.getDataSourceRouterKey();
        logger.info("当前数据源是:{}", dataSourceName);
        return DynamicDataSourceContextHolder.getDataSourceRouterKey();
    }
}

6、通过 aop + 注解实现动态数据源的切换

现在 spring 也已经知道通过 key 来取对应的数据源,我们现在只需要实现给对应的类或者方法设置他们的数据源的 key,并且保存在数据源上下文中即可。这里我们采用注解来设置数据源,通过 aop 拦截并且保存到数据源上下中。
我们新建一个标识数据源的注解@DataSource具体代码取下
com.yukong.chapter5.annotation.DataSource

/**
 * 切换数据注解 可以用于类或者方法级别 方法级别优先级 > 类级别
 */
@Target({ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSource {
    String value() default "master"; //该值即key值
}

其中他的默认值是master, 因为我们默认数据源的 key 也是master。也就是说如果你直接用注解,而不指定 value 的话,那么默认就使用 master 默认数据源。
然后我们新建一个 aop 类来拦截。代码如下
com.yukong.chapter5.aop

package com.yukong.chapter5.aop;

import com.yukong.chapter5.annotation.DataSource;
import com.yukong.chapter5.config.DynamicDataSourceContextHolder;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

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

    @Before("@annotation(ds)")
    public void changeDataSource(JoinPoint point, DataSource ds) throws Throwable {
        String dsId = ds.value();
        if (DynamicDataSourceContextHolder.dataSourceIds.contains(dsId)) {
            logger.debug("Use DataSource :{} >", dsId, point.getSignature());
        } else {
            logger.info("数据源[{}]不存在,使用默认数据源 >{}", dsId, point.getSignature());
            DynamicDataSourceContextHolder.setDataSourceRouterKey(dsId);
        }
    }

    @After("@annotation(ds)")
    public void restoreDataSource(JoinPoint point, DataSource ds) {
        logger.debug("Revert DataSource : " + ds.value() + " > " + point.getSignature());
        DynamicDataSourceContextHolder.removeDataSourceRouterKey();

    }
}

通过 aop 拦截,获取注解上面的 value 的值 key,然后取判断我们注册的 keys 集合中是否有这个 key, 如果没有,则使用默认数据源,如果有,则设置上下文中当前数据源的 key 为注解的 value。
7、测试
最后我们在对应的方法上面加上注解来测试一下即可
我们在 UserMapper.java 上面加上注解,并且进行测试。

/**
 * @Auther: yukong
 * @Date: 2018/8/13 19:47
 * @Description: UserMapper接口
 */
public interface UserMapper {

    /**
     * 新增用户
     * @param user
     * @return
     */
    @DataSource  //默认数据源
    int save(User user);

    /**
     * 更新用户信息
     * @param user
     * @return
     */
    @DataSource  //默认数据源
    int update(User user);

    /**
     * 根据id删除
     * @param id
     * @return
     */
    @DataSource  //默认数据源
    int deleteById(Long id);

    /**
     * 根据id查询
     * @param id
     * @return
     */
    @DataSource("slave1")  //slave1
    User selectById(Long id);

    /**
     * 查询所有用户信息
     * @return
     */
    @DataSource("slave2")  //slave2
    List<User> selectAll();
}

上面代码可以知道,我们的新增,修改,删除方法都是在默认数据 master 上,我们的 id 查询是在 slave1,我们的查询所有在 slave2,我们编写测试类来测试把。

/**
 * @Auther: yukong
 * @Date: 2018/8/14 16:34
 * @Description:
 */
@SpringBootTest
@RunWith(SpringJUnit4ClassRunner.class)
public class UserMapperTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    public void save() {
        User user = new User();
        user.setUsername("master");
        user.setPassword("master");
        user.setSex(1);
        user.setAge(18);
        Assert.assertEquals(1,userMapper.save(user));
    }

    @Test
    public void update() {
        User user = new User();
        user.setId(8L);
        user.setPassword("newpassword");
        // 返回插入的记录数 ,期望是1条 如果实际不是一条则抛出异常
        Assert.assertEquals(1,userMapper.update(user));
    }

    @Test
    public void selectById() {
        User user = userMapper.selectById(2L);
        System.out.println("id:" + user.getId());
        System.out.println("name:" + user.getUsername());
        System.out.println("password:" + user.getPassword());
    }

    @Test
    public void deleteById() {
        Assert.assertEquals(1,userMapper.deleteById(1L));
    }

    @Test
    public void selectAll() {
        List<User> users= userMapper.selectAll();
        users.forEach(user -> {
            System.out.println("id:" + user.getId());
            System.out.println("name:" + user.getUsername());
            System.out.println("password:" + user.getPassword());
        });
    }

}

首先测试 save 方法,它将会把数据存到 master 库的 user 表,
现在 user 表是空的,如图


image.png

运行 save 方法。

image.png

绿色,测试通过,并且日志提示数据源注册成功,一共三个。并且当前使用的 master 数据源,我们再去 master 数据库看看有没有数据。

image.png

如上图,插入成功。
新增方法测试完成了。我们在测试一下修改与删除。

[图片上传失败...(image-3947c9-1632203884939)]

修改方法也测试通过,查看数据库。

[图片上传失败...(image-2b41bf-1632203884939)]

修改成功,删除方法我就不测试, 我们在测试测试,slave1 跟 slave2 数据源的方法,
首先测试slave1的主键查询方法,先看数据库 slave1有哪些数据。

[图片上传失败...(image-8791a2-1632203884939)]

slave1.user就一条 id 为 2 的数据并且 id 为 2 的数据就 slave1 才有,我们测试一下能不能查到。

[图片上传失败...(image-3b3b88-1632203884939)]

运行通过,数据源为slave1并且数据也正确显示。
最后我们来测试一下slave2的 selectAll 方法把,同样先看看slave2.user中有什么数据。

[图片上传失败...(image-f915c4-1632203884939)]

从图中,得知slave2.user中有两条数据,id 分别为 3,4。接下来运行测试方法。
结果如图。

[图片上传失败...(image-aa225e-1632203884939)]

日志提示数据源切换值slave2,并且 id 为 3,4 的数据也成功打印。
那么至此我们的多数据源动态数据源就完成了。

主要的思路就是

  1. 配置文件中配置多个数据源
  2. 启动类注册动态数据源
  3. 在需要的方法上使用注解指定数据源
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容