笔者之前就事务和动态数据源之间的问题,做过一些探讨和总结(详见事务内动态数据源切换失效及传播属性)。后来从源码层面分析有了一些收获,篇幅较长不便写在原文中,因此另起一篇作为补充。
注意,上篇的实验和结论依旧有效,本文旨在将理论和实践结合,给出更精确的解决方案及底层原理。
1 结论
先上结论,帮助同样被这个问题困扰的朋友们。
首先明确,事务控制得最小单位为“同一个数据库连接”,即想要正常控制事务回滚提交,那么整个过程只能基于一个Connection;而使用不同数据源就意味着一定对应不同的Connection,因此既要使用多数据源切换、又要使得所有数据库操作同步回滚提交,不引入分布式事务的前提下是做不到的(也许只是我做不到,如果有解决方案也可以分享)。
其次,在事务开启时如果没有指定数据源,事务会取默认数据源并使用他,即配置文件中的primary数据源。
基于上面的原则,结论如下:
- 需要切换数据源的地方一定要开启新的事务,例如Service1调用Service2,如两个方法要使用不同数据源,则必须要在Service2方法上指定能够挂起原事务、开启新事务的传播属性。如使用REQUIRES_NEW、NOT_SUPPORT,强行重置数据库连接。但这也就意味着两个事务是独立的,无法同步提交/回滚。
- 开启事务必须与指定数据源同步进行,即方法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<Object> 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,正对应着我们加入的两个拦截器:
到这一步,说明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<Deque<String>> LOOKUP_KEY_HOLDER = new NamedThreadLocal<Deque<String>>("dynamic-datasource") {
protected Deque<String> 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里放了一个Deque,Deque是一个双端队列,可以当作栈用也可以当作队列用。实例化时使用了ArrayDeque,因此在此处将其用作了先进先出的队列,其push方法底层调用了addFirst(),将数据放在了队列头。
放完后DS拦截器的活儿基本也干完了,于是继续向下调用拦截器,因为拦截器是链式调用嘛,在所有链路走完前肯定不能断,因此就又回到ReflectiveMethodInvocation的proceed方法,处理下一个拦截器。
2.3 Transactional拦截器
TransactionInterceptor的invoke()方法只做了一件事,将方法签名、被代理类、反射执行器的proceed方法引用,传递给真正开启并处理事务的TransactionAspectSupport。可能会有疑惑的是这个“proceed方法引用”,其实也很简单,上一步DS拦截器最后一步调用了proceed方法,来继续向下处理拦截器链;这里也是同理,将方法引用传进去,当前拦截器处理完毕后再回到ReflectiveMethodInvocation向下处理。
之后便进入TransactionAspectSupport的invokeWithinTransaction()方法处理事务,我们来依次看看他都做了些什么。
先看看最前面,在学习编程式事务时大家都清楚,我们要获取事务属性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()方法逻辑里就有体现。
此时我们的事务管理器还空空的,没办法,刚开启事务,啥也拿不到。所以下面的逻辑都不会走。
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;再回到事务管理器,将连接做成连接持有设置给事务管理器。到这里,我们的事务管理器终于不是光秃秃的了,他有了沉甸甸的数据库连接。
再回到一开始的抽象事务管理器类中,现在的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 总结
想要成功切换数据源一定要保证这几点:
- 第一要保证事务开启和数据源指定同步进行,不然事务开启只能拿到主数据源;
- 第二要保证创建一个新的事务,否则拦截器发现当前存在嵌套事务、又没有指定新建事务或不支持事务的传播属性,就会加入上一层的事务,一个事务内的数据库连接一定是同一个,更别说不同的数据源了,这是不可能的。