事务内动态数据源切换失效及传播属性

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一样再次创建个新的。

        

        基于这两个场景对于是否使用事务产生的差异,我们可以得出以下几点结论:

  1. 事务启动后,如不特殊指定传播属性,整个方法体内SQL执行都会属于同一事务。
  2. 同一事务下会使用同一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。

 

        基于这几种情景,我们可以总结出以下几点:

  1. 不论是事务方法调用非事务、还是非事务调用事务,如果使用默认的事务传播属性,会使所有使用事务的方法都调用首个创建的事务。
  2. 我们的终极诉求就是每个事务使用自己的事务连接,不共用就不会产生共用JDBC连接的问题。
  3. 非事务方法调用事务方法,虽然两个事务方法会使用不同事务,但数据库连接会使用同一个;可能是因为ThreadLocal。
  4. 事务方法调用非事务方法,会使整个方法处于同一个事务,也就会使用同一个连接。

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 续

        经过后来的几次尝试,又发现了几个问题:

  1. 如果主方法标记了使用事务,那么该方法会默认使用主数据源。比如Controller加上了事务注解,且使用默认的REQUIRED属性,第一个SQL语句如果是查询副数据源会直接报错,显示找不到该表。除非使用NOT_SUPPORTED属性,意为不支持事务,或者直接不加事务。
  2. 使用REQUIRES_NEW传播属性时,两个不同的事务仍然有可能获取到同一个数据库连接。这是因为事务1在执行完SQL以后,将事务和数据库连接一同关闭,事务2在获取时就有可能拿到事务1刚刚释放的连接,所以REQUIRES_NEW的使用还是需要考量的,能用NOT_SUPPORTED或者不加事务的来代替地方还是尽量避免使用。
  3. 同一个类中调用一定是同一个事务,可能会出现很多种形式的自调用,这点一定要注意,虽然很简单但就是有可能疏忽。比如Controller调用Service1又调用了Service2,但是Service2中又调用了Service1,尽管所有方法使用NOT_SUPPORTED,看日志显示Service2中调用的Service1直接获取了当前事务“Fetch Current SQL Session”,说明Service2中的Service1事务并没有生效。这种比较隐秘容易疏忽的地方还是要仔细检查的。

 

  • 4
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值