声明式事务注意事项

警告
本文最后更新于 2020-12-05,文中内容可能已过时。

spring源码系列文章,示例代码的中文注释,均是 copy 自 https://gitee.com/wlizhi/spring-framework

链接中源码是作者从 github 下载,并以自身理解对核心流程及主要节点做了详细的中文注释。


1 不正确使用声明式事务的危害

作为后端开发人员,spring声明式事务是我们经常使用的。一定要透彻理解事务的传播行为,否则开发出来的代码大概率是有问题的。影响性能是其一,还可能会出现一些极难排查的“脏数据”,甚至发生死锁、影响整个进程的服务。

影响性能的原因:事务的范围跨度过大,可能有一些点,不要求原子操作的,写到了声明了事务的方法中,那么在这个事务方法执行完之前,数据库相关的锁会迟迟无法释放(根据不同的隔离级别,数据库会有不同粒度的锁。);同时会延缓数据库连接的释放。

脏数据产生的原因:业务上应该在一个事务中执行的原子操作,由于传播行为设置问题,或者事务声明范围过小,导致其未能串联到一个事务中,就很有可能产生脏数据。事务隔离级别过小也可能导致这种情况,这是数据库的范畴,不做讨论。

总的来说,事务的跨度过大会影响性能。业务上的多项操作,要求原子性的执行的,没有全部包含到一个事务中,就会产生脏数据。

2 本类中自调用方法的问题

有这么一段伪代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class OrderService{

    @Transactional
    public void order1(){
        this.order2();
    }
    @Transactional
    public void order2(){
    }   
}

在 order1() 调用 order2() 的时候,order2() 的事务注解中的配置是不会生效的。由于声明式事务是使用代理实现的,只有通过代理对象调用方法时,相应的通知方法才会执行,声明式事务本身的事务开启和提交、回滚就是通过一个通知方法来实现的。

获取代理对象的方式有两种:

  • AopContext.currentProxy(),会返回正在执行的当前方法所在实例的代理对象。需要在代理配置中指定 exposeProxy = true
  • 注入自身实例,即在本类中注入自身实例作为一个成员变量,如果有本类中方法自调用的情况,使用这个变量来调用。就会走代理对象的通知方法。

注入自身实例伪代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class OrderService{
    @Autowired
    OrderService orderService;

    @Transactional
    public void order1(){
        orderService.order2();
    }
    
    @Transactional
    public void order2(){
    }   
}

之所以可以这么将自身注入为变量,在循环依赖问题的解决源码章节中有详细列举。

3 避免事务的嵌套

注意

事务方法中,尽量避免嵌套新启事务:

  • 一个方法调用开启了事务的另一个方法,如此嵌套很多事务,会延长外层事务的提交时间,也会延缓数据库连接的释放。因为在方法执行完之前,无法走到代理的后置通知,就无法触发提交或回滚代码,更无法释放数据库连接。
  • 另一个原因就是,开启事务是需要数据库连接的,连接在连接池中的数量有限。事务方法中嵌套新启事务,会使得这个方法的执行占用多个连接。 如果多个请求并发执行这种方法,或者事务嵌套个数本身就超出了连接池的数量(这种基本不存在)。为了打开多个事务,每个线程要获取多个数据库连接,就很可能在获取过程中发生死锁的情况。

传播行为 NESTED ,本质上和外围方法在一个事务中。只是在事务通知方法中,标识了 savePoint。它们使用的数据库连接,还是同一个。

下面代码展示由于事务方法中多层事务嵌套,导致的死锁问题。

首先,将连接池最大连接数设置为1。写一个开启事务的方法,让它调用另一个新启事务的方法。那么外层方法执行时,就需要开启两个事务,占用两个连接。

使用 声明式事务七种传播行为的表现形式案例 中的一块测试代码,唯一的改变是修改连接池大小。 数据库连接池设置如下:

1
2
3
4
5
6
7
8
9
public class GlobalTransactionConfig {
    @Bean
	public DruidDataSource getDruidDataSource(){
		DruidDataSource ds = new DruidDataSource();
		// 省略...
		ds.setMaxActive(1);
		return ds;
	}
}

模拟业务方法如下

方法1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class TransactionPropagationServiceImpl implements TransactionPropagationService {

	@Transactional(rollbackFor = Exception.class)
	@Override
	public void transactionExceptionRequiresNewRequiresNew() {
		userService.addRequiredNew(new PropagationUser("张三"));
		userService.addRequiredNew(new PropagationUser("李四"));
		throw new RuntimeException();
	}
}

方法2

1
2
3
4
5
6
7
8
public class PropagationUserServiceImpl implements PropagationUserService {

	@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
	@Override
	public void addRequiredNew(PropagationUser user) {
		userDao.insertSelective(user);
	}
}

在我们调用 transactionExceptionRequiresNewRequiresNew() 的时候就会发现,实际上代码执行到 userService.addRequiredNew(new PropagationUser("张三")) 时,就会发生死锁。

道理很简单,addRequiredNew() 在等待数据库连接池给它链接,而连接池已经没有连接了,唯一的一个连接被外层方法给占用了,所以它也在等待 transactionExceptionRequiresNewRequiresNew() 释放连接,这样就产生了死锁。

有限数量的资源,被多个线程并发的请求各自占有多个,就可能发生死锁。很有可能在占有过程中,资源已经用尽了。即使仅嵌套一个事务,假设总数100个数据库连接, 也是有可能发生死锁的(有100个线程各占了一个连接,这100个线程就会等待在获取第二个连接处)。

注:这和并发编程中死锁的概念是有区别的。并发编程中的死锁跟锁的获取顺序有关,获取多个锁对象,只要顺序一致就不会死锁。而这里占用数据库连接,仅是资源的争抢,资源耗尽就会阻塞在争抢的过程中,跟获取顺序无关。