1 简介
由于最近在做一个动态策略相关的功能,这个“动态”体现在很多方面,其中一方面涉及今天要提到的问题——动态数据源切换,动态数据源基于苞米豆的Dynamic Datasource依赖。
大体情景如下:
该方法中会动态匹配很多SQL语句,每个SQL语句可能对应不同数据源,需要将所有SQL的结果汇总起来返回。且必须使用事务,因为查询数据量较大,涉及流式查询(下一篇会介绍),需要保持事务连接。
在第一条SQL语句成功执行后,第二条SQL语句会提示“Table 'datasource.xxx_table' doesn't exist”,显示在当前数据源没有找到对应的表。这问题就大了,我第二个SQL明明指定了另一个数据源嘛,怎么还去第一个里面找,这能找到才奇怪了。
问题大概就是这样,其实不用事务这个问题也就不存在了,流式查询必须使用事务先按下不表,这种解决不了就连根拔起的解决方案也不合理。下面就依据情景,挨个分析各种情景和最佳方案。
2 情景分析
为了方便测试,所有逻辑都在Controller中进行,注入了一个Service、一个Mapper,Mapper中准备了两个查询不同数据源的SQL语句。
2.1 调用Mapper
情景1
情景1也就是最原始的情况,Controller加上事务注解且不指定传播等级,分别调用Mapper中两个语句。
@Transactional(rollbackFor = Exception.class)
public void transaction1() {
testMapper.test1();
testMapper.test2();
}
运行后观察一下日志可以发现,第一个SQL正常执行并返回结果,第二个SQL就不对劲了,报出了表不存在的异常,说明数据源切换异常。
Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1ddb0b6d]
JDBC Connection [com.alibaba.druid.proxy.jdbc.ConnectionProxyImpl@650b82b4] will be managed by Spring
SELECT * FROM A
Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1ddb0b6d]
Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1ddb0b6d] from current transaction
SELECT * FROM B
Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1ddb0b6d]
Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1ddb0b6d]
Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1ddb0b6d]
情景2
情景2我们先去掉事务注解,让这两个SQL正常运行,看看日志情况。
@GetMapping("/test2")
public void transaction2() {
testMapper.test1();
testMapper.test2();
}
不出意料地执行成功了,重点是对比两段日志的不同之处。
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@14e094b2] was not registered for synchronization because synchronization is not active
JDBC Connection [com.alibaba.druid.proxy.jdbc.ConnectionProxyImpl@1271352d] will not be managed by Spring
SELECT * FROM A
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@14e094b2]
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@347202d3] was not registered for synchronization because synchronization is not active
JDBC Connection [com.alibaba.druid.proxy.jdbc.ConnectionProxyImpl@3659e864] will not be managed by Spring
SELECT * FROM B
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@347202d3]
- 情景1使用了事务,而情景2为非事务,因此1中注册的SqlSession为“transaction synchronization for SqlSession”,2只是普通的“non transactional SqlSession”。
- 重点来了,1中成功执行第一个SQL后,为第二个SQL注册SqlSession时,从日志和@后面的标识可以看出,他直接从当前事务中(也就是第一个SQL注册的事务)拉取,而不是像2一样再次创建个新的。
基于这两个场景对于是否使用事务产生的差异,我们可以得出以下几点结论:
- 事务启动后,如不特殊指定传播属性,整个方法体内SQL执行都会属于同一事务。
- 同一事务下会使用同一JDBC Connection,由于TransactionManager有一个ThreadLocal线程共享变量,里面就放了事务最开始创建的Connetion;因此就造成了,第二个语句仍然沿用第一个语句的连接,没有切换数据源。
问题就回到了事务的传播属性上,要让每个SQL语句执行使用不同的数据源,首先就要保证都使用不同的事务。那么下面的情景我们不再直接调用Mapper,而是调用Service,再在Service上加入事务注解。
2.2 调用Service + 基本事务
情景3
这次我们Controller和Service都不用事务,看看成功执行的结果。
@GetMapping("/test3")
public void transaction3() {
testService.transaction1();
testService.transaction2();
}
@Override
public void transaction1() {
testMapper.test1();
}
@Override
public void transaction2() {
testMapper.test2();
}
和情景2的结果一致,事务和JDBC Connection都是不同的,成功切换了。
情景4
情景4给Controller加上注解,Service不变。
@GetMapping("/test4")
@Transactional(rollbackFor = Exception.class)
public void transaction4() {
testService.transaction1();
testService.transaction2();
}
@Override
public void transaction1() {
testMapper.test1();
}
@Override
public void transaction2() {
testMapper.test2();
}
这次也在预期中,又报找不到表了;因为Controller开启事务后,第一个执行的Service创建的事务就会被指定为默认事务供后续所有SQL使用,与情景1一致。
情景5
情景5不给Controller加注解,给每个Service加上注解。
@GetMapping("/test7")
public void transaction7() {
testService.transaction1();
testService.transaction2();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void transaction1() {
testMapper.test1();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void transaction2() {
testMapper.test2();
}
还是找不到表,这种情况就是由于事务的默认传播属性“Propagation.REQUIRED”,即“有事务则支持当前事务,当前没有事务则新建事务”;Service1创建事务,Service2也创建事务,但由于是同一线程,ThreadLocal指向同一个连接,因此共用了JDBC Connection,导致数据源没有切换。
情景6(???)
Controller不加注解,给一个Service加上注解,一个Service不加。
@GetMapping("/test7")
public void transaction7() {
testService.transaction1();
testService.transaction2();
}
@Override
public void transaction1() {
testMapper.test1();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void transaction2() {
testMapper.test2();
}
这次执行成功了,因为Service1没有事务,Service2有事务,他们俩等于还是两个事务连接,没有共用事务的情况下连接也是自身的,不会产生问题。
PS:第二次执行又失败了,日志还是显示不同的事务和不同的JDBC连接,但就是失败了,我人晕了;第三次又成功了,好偶发的现象,我无法解释。
情景7
给Controller和所有Service都加上事务。
@GetMapping("/test7")
@Transactional(rollbackFor = Exception.class)
public void transaction7() {
testService.transaction1();
testService.transaction2();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void transaction1() {
testMapper.test1();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void transaction2() {
testMapper.test2();
}
找不到表,原因同情景5。
基于这几种情景,我们可以总结出以下几点:
- 不论是事务方法调用非事务、还是非事务调用事务,如果使用默认的事务传播属性,会使所有使用事务的方法都调用首个创建的事务。
- 我们的终极诉求就是每个事务使用自己的事务连接,不共用就不会产生共用JDBC连接的问题。
- 非事务方法调用事务方法,虽然两个事务方法会使用不同事务,但数据库连接会使用同一个;可能是因为ThreadLocal。
- 事务方法调用非事务方法,会使整个方法处于同一个事务,也就会使用同一个连接。
2.3 不同事务传播属性
这次我们分别针对Controller和Service,使用不同的传播属性,看看会产生什么效果。
情景8
Controller使用“Propagation.REQUIRES_NEW”,即创建新事务,并挂起当前事务;Service都使用默认“Propagation.REQUIRED”。
@GetMapping("/test7")
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void transaction7() {
testService.transaction1();
testService.transaction2();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void transaction1() {
testMapper.test1();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void transaction2() {
testMapper.test2();
}
仍然报错,传播属性加在了Controller上,因此在整个方法体中开启了事务。而Service1调用后使用当前事务,Service2也使用当前事务。
情景9
Controller使用默认传播属性,Service都使用“Propagation.REQUIRES_NEW”。
@GetMapping("/test7")
@Transactional(rollbackFor = Exception.class)
public void transaction7() {
testService.transaction1();
testService.transaction2();
}
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void transaction1() {
testMapper.test1();
}
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void transaction2() {
testMapper.test2();
}
这次运行成功了,因为这种传播属性,会挂起当前事务并创建一个新的事务。新的事务会重置事务的所有属性,以及Connection与当前线程的绑定,也就避免了情景5出现的情况,不同的事务、但是同一线程指向同一连接。
情景10
Controller使用默认传播属性,Service都使用“Propagation.NOT_SUPPORTED”,即不支持事务。
@GetMapping("/test7")
@Transactional(rollbackFor = Exception.class)
public void transaction7() {
testService.transaction1();
testService.transaction2();
}
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.NOT_SUPPORTED)
public void transaction1() {
testMapper.test1();
}
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.NOT_SUPPORTED)
public void transaction2() {
testMapper.test2();
}
这次运行也成功了,因为两个Service不支持事务,会挂起当前事务并以非事务方式执行,不共享事务,也不共享线程绑定的连接。
3 总结
- 多个方法使用同一事务,则一定使用同一JDBC Connection。
- 多个方法不使用同一事务,也有可能使用同一JDBC Connection,因为TransactionManager将Connection绑定到了当前线程上,导致你不想拿也得拿。
- 所有方法不使用事务,则一定不使用同一JDBC Connection,因为没有事务绑定连接到线程这一步。
- REQUIRED_NEW和NOT_SUPPORTED,都是将事务挂起,挂起会强行重置所有信息,包括绑定在ThreadLocal中的Connection,因此也可以使用。
- 情景6实在是让人困惑,一个使用一个不使用,拿到不同的事务和连接居然也会偶尔切换连接失败。不懂,希望有大佬解答。
最后再附上事务传播属性,基于理解和实践来记忆,并根据实际情况使用。
- REQUIRED(默认):支持当前事务,如果当前没有事务,就新建一个事务。
- SUPPORTS:支持当前事务,如果当前没有事务,就以非事务方式执行。
- MANDATORY:支持当前事务,如果当前没有事务,就抛出异常。
- REQUIRES_NEW:新建事务,如果当前存在事务,把当前事务挂起。
- NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
- NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
- NESTED:支持当前事务,如果当前事务存在,则执行一个嵌套事务,如果当前没有事务,就新建一个事务。
4 续
经过后来的几次尝试,又发现了几个问题:
- 如果主方法标记了使用事务,那么该方法会默认使用主数据源。比如Controller加上了事务注解,且使用默认的REQUIRED属性,第一个SQL语句如果是查询副数据源会直接报错,显示找不到该表。除非使用NOT_SUPPORTED属性,意为不支持事务,或者直接不加事务。
- 使用REQUIRES_NEW传播属性时,两个不同的事务仍然有可能获取到同一个数据库连接。这是因为事务1在执行完SQL以后,将事务和数据库连接一同关闭,事务2在获取时就有可能拿到事务1刚刚释放的连接,所以REQUIRES_NEW的使用还是需要考量的,能用NOT_SUPPORTED或者不加事务的来代替地方还是尽量避免使用。
- 同一个类中调用一定是同一个事务,可能会出现很多种形式的自调用,这点一定要注意,虽然很简单但就是有可能疏忽。比如Controller调用Service1又调用了Service2,但是Service2中又调用了Service1,尽管所有方法使用NOT_SUPPORTED,看日志显示Service2中调用的Service1直接获取了当前事务“Fetch Current SQL Session”,说明Service2中的Service1事务并没有生效。这种比较隐秘容易疏忽的地方还是要仔细检查的。