SpringBoot动态数据源切换(基于AbstractRoutingDataSource实现)

开发环境

  • jdk 11.0.10
  • SpringBoot 2.6.2
  • Idea

本文的代码实现了动态数据源切换的starter工具类。

SpringBoot提供了AbstractRoutingDataSource类用于确定运行时使用哪一个数据源,通过继承该类能够获得动态切换数据源的功能。

首先需要一个切换数据源的上下文辅助类DynamicDataSourceContextHolder,该类中使用了ThreadLocal来为每个请求线程设置需要切换的数据源的key

public final class DynamicDataSourceContextHolder {

    /**
     * 实际的数据源
     */
    private static final Map<String, DataSource> DS_MAP = new ConcurrentHashMap<>();

    /**
     * 数据源类型-数据源key列表映射
     */
    private static final Map<DataSourceType, List<String>> KEY_MAP = new ConcurrentHashMap<>();

    /**
     * 默认主数据源的key
     */
    private static final ThreadLocal<String> CONTEXT = ThreadLocal.withInitial(() -> null);

    /**
     * 添加数据源
     * @param beanName 数据源BeanName
     * @param type 数据源类型
     * @param dataSource 数据源
     */
    public synchronized static void addMap(String beanName, DataSourceType type, DataSource dataSource) {
        var list = KEY_MAP.get(type);
        if (list == null) {
            list = new ArrayList<String>();
        }
        list.add(beanName);
        KEY_MAP.put(type, list);
        // 实际数据源的缓存
        DS_MAP.put(beanName, dataSource);
    }

    /**
     * 获取数据源
     * @param key 数据源key
     * @return 数据源
     */
    protected synchronized static DataSource getDataSource(String key) {
        return DS_MAP.get(key);
    }

    /**
     * 将第一个从数据源转换为主数据源
     * @return 主数据源的key
     */
    public synchronized static String convertSlaveToMaster() {
        var sList = KEY_MAP.get(DataSourceType.SLAVE);
        var mList = new ArrayList<String>(1);
        String key = sList.remove(0);
        mList.add(key);
        KEY_MAP.put(DataSourceType.MASTER, mList);
        return key;
    }

    /**
     * 当前数据源的key
     * @return 当前数据源的key
     */
    public static String current() {
        return CONTEXT.get();
    }

    /**
     * 切换为从数据源(随机)
     */
    public static void slave() {
        var list = KEY_MAP.get(DataSourceType.SLAVE);
        if (list == null || list.isEmpty()) {
            CONTEXT.set(null);
        } else {
            // 简单随机
            CONTEXT.set(list.get(new Random().nextInt(128) % list.size()));
        }
    }
    
    /**
     * 切换为从数据源(指定)
     * @param key 指定数据源key
     */
    public static void slave(String key) {
        if (KEY_MAP.get(DataSourceType.SLAVE).contains(key)) {
            CONTEXT.set(key);
        } else {
            throw new IllegalArgumentException("no slave datasource matched!...");
        }
    }

    /**
     * 切换为主数据源
     */
    public static void master() {
        // 一定会有一个主数据源
        CONTEXT.set(KEY_MAP.get(DataSourceType.MASTER).get(0));
    }

    /**
     * 指定数据源
     * @param key 指定数据源key
     */
    public static void specify(String key) {
        if (DS_MAP.containsKey(key)) {
            CONTEXT.set(key);
        } else {
            throw new IllegalArgumentException("key not matched!");
        }
    }

    /**
     * 重置数据源
     */
    public static void reset() {
        // 如果找不到对应的key,AbstractRoutingDataSource中会使用默认的数据源
        // 默认数据源需要在设置AbstractRoutingDataSource时指定
        CONTEXT.remove();
    }
}

需要注意的是,每次切换数据源后都必须将线程变量中的数据源key清空,否则会影响下一个请求。

这个类中,还持有两个Map, 一个是数据源类型-数据源key列表映射,在外部切换时,直接指定数据源类型即可,这个辅助类会根据类型找到对应所有可用的数据源。另一个是保存实际的数据源的Map,辅助类在实际寻找数据源时利用key找到对应的数据源。具体实现方式见slave()方法。

下面需要实现AbstractRoutingDataSource

public class DynamicDataSource extends AbstractRoutingDataSource {

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

    private DataSource defaultTargetDataSource;

