springboot动态多数据源配置多线程切换问题

背景

项目中使用了spring的AbstractRoutingDataSource来实现多数据切换动态功能,大体实现思路和链接中文章所述差不多的: link,一开始运行正常,后来对一个查询比较慢的接口做了优化,使用了多个线程并发处理,然后就出问题了,现象是请求每隔几次就报错 表或视图不存在,也就是切换数据源失败了。

排查思路

先总结下我之前遇到过的各种切换失败的情况:

1.方法调用导致失败

基于aop切换数据源的思路就是执行目标方法前根据目标方法上的自定义注解切换数据,然后执行目标方法,然后在目标方法执行结束后再清空ThreadLocal中的信息,这一点网上基于aop做切换的文章基本都是这样,如果存在几个切换数据源的方法嵌套调用话是会导致数据源失效的,举个例子 A方法调用B方法,A方法的注解指明想要切换数据源为 ds1,B方法的注解指明想要切换数据源为 ds2,那么在执行A,B方法过程中,该线程的ThreadLocal一开始值是ds1,此时A方法还未结束,然后执行到B方法,aop将ThreadLocal的值设置为ds2,然后B方法正常执行结束,此时由于B方法结束,aop将清空本次请求线程的ThreadLocal的值,如果此时A方法还有数据库请求,那此时ds1数据源其实已经不存在ThreadLocal中了,就会导致表或视图不存在。这个问题是客观存在的,解决该问题的简单办法是在A方法调用B方法后再手动切换一次,但是这并不是我本次遇到的这种情况。

2.@Transactional事物导致切换失败

这个其实很明显,spring默认事物管理是需要基于同一个connect来处理的,具体的源码分析和解决办法网上也挺多的,我们目前还没有几个数据源需要强一致性的事物要求,如果有要求的话最好是上分布式事务。

3.多线程并发问题

这个就是我这次遇到的问题,我用了多个线程去调用一个需要切换数据的方法,尽管我们每个线程对应的自己的ThreadLocal里的值是独立的,但是有一个致命并且被忽略的点,在上文我提到了一个文章链接,其中切换数据源的方法我摘抄在这:

/**
 * 多数据源
 *
 */
public class DynamicDataSource extends AbstractRoutingDataSource {

    public static Map<Object, Object> targetDataSources = new ConcurrentHashMap<>(10);

    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicContextHolder.peek();
    }

    public static void setDataSource(String dbKey) throws Exception{
        if(!DynamicDataSource.targetDataSources.containsKey(dbKey)){
            DruidDataSource dataSource = DynamicDBUtil.getDbSourceByDbKey(dbKey);
            DynamicDataSource.targetDataSources.put(dbKey,dataSource);
        }
        //切换动态多数据源的dbKey
        DynamicContextHolder.push(dbKey);
        DynamicDataSource dynamicDataSource = (DynamicDataSource) SpringContextUtils.getBean("dynamicDataSource");
        //使得修改后的targetDataSources生效
        dynamicDataSource.afterPropertiesSet();
    }

}
————————————————
版权声明:本文为CSDN博主「新林。」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_21187515/article/details/121613369

而问题也出现在这里 setDataSource(String dbKey) 这个方法中的 dynamicDataSource.afterPropertiesSet(); 这行代码;
在这里贴出spring中的AbstractRoutingDataSource 中的afterPropertiesSet方法

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

        }
    }

afterPropertiesSet方法是spring中InitializingBean接口的方法,实现这个接口在bean初始化时可以做一些自定义操作,targetDataSources是一个map,需要我们将自定义的数据源添加进去,然后spring在afterPropertiesSet中会将targetDataSources 中的数据源拷贝一份到resolvedDataSources,之所以存在2个map我觉得可能是spring为拓展功能预留的,目前来看这2个map的内容是一样的。afterPropertiesSet这个方法在单线程下自然不会有什么问题,但是多线程下对resolvedDataSources进行put就有些问题了,到这里找到了问题所在,既然是线程问题,那我们在 setDataSource(String dbKey) 这个方法加锁可以吗?答案也是不行的,因为这里要保证的是resolvedDataSources读写的一致性,或者说读写的互斥性,对setDataSource(String dbKey)加锁也就是对afterPropertiesSet方法加锁,只能说对resolvedDataSources的写入是安全的,但是其他线程任然是可以并发读取的,而这才是导致我一开始描述的 请求每隔几次就报错 表或视图不存在 这个问题的元凶。
解决办法就是不要在切换数据源的时候去调用afterPropertiesSet,afterPropertiesSet这个方法本身就是bean初始化的时候调用的方法,就spring的bean的生命周期而言,这个方法本身就只应该调用一次,就是populateBean之后在initializeBean过程中调用!从这里而言,我们这种做法其实违背了spring的初衷,所以导致了这种问题。但是只在初始化时调用一次afterPropertiesSet显然是无法动态添加数据源的,要做的灵活,我们一般是将数据源配置放在界面上,支持人工配置各个数据源,将其保存在数据库表中,这样可以动态crud各个数据源,所以afterPropertiesSet这个方法我们需要放在配置数据源的增删改的方法之中去调用,这样绝大部分情况下就没有问题了。
但是如果在crud数据源的时候,刚好又有其他线程并发切换数据源的话,还是有可能失败的,那么这种情况下就需要将afterPropertiesSet和determineTargetDataSource这2个方法也重写一下,对这2个方法加上互斥锁。

总结

一开始这个动态切换数据源的功能也是借鉴了网上的思路,其中是否存在的问题也并未深究,要拓展spring的功能的时候,还是需要去理解一下spring的设计思想的,纸上得来终觉浅,绝知此事要躬行。