事务内动态数据源切换失效:源码解析

        笔者之前就事务和动态数据源之间的问题,做过一些探讨和总结(详见事务内动态数据源切换失效及传播属性)。后来从源码层面分析有了一些收获,篇幅较长不便写在原文中,因此另起一篇作为补充。

        注意,上篇的实验和结论依旧有效,本文旨在将理论和实践结合,给出更精确的解决方案及底层原理。

1 结论

        先上结论,帮助同样被这个问题困扰的朋友们。

        首先明确,事务控制得最小单位为“同一个数据库连接”,即想要正常控制事务回滚提交,那么整个过程只能基于一个Connection;而使用不同数据源就意味着一定对应不同的Connection,因此既要使用多数据源切换、又要使得所有数据库操作同步回滚提交,不引入分布式事务的前提下是做不到的(也许只是我做不到,如果有解决方案也可以分享)。

        其次,在事务开启时如果没有指定数据源,事务会取默认数据源并使用他,即配置文件中的primary数据源。

        基于上面的原则,结论如下:

  1. 需要切换数据源的地方一定要开启新的事务,例如Service1调用Service2,如两个方法要使用不同数据源,则必须要在Service2方法上指定能够挂起原事务、开启新事务的传播属性。如使用REQUIRES_NEW、NOT_SUPPORT,强行重置数据库连接。但这也就意味着两个事务是独立的,无法同步提交/回滚。
  2. 开启事务必须与指定数据源同步进行,即方法1上如果要指定非主数据源、又要开启事务,就必须把@Transactional和@DS同时加在方法上,先开事务再在里面的Mapper接口指定数据源是无效的哦。

        事务和数据源注解的用法,直接上代码。

class Service1 {

    //由于未指定数据源便开启了事务,会使用primary数据源
    @Transactional(rollbackFor = Exception.class)
    public void m1() {
        //SQL调用
    }

    //开启事务时指定了数据源,会使用datasource2数据源
    @DS("datasource2")
    @Transactional(rollbackFor = Exception.class)
    public void m2() {
        //SQL调用
    }

}

        上面为一个方法内只使用单个数据源的方式,在开启事务时就需要指定数据源,否则会默认使用主数据源;一般常用的方式是在Mapper接口上加@DS,但这种方式在这种场景下是无效的,因为事务在Service层就开启,进入Mapper哪怕切换出火花来也不好使。

class Service1 {

    //由于未指定数据源便开启了事务,会使用primary数据源
    //m3加入该事务,也会使用primary
    //m4新建事务,使用datasource2
    @Transactional(rollbackFor = Exception.class)
    public void m1() {
        //SQL调用
        Service2.m3();
        Service2.m4();
    }

    //开启事务时指定了数据源,会使用datasource2数据源
    //m3加入该事务,也会使用datasource2
    //m4新建事务,使用datasource3
    @DS("datasource2")
    @Transactional(rollbackFor = Exception.class)
    public void m2() {
        //SQL调用
        Service2.m3();
        Service2.m4();
    }

}

class Service2 {

    //默认传播属性,加入已有事务
    @DS("datasource2")
    @Transactional(rollbackFor = Exception.class)
    public void m3() {
        //SQL调用
    }

    //REQUIRES_NEW,挂起原事务,开启新事务
    @DS("datasource3")
    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
    public void m4() {
        //SQL调用
    }

}

        上述代码一个方法内多次切换数据源,首先一定要确保是注入Bean来调用方法,否则加啥注解也不好使,动态代理会直接失效。

        先看m1(),没有指定数据源所以使用primary数据源,调用得m3()使用了默认的REQUIRED传播属性会加入m1()的事务一起使用primary,因此m3()上的数据源切换失效;而m4()使用了REQUIRES_NEW,挂起了m1()开启的事务并新建了事务,开启事务的同时指定了数据源datasource3,因此m4()不会受任何人影响做自己想做的。

        m2()类似,会使m3()使用m2()开启时指定的datasource2,m4()用自己的datasource3。

2 源码解析

        结论有了,自然也要有辩证的过程,矛盾集中在@Transactional和@DS同时使用,因此我们要整明白这两个注解都干了什么、哪一步产生了冲突、又该如何解决这种冲突,我们来一步一步盘。

2.1 方法代理

        断点加在从Controller跳转到Service实现类中间,因为Spring注解采用了AOP动态代理来实现功能增强,那连接点就在加了注解的方法上,切点在方法执行前;因此不论是事务的开启还是数据源的切换,我们都需要关注AOP是如何对其进行改造的。

        在跳转进方法后进入了CglibAopProxy,入参为代理类、方法签名、方法实参和代理方法,重点是在连接点中获取方法对应的注解,再去ProxyFactory获取注解对应的拦截器,返回一个拦截器链列表;当拦截器链不为空时,就会将代理对象、被代理对象、被代理方法、方法入参、被代理类、拦截器链、代理方法传入代理方法执行器并执行。

//获取被代理原对象,也就是单例Bean
target = targetSource.getTarget();
Class targetClass = target != null ? target.getClass() : null;

//根据方法签名和类获取拦截器链
List chain = this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass);
Object retVal;

//拦截器为空则直接反射调用,不为空则进入拦截器逻辑
if (chain.isEmpty() && Modifier.isPublic(method.getModifiers())) {
    Object[] argsToUse = AopProxyUtils.adaptArgumentsIfNecessary(method, args);
    retVal = methodProxy.invoke(target, argsToUse);
} else {
    retVal = (new CglibAopProxy.CglibMethodInvocation(proxy, target, method, args, targetClass, chain, methodProxy)).proceed();
} 
  

        Debug中方法注解和chain,正对应着我们加入的两个拦截器:

事务内动态数据源切换失效:源码解析_第1张图片

         到这一步,说明AOP成功感知到了调用方法需要事务和数据源切换的增强,并将根据拦截器链挨个处理;执行的顺序是先切数据源、再开启事务,如此合理也自然不会是巧合,肯定是框架内部指定了Order之类的属性,至于如何控制优先级就不展开讨论了。

2.2 DS拦截器

        按照顺序,首先要处理@DS对应的DynamicDataSourceAnnotationInterceptor

        invoke()方法的入参为this当前对象,即我们上面说到的所有东西都传过来了,拦截器首先要看看准备往哪个数据源切;这一步逻辑也很简单,就是去方法签名里找DS注解对应的value,比较特别的是他内部维护了一个dsCache,是个放了所有方法使用数据源名称字符串的ConcurrentHashmap,每次先去dsCache查,有就返回没有就查了放进去供下次使用。

    //DataSourceClassResolver处理切换数据源名称逻辑
    public String findKey(Method method, Object targetObject) {
        if (method.getDeclaringClass() == Object.class) {
            return "";
        } else {
    //根据方法签名获取数据源名称
    //dsCache为本地Map缓存,查询过的会缓存下来避免重复查询
            Object cacheKey = new MethodClassKey(method, targetObject.getClass());
            String ds = (String)this.dsCache.get(cacheKey);
            if (ds == null) {
                ds = this.computeDatasource(method, targetObject);
                if (ds == null) {
                    ds = "";
                }

                this.dsCache.put(cacheKey, ds);
            }

            return ds;
        }
    }

        获取到数据源名称后,关键的一步出现了!调用了工具类DynamicDataSourceContextHolder

并将数据源名称push了进去,push进了一个怎样的容器,这点是至关重要的,我们来具体看看。

    private static final ThreadLocal> LOOKUP_KEY_HOLDER = new NamedThreadLocal>("dynamic-datasource") {
        protected Deque initialValue() {
            return new ArrayDeque();
        }
    };


    public static String push(String ds) {
        String dataSourceStr = StringUtils.isEmpty(ds) ? "" : ds;
        ((Deque)LOOKUP_KEY_HOLDER.get()).push(dataSourceStr);
        return dataSourceStr;
    }

        可以看到线程绑定变量ThreadLocal里放了一个DequeDeque是一个双端队列,可以当作用也可以当作队列用。实例化时使用了ArrayDeque,因此在此处将其用作了先进先出的队列其push方法底层调用了addFirst(),将数据放在了队列头

        放完后DS拦截器的活儿基本也干完了,于是继续向下调用拦截器,因为拦截器是链式调用嘛,在所有链路走完前肯定不能断,因此就又回到ReflectiveMethodInvocation的proceed方法,处理下一个拦截器。

2.3 Transactional拦截器

        TransactionInterceptor的invoke()方法只做了一件事,将方法签名、被代理类、反射执行器的proceed方法引用,传递给真正开启并处理事务的TransactionAspectSupport。可能会有疑惑的是这个“proceed方法引用”,其实也很简单,上一步DS拦截器最后一步调用了proceed方法,来继续向下处理拦截器链;这里也是同理,将方法引用传进去,当前拦截器处理完毕后再回到ReflectiveMethodInvocation向下处理。

        之后便进入TransactionAspectSupportinvokeWithinTransaction()方法处理事务,我们来依次看看他都做了些什么。

        先看看最前面,在学习编程式事务时大家都清楚,我们要获取事务属性TransactionDefinition将其传入TransactionManager构建事务管理器并创建事务;这里的逻辑也是类似的,

// If the transaction attribute is null, the method is non-transactional.
TransactionAttributeSource tas = getTransactionAttributeSource();
final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
final PlatformTransactionManager tm = determineTransactionManager(txAttr);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);

        原作者的注释也写得很清楚:如果事务属性为null,说明为非事务方法。这一点也是贯穿整个流程的,很多地方都是基于是否开启事务进行逻辑区分的,就比如第2行TransactionAttribute的获取,事务属性不为null才去获取他。TransactionAttribute其实就是TransactionDefinition的子类,猫叫咪咪而已。

        这几行执行完以后,就有了事务属性、事务管理器以及连接点描述(就是方法名称,根据Method方法签名获取,但内部逻辑有点没看懂),接下来要根据这些信息进行事务逻辑处理。

        先看第一个if分支,如果没有事务属性,或者事务管理器不是CallbackPreferringPlatformTransactionManager

事务管理器的实例,就会进入这部分逻辑。至于这个特定的事务管理器,网上没有搜到很明确的资料,但既然他叫回调、且else逻辑里也是以回调形式调用,姑且就认为他是回调事务管理器。

//没有事务属性(也就是没有事务)时,会进入这段逻辑
if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
			// Standard transaction demarcation with getTransaction and commit/rollback calls.
			TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);

			Object retVal;
			try {
				// This is an around advice: Invoke the next interceptor in the chain.
				// This will normally result in a target object being invoked.
				retVal = invocation.proceedWithInvocation();
			}
			catch (Throwable ex) {
				// target invocation exception
				completeTransactionAfterThrowing(txInfo, ex);
				throw ex;
			}
			finally {
				cleanupTransactionInfo(txInfo);
			}
			commitTransactionAfterReturning(txInfo);
			return retVal;
		}

        我们传入了事务属性,也使用的是JDBC的DataSourceTransactionManager,因此会进入if逻辑。有意义的是createTransactionIfNecessary()方法,返回值是TransactionInfo也就是TransactionStatus的子类,进去看看里面的处理逻辑:

		// If no name specified, apply method identification as transaction name.
		if (txAttr != null && txAttr.getName() == null) {
			txAttr = new DelegatingTransactionAttribute(txAttr) {
				@Override
				public String getName() {
					return joinpointIdentification;
				}
			};
		}

		TransactionStatus status = null;
		if (txAttr != null) {
			if (tm != null) {
				status = tm.getTransaction(txAttr);
			}
			else {
				if (logger.isDebugEnabled()) {
					logger.debug("Skipping transactional joinpoint [" + joinpointIdentification +
							"] because no transaction manager has been configured");
				}
			}
		}
		return prepareTransactionInfo(tm, txAttr, joinpointIdentification, status);

         方法里将事务属性包装成了一个代理类,将事务名称定义为了方法名,之后便是喜闻乐见的用事务管理器开启事务getTransaction()

    Object transaction = doGetTransaction();


    //JDBC实现类 DataSourceTransactionManager
    protected Object doGetTransaction() {
        DataSourceTransactionManager.DataSourceTransactionObject txObject = new DataSourceTransactionManager.DataSourceTransactionObject();
        txObject.setSavepointAllowed(this.isNestedTransactionAllowed());
        ConnectionHolder conHolder = (ConnectionHolder)TransactionSynchronizationManager.getResource(this.obtainDataSource());
        txObject.setConnectionHolder(conHolder, false);
        return txObject;
    }

        先调用doGetTransaction()创建一个对象txObject,再将当前事务的一些信息封装进去。比较有意思的是获取ConnectionHolder,会先调用obtainDataSource()获取当前数据源集合,此时获取到的是:

        可不要以为是原生DataSource哦,在项目启动时DynamicRoutingDataSource就作为DataSource实现类被注入,且里面放置了所有已经初始化的数据源。最后ConnectionHolder为null,因为事务是刚刚开启的;而是否持有连接与是否开启新事务有着很大联系,单个事物只能对应单个连接,要切换就必须开启新的事务并重新获取连接,这点在后面的handleExistingTransaction()方法逻辑里就有体现。

事务内动态数据源切换失效:源码解析_第2张图片

        此时我们的事务管理器还空空的,没办法,刚开启事务,啥也拿不到。所以下面的逻辑都不会走。 

        if (definition == null) {
			// Use defaults if no transaction definition given.
			definition = new DefaultTransactionDefinition();
		}

        //根据是否持有连接/事务是否活跃 判断当前是否有事务在处理中
        //有的话调用handleExistingTransaction(),根据事务传播属性处理新旧事务切换等
		if (isExistingTransaction(transaction)) {
			// Existing transaction found -> check propagation behavior to find out how to behave.
			return handleExistingTransaction(definition, transaction, debugEnabled);
		}

		// Check definition settings for new transaction.
		if (definition.getTimeout() < TransactionDefinition.TIMEOUT_DEFAULT) {
			throw new InvalidTimeoutException("Invalid transaction timeout", definition.getTimeout());
		}

        下面是一长串判断,根据事务传播属性分别处理事务,我们使用了默认的REQUIRED,因此看看这段逻辑。

SuspendedResourcesHolder suspendedResources = suspend(null);
if (debugEnabled) {
	logger.debug("Creating new transaction with name [" + definition.getName() + "]: " + definition);
}
try {
	boolean newSynchronization = (getTransactionSynchronization() != SYNCHRONIZATION_NEVER);
	DefaultTransactionStatus status = newTransactionStatus(
	    definition, transaction, true, newSynchronization, debugEnabled, suspendedResources);
	doBegin(transaction, definition);
	prepareSynchronization(status, definition);
	return status;
}

        suspend()方法就不细看了,里面是TransactionSynchronizationManager相关的处理逻辑。他是线程同步事务管理器,可以实现类似CompletableFuture.whenComplete()的功能,意为这个事务执行完以后、异步事务再开始执行,可以避免主事务插入的数据没commit,异步事务想查又查不到的尴尬情景。

        之后初始化了一个TransactionStatus,里面放了一些事务相关的基本属性,然后调用doBegin()方法开启事务。这一步是最重要的,前面铺垫了那么久好像一直没有数据源什么事,其实就是为了这一刻,咱们来一行一行看。

DataSourceTransactionManager.DataSourceTransactionObject txObject = (DataSourceTransactionManager.DataSourceTransactionObject)transaction;
Connection con = null;

if (!txObject.hasConnectionHolder() || txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
    Connection newCon = this.obtainDataSource().getConnection();
    txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
}

        先初始化几个属性,然后判断事务管理器是否持有连接,没有就进入if逻辑。前文提到obtainDataSource()获取当前数据源集合,获取到了DynamicRoutingDataSource,那再看看getConnection()干了点啥。

    public Connection getConnection() throws SQLException {
        //获取全局事务号
        String xid = TransactionContext.getXID();

        //根据是否有事务号判断分支
        if (StringUtils.isEmpty(xid)) {
            return this.determineDataSource().getConnection();
        } else {
            String ds = DynamicDataSourceContextHolder.peek();
            ds = StringUtils.isEmpty(ds) ? "default" : ds;
            ConnectionProxy connection = ConnectionFactory.getConnection(ds);
            return (Connection)(connection == null ? this.getConnectionProxy(ds, this.determineDataSource().getConnection()) : connection);
        }
    }

         因为我们的事务号为null,因此要先调用determineDataSource()

    public DataSource determineDataSource() {
        String dsKey = DynamicDataSourceContextHolder.peek();
        return this.getDataSource(dsKey);
    }

        还记得这个DynamicDataSourceContextHolder吗?正是DS拦截器之前操作的动态数据源中,ThreadLocal里的双端队列Deque!调用Deque的peek()方法,从队列里拿到数据源名称后,又到服务启动时注册的数据源ConcurrentHashMap,拿到了我们所需的DataSource对象!

        至此,我逐渐理解一切。

        之后就是数据库连接池Druid的活儿了,他去池子里拿到了池化Connection;再回到事务管理器,将连接做成连接持有设置给事务管理器。到这里,我们的事务管理器终于不是光秃秃的了,他有了沉甸甸的数据库连接。

事务内动态数据源切换失效:源码解析_第3张图片

        再回到一开始的抽象事务管理器类中,现在的TransactionStatus可谓是应有尽有。

        将这些东西全部传入prepareTransactionInfo()方法,包括准备好的事务管理器、事务属性、方法名称、事务状态。

	    //所有信息封装成一个TransactionInfo对象
        TransactionInfo txInfo = new TransactionInfo(tm, txAttr, joinpointIdentification);
		if (txAttr != null) {
            //绑定进TransactionInfo对象
			txInfo.newTransactionStatus(status);
		}
		else {
            //打日志,啥也没干
		}
        //将对象绑定至当前线程
		txInfo.bindToThread();
		return txInfo;

        方法的逻辑非常简单,精简了一下就是上面的内容。创建了一个大对象,把所有事务相关的信息全部封装进去,然后绑定到当前线程。提到当前线程,猜也猜得到又是一个ThreadLocal,用一个变量先将旧事务存了起来,又将新事务塞进ThreadLocal,这也就实现了多事务的挂起和切换

        事务和数据源拦截器全整完了,之后就去找下一个进行处理,而我们的拦截器链已经全部走完了,因此就可以愉快地回到Service方法里处理SQL了。

3 问题分析

        相信看完上面源码解析的朋友应该懂为什么会导致切换失败了,我们再从源码层面分析之前举例的几种情况。

3.1 加@DS与不加@DS

        前文的分析表明,加上@DS会给ThreadLocal中的Deque加入指定数据源名称,如果我们不加,这个Deque就会是空的,这点肯定没有异议。

    public DataSource determineDataSource() {
        String dsKey = DynamicDataSourceContextHolder.peek();
        return this.getDataSource(dsKey);
    }

        此时的dsKey为null,给getDataSource()方法传入null会发生什么呢?

    public DataSource getDataSource(String ds) {
        if (StringUtils.isEmpty(ds)) {
            return this.determinePrimaryDataSource();
        } else if (!this.groupDataSources.isEmpty() && this.groupDataSources.containsKey(ds)) {
            log.debug("dynamic-datasource switch to the datasource named [{}]", ds);
            return ((GroupDataSource)this.groupDataSources.get(ds)).determineDataSource();
        } else if (this.dataSourceMap.containsKey(ds)) {
            log.debug("dynamic-datasource switch to the datasource named [{}]", ds);
            return (DataSource)this.dataSourceMap.get(ds);
        } else if (this.strict) {
            throw new CannotFindDataSourceException("dynamic-datasource could not find a datasource named" + ds);
        } else {
            return this.determinePrimaryDataSource();
        }
    }

        没错,正如大家所想,为空就会去取primary主数据源。因此建议大家无论如何都要配置primary属性,默认为“master”,如果你没有master数据源就会直接报错。这也就解释了为什么在不指定数据源时,事务开启会直接使用主数据源。

3.2 不加@Transactional

        不加事务时,执行到对应的SQL时会先获取对应数据源,如果加@DS就获取指定数据源、不加就使用主数据源。这也是最常用的用法,不加事务时想怎么切怎么切。

3.3 加@Transactional

        加上事务可就不一样了,在方法开启事务的一瞬间,就会走我们上面所说的那一套逻辑,而这种又分两种情况。

        一种是开启事务时就指定数据源。由于事务开启以前会先处理@DS拦截器逻辑,处理完数据源队列中就摆好了需要使用的数据源;此时事务拦截器去队列里取,此时队列不为空,直接就能取到所需的数据源,所以可以切换成功。

        另一种是开启事务后,在内部调用的Mapper再指定数据源。看这个执行顺序就很明显,在开启事务时,数据源队列是空的,一peek发现为空就只能取主数据源;你里面的Mapper哪怕切一晚上,也只是加入了队列里,但无人在意。

4 总结

        想要成功切换数据源一定要保证这几点:

  • 第一要保证事务开启和数据源指定同步进行,不然事务开启只能拿到主数据源;
  • 第二要保证创建一个新的事务,否则拦截器发现当前存在嵌套事务、又没有指定新建事务或不支持事务的传播属性,就会加入上一层的事务,一个事务内的数据库连接一定是同一个,更别说不同的数据源了,这是不可能的。

你可能感兴趣的:(数据库,mysql,java,spring,boot)