    @Override
    public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
        logger.info("default dataSource has been setup...");
        this.defaultTargetDataSource = (DataSource) defaultTargetDataSource;
    }

    /**
     * 确定当前需要获取的数据源key-父类中根据key获取对应的数据源
     * @return 数据源的key
     */
    @Override
    protected String determineCurrentLookupKey() {
        String key = DynamicDataSourceContextHolder.current();
        logger.info("Current DataSource Is {}", key);
        return key;
    }

    /**
     * 重写查找数据源的方法-一般重写determineCurrentLookupKey已经足够满足业务需求了
     * @return 确定的数据源
     */
    @Override
    protected DataSource determineTargetDataSource() {
        // 由于重写了determineTargetDataSource方法,所以此处determineCurrentLookupKey的作用可有可无
        String lookupKey = this.determineCurrentLookupKey();

        if (lookupKey == null || DynamicDataSourceContextHolder.getDataSource(lookupKey) == null) {
            logger.info("use the default dataSource...");
            return this.defaultTargetDataSource;
        }
        var ds = DynamicDataSourceContextHolder.getDataSource(lookupKey);

        // ThreadLocal的key为弱引用,可能已经被GC回收了
        if (ds == null) {
            // 清除缓存
            logger.warn("dataSource key has been garbage collected... current dataSource has been convert to the default dataSource");
            DynamicDataSourceContextHolder.reset();
            return this.defaultTargetDataSource;
        }
        return ds;
    }

    /**
     * 重写afterPropertiesSet,让数据源可以在容器初始化后再设置
     */
    @Override
    public void afterPropertiesSet() {
      logger.info("AbstractRoutingDataSource has been override...default setup will not be executed...");
    }

正常情况下实现determineCurrentLookupKey方法其实已经够了,实际运行中就是根据这个方法的返回值去找对应的数据源,找不到则使用默认的defaultTargetDataSource
但是这边考虑到一主多从的情况,所以将determineTargetDataSource也重写了。具体逻辑也不复杂,具体参考贴出的代码。
注意这里还重写了afterPropertiesSet方法,因为正常情况下afterPropertiesSet会在容器生成Bean后进行调用,而父类的afterPropertiesSet方法中会进行一些处理,如下:

@Override
public void afterPropertiesSet() {
    if (this.targetDataSources == null) {
        throw new IllegalArgumentException("Property 'targetDataSources' is required");
    }
    this.resolvedDataSources = CollectionUtils.newHashMap(this.targetDataSources.size());
    this.targetDataSources.forEach((key, value) -> {
        Object lookupKey = resolveSpecifiedLookupKey(key);
        DataSource dataSource = resolveSpecifiedDataSource(value);
        this.resolvedDataSources.put(lookupKey, dataSource);
    });
    if (this.defaultTargetDataSource != null) {
        this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
    }
}

具体可看AbstractRoutingDataSource源码

由于我们是要做一个工具类,所以需要开放权限给用户自定义,所以初始化时没有设置数据源集合,默认处理会导致异常,这里重写afterPropertiesSet方法避免出错。

数据类型的枚举类如下:

public enum DataSourceType {
    /**
     * 默认类型,和MASTER等价
     */
    DEFAULT("DEFAULT"),

    /**
     * 主数据源,一般用于写
     */
    MASTER("MASTER"),

    /**
     * 从数据源,一般用于都
     */
    SLAVE("SLAVE");

    /**
     * 数据源类型名称
     */
    private String name;

    private DataSourceType(String name) {
        this.name = name;
    }

    /**
     * 获取类型名称
     * @return 类型名称
     */
    public String typeName() {
        return this.name;
    }
}

完成核心功能后,可以开始添加注解的功能。
自定义注解如下:

@Documented
@Retention(RUNTIME)
@Target(value = {ElementType.TYPE, ElementType.METHOD})
public @interface Dynamic {
    /**
     * 指定数据源key
     * @return 数据源key
     */
    String target() default "";

    /**
     * 指定数据源类型(随机获取)
     * @return 数据源类型
     */
    DataSourceType type() default DataSourceType.DEFAULT;
}

切面类如下:

@Aspect
@Order(-1) // 高优先级-必须要比@Transacation高
public class DynamicSwitchAspect {

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

    /**
     * 切点
     */
    @Pointcut("@annotation(cn.t.dynamic.switcher.annotation.Dynamic) || @within(cn.t.dynamic.switcher.annotation.Dynamic)")
    public void pointCut() {}

    /**
     * 方法执行前切换数据源
     * @param joinPoint 连接点信息
     */
    @Before("pointCut()")
    public void before(JoinPoint joinPoint) {
        // 此处注解信息一定存在于类上或者方法上
        // 优先使用方法上的注解
        Dynamic d = ((MethodSignature)joinPoint.getSignature()).getMethod().getAnnotation(Dynamic.class);
        if (d == null) {
            d = joinPoint.getTarget().getClass().getAnnotation(Dynamic.class);
        }

        String target = d.target();
        DataSourceType type = d.type();

        if (target.isEmpty() && type.equals(DataSourceType.DEFAULT)) {
            // 使用默认的数据源
            DynamicDataSourceContextHolder.master();
        } else if (!target.isEmpty() && type.equals(DataSourceType.DEFAULT)) {
            // 指定数据源
            DynamicDataSourceContextHolder.specify(target);
        } else if (target.isEmpty() && type.equals(DataSourceType.SLAVE)) {
            // 随机选取从数据源
            DynamicDataSourceContextHolder.slave();
        } else if (!target.isEmpty() && type.equals(DataSourceType.SLAVE)) {
            // 指定从数据源
            DynamicDataSourceContextHolder.slave(target);
        } else {
            // 排除所有情况后
            // 主数据源
            DynamicDataSourceContextHolder.master();
        }
        logger.info("datasource conversion completed");
    }

    /**
     * 方法执行后清空切换key
     * @param joinPoint 连接点信息
     */
    @After("pointCut()")
    public void after(JoinPoint joinPoint) {
        logger.info("datasource key will be reset...");
        DynamicDataSourceContextHolder.reset();
    }
}

由于注解支持类和方法,所以切面类中需要仔细判断。

由于容器启动过程中不会指定数据源集合,所以需要在容器启动后进行增强处理,在增强处理中配置数据源集合:

public class DynamicContextAware implements ApplicationContextAware {

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

    /**
     * 容器后处理-在最后给动态数据源设置可用的数据源
     * @param applicationContext Spring容器上下文
     * @throws BeansException 异常
     */
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        // 在所有Bean初始化完成后获取动态数据源,最后进行设置,保证自定义的数据源已经被初始化完成了
        DynamicDataSource ds = applicationContext.getBean(DynamicSwitcherConfiguration.DYNAMIC_SOURCE_BEAN_NAME, DynamicDataSource.class);

        // 获取所有的数据源实例(BeanName-DataSource)(需要去除动态数据源-动态数据源也是DataSource类型)
        Map<String, DataSource> beans = applicationContext.getBeansOfType(DataSource.class);
        // 去除自身
        beans.remove(DynamicSwitcherConfiguration.DYNAMIC_SOURCE_BEAN_NAME);

        if (beans.isEmpty()) {
            logger.warn("it seems to you have not setup the dataSource...");
            return;
        }

        // 配置多数据源Map
        Map<Object, Object> dsMap = CollectionUtils.newHashMap(beans.size());
        var hasPrimary = false;
        for (Map.Entry<String, DataSource> entry : beans.entrySet()) {
            var v = entry.getValue();
            var k = entry.getKey();
            // DataSource是在配置类中使用@Bean方法生成的,DataSource类上并没有Primary注解,需要使用Spring容器上下文来获取
            // var p = v.getClass().getAnnotation(Primary.class);
            if (this.isPrimary(applicationContext, k)) {
                if (!hasPrimary) {
                    // 设置主数据源
                    ds.setDefaultTargetDataSource(v);
                    hasPrimary = true;
                    DynamicDataSourceContextHolder.addMap(k, DataSourceType.MASTER, v);
                } else {
                    logger.info("already has a master...{} will become a slave...", k);
                    DynamicDataSourceContextHolder.addMap(k, DataSourceType.SLAVE, v);
                }
            } else {
                DynamicDataSourceContextHolder.addMap(k, DataSourceType.SLAVE, v);
            }
            dsMap.put(k, v);
        }

        // 未指定默认数据源则使用第一个
        if (!hasPrimary) {
            String key = DynamicDataSourceContextHolder.convertSlaveToMaster();
            logger.warn("do not find a primary dataSource...the first dataSource whose name is {} will become the master...", key);
            ds.setDefaultTargetDataSource(beans.get(key));
        }
        // 设置多数据源Map
        // ds.setTargetDataSources(dsMap);
    }

    /**
     * 判断当前数据源是否被Primary注解标注(由于DataSource是在配置类中使用@Bean方法生成的,普通的getClass()再获取注解将不适用)<br/>
     * 而使用容器的context的方法可以获取到
     * @param applicationContext 容器上下文
     * @param key 比对的数据源key
     * @return 是否标注了primary注解
     */
    private boolean isPrimary(ApplicationContext applicationContext, String key) {
        var beanMap = applicationContext.getBeansWithAnnotation(Primary.class);
        return beanMap.containsKey(key);
    }
}

最后,配置自动配置类:

@Configuration
public class DynamicSwitcherConfiguration {

    public static final String DYNAMIC_SOURCE_BEAN_NAME = "dynamicDataSource";

    /**
     * 创建切面Bean
     * @return
     */
    @Bean
    public DynamicSwitchAspect dynamicSwitchAspect() {
        return new DynamicSwitchAspect();
    }

    /**
     * 创建后处理Bean
     * @return
     */
    @Bean
    public DynamicContextAware dynamicContextAware() {
        return new DynamicContextAware();
    }

    /**
     * 动态数据源上下文-内部应该包含一个数据源key-数据源对象的键值对Map<br/>
     * mybatis中直接注入这个bean即可
     * @return
     */
    @Bean(name = DYNAMIC_SOURCE_BEAN_NAME)
    public DynamicDataSource dynamicDataSource() {
        return new DynamicDataSource();
    }
}
使用方式

打包后,引入需要该功能的模块中

<dependency>
     <groupId>cn.t.dynamic</groupId>
     <artifactId>dataSource-switcher-spring-boot-starter</artifactId>
  <version>1.0.0</version>
</dependency>

在自己模块中注入动态数据源

@Autowired
private DynamicDataSource dynamicDataSource;

在为类似mybatis框架配置数据源时,使用dynamicDataSource即可
多数据源配置可参考官网

https://docs.spring.io/spring-boot/docs/2.4.2/reference/html/howto.html#howto-two-datasources

  1. 数据源需要自己配置生成,配置过程中可高度定制化。

数据源需要自己配置生成,配置过程中可高度定制化。

//ds1...
@Bean("prop1")
@ConfigurationProperties(prefix = "spring.datasource.datasource1")
public DataSourceProperties dsProp1() {
    return new DataSourceProperties();
}

@Bean(name = "dataSource1")
@Primary // 该注解标注的数据源会被认为是master
public DataSource dataSource1() {
    // 可以自定义一些属性,比如连接池的设置
    return dsProp1().initializeDataSourceBuilder().build();
}

// ds2....

配置事务和sessionFactory时注入动态数据源

// 以事务配置举例
@Bean(name = "sf1")
@Primary
public PlatformTransactionManager txManager1(DynamicDataSource dataSource) {
    DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
    // 事务10秒超时
    transactionManager.setDefaultTimeout(10);
    return transactionManager;
}
切换数据源

切换数据源(编码方式)

  1. 切换master

    DynamicDataSourceContextHolder.master()

  2. 切换slave

    DynamicDataSourceContextHolder.slave()

  3. 指定slave

    DynamicDataSourceContextHolder.slave(String)

  4. 指定存在的数据源

    DynamicDataSourceContextHolder.specify(String)

public voud func() {
    // 切换数据源
    DynamicDataSourceContextHolder.master();
    // DynamicDataSourceContextHolder.slave();
    // do something
    // ...
    // !!!清除缓存
    DynamicDataSourceContextHolder.reset();
}

完成逻辑后一定要调用DynamicDataSourceContextHolder.reset()清除缓存

切换数据源(注解方式)

  1. 切换master

    @Dynamic
    public void demo() {  }
    
    @Dynamic(type = DataSourceType.MASTER)
    public void demo() {  }
    
    // 当类型指定为MASTER时,key任意,因为主数据源只有一个
    @Dynamic(target = "any key", type = DataSourceType.MASTER)
    public void demo() {  } 
    
  2. 切换slave

    // 指定一个从数据源,target必须存在且类型为从数据源
    @Dynamic(target = "ds2", type = DataSourceType.SLAVE)
    public void demo() {  } 
    
    // 随机选取一个从数据源
    @Dynamic(type = DataSourceType.SLAVE)
    public void demo() {  } 
    
  3. 指定数据源

    // 指定一个数据源,target必须存在
    @Dynamic(target = "ds2")
    public void demo() {  } 
    

注意DataSourceType.DEFAULT不需要手动设置

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

推荐阅读更多精彩内容