SpringBoot 是怎么处理事务?事务注解怎么用?有哪些需要注意的问题?我们自己动手一步一步的从实验中学习。
概览
众所周知 SpringBoot 支持声明式事务和编程式事务,本文讨论的是基于声明式事务,或者叫注解式事务。
为了方便同步,文章中的代码用到了以下框架和类库:
- spring-boot-starter
- spring-boot-starter-web
- mybatis-plus-boot-starter
- mysql-connector-java
首先我们复习下前置知识点。
数据库事务有 ACID 四大原则:
- 原子性(Atomicity)指事务包含的所有操作要么全部成功,要么全部失败回滚,这和前面两篇博客介绍事务的功能是一样的概念,因此事务的操作如果成功就必须要完全应用到数据库,如果操作失败则不能对数据库有任何影响。
- 一致性(Consistency)指事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态。
- 隔离性(Isolation)隔离性是当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。即要达到这么一种效果:对于任意两个并发的事务 T1 和 T2,在事务 T1 看来,T2 要么在 T1 开始之前就已经结束,要么在 T1 结束之后才开始,这样每个事务都感觉不到有其他事务在并发地执行。
- 持久性(Durability)指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作。
还有数据库经常出现的几种错误情况:
脏读:是指在一个事务处理过程里读取了另一个未提交的事务中的数据。比如一个人的名字叫张三,事务 A 这时候修改为了李四,但是没有提交事务,事务 B 此时读取姓名,如果读到了张三,就产生了脏读,一旦事务 A 回滚了,B 读到的李四就是脏数据。
不可重复读:是指在对于数据库中的某个数据,一个事务范围内多次查询却返回了不同的数据值,这是由于在查询间隔,被另一个事务修改并提交了。不可重复读和脏读的区别是,脏读是某一事务读取了另一个事务未提交的脏数据,而不可重复读则是读取了前一事务提交的数据。比如事务 A,读取
id=1
的人的名字,得到是张三,这时候事务 B 把id=1
的人的名字改为了李四,并且提交了事务。这时候事务 A 再查询一遍id=1
的人的名字,发现变成了李四。幻读(虚读):幻读是事务非独立执行时发生的一种现象,是指操作记录数的变化。幻读的定义其实经常引起争议。
高性能 mysql 中这么定义幻读:
事务A读取某个范围的记录,事务B在该范围插入了新的记录,事务A再次读取该范围的记录,会产生幻行。
InnoDB 使用 mvcc 解决了幻读的问题。
在这种意义下,InnoDB 中的 Repeatable Read 隔离级别是直接解决了幻读的,但是还有如下情况:
事务 A 对先查询一条不存在的数据,结果显示为空,然后插入该数据,在事务 A 插入之前,事务 B 率先插入了数据,导致事务 A 插入失败,就现了幻觉。也有人将这种情况称之为幻读,但是我觉得高性能 MySQL 中的定义更准确,另外后面这种情况可以用
for update
解决。
然后就是事务的隔离级别,MySQL 中的隔离级别和 SpringBoot 是一样的(或者应该反过来说),为了解决以上三种问题,有四种不同的隔离级别,具体作用如图:
Transactional 注解
Spring 事务最简单方便的使用方式是使用Transactional
注解,可以被应用于接口定义和接口方法、类定义和类的 public 方法上。但是不推荐用在接口上面,因为一旦标注在 Interface 上并且配置了Spring AOP 使用 CGLib 动态代理,将会导致Transactional
注解失效。
Transactional 注解的原理是这样:
在应用系统调用声明@Transactional
的目标方法时,Spring Framework 默认使用 AOP 代理,在代码运行时生成一个代理对象,根据@Transactional
的属性配置信息,这个代理对象决定该声明@Transactional
的目标方法是否由拦截器 TransactionInterceptor
来使用拦截,在TransactionInterceptor
拦截时,会在在目标方法开始执行之前创建并加入事务,并执行目标方法的逻辑, 最后根据执行情况是否出现异常,利用抽象事务管理器AbstractPlatformTransactionManager
操作数据源 DataSource 提交或回滚事务。
注解的所有属性如下:
第一个属性value
的作用是:当在配置文件中有多个TransactionManager
,可以用该属性指定选择哪个事务管理器。
事务的传播和隔离级别下面详细叙述。
readOnly
和timeout
都很好理解。
最后面的四个是指定事务回滚规则,事务回滚规则定义了哪些异常会导致事务回滚而哪些不会。默认情况下,只有未检查异常(RuntimeException
和Error
类型的异常)会导致事务回滚。
事务的隔离级别
Spring 事务支持四种隔离级别:
ISOLATION_DEFAULT: 这是一个
PlatfromTransactionManager
默认的隔离级别,使用数据库默认的事务隔离级别。另外四个与 JDBC 的隔离级别相对应。MySQL 默认的事务处理级别是REPEATABLE-READ
。ISOLATION_READ_UNCOMMITTED: 这是事务最低的隔离级别,它充许令外一个事务可以看到这个事务未提交的数据。这种隔离级别会产生脏读,不可重复读和幻像读。
ISOLATION_READ_COMMITTED:保证一个事务修改的数据提交后才能被另外一个事务读取。另外一个事务不能读取该事务未提交的数据。这种隔离级别有可能产生不可重复读和幻读。
ISOLATION_REPEATABLE_READ: 这种事务隔离级别可以防止脏读,不可重复读。但是可能出现幻像读。
ISOLATION_SERIALIZABLE: 这是花费最高代价但是最可靠的事务隔离级别。事务被处理为顺序执行。除了防止脏读,不可重复读外,还避免了幻像读。
好,原理看完了,开始实验。SpringBoot 的项目搭建就不赘述了,我们先准备一个表:
图省事只有三个字段,自增主键id
和姓名年龄。
我们先看默认的情况,添加一个开启事务的插入操作:
1 | () |
2 | public void testInsert() { |
3 | TestTable data = new TestTable(); |
4 | data.setName("张三"); |
5 | data.setAge(20); |
6 | int result = testTableMapper.insert(data); |
7 | if (result == 1) |
8 | logger.info("添加数据{}成功", data); |
9 | throw new RuntimeException("RuntimeException"); |
10 | } |
可以看到日志提示添加成功,然后去数据库里没有这条,因为抛出异常回滚了。
如果在这个事务已经添加,但是没提交的情况下,另一个会话去读的话会发生什么?
我们添加一个读去全部数据的方法:
1 | public void testSelect() { |
2 | List<TestTable> list = testTableMapper.selectList(Wrappers.emptyWrapper()); |
3 | logger.info("读取数据:{}", list.toString()); |
4 | } |
然后在上面那个插入的方法中添加线程休眠,模拟事务未提交的情况。
1 | () |
2 | public void testInsert() { |
3 | TestTable data = new TestTable(); |
4 | data.setName("张三"); |
5 | data.setAge(20); |
6 | int result = testTableMapper.insert(data); |
7 | if (result == 1) |
8 | logger.info("添加数据{}成功", data); |
9 | try { |
10 | Thread.sleep(10000000); |
11 | } catch (InterruptedException e) { |
12 | e.printStackTrace(); |
13 | } |
14 | throw new RuntimeException("RuntimeException"); |
15 | } |
这时候你会发现,不光第一个插入的方法阻塞了,查询的方法也阻塞了。这是因为虽然你没开启事务,但是数据库实际上用了默认的隔离级别。
我们切换到最低的隔离级别:READ_UNCOMMITTED
。就是说一个事务可以看到另一个事务未提交的数据。
把查询方法也加上事务,并且切换隔离级别:
1 | (isolation = Isolation.READ_UNCOMMITTED) |
2 | public void testSelect() { |
3 | List<TestTable> list = testTableMapper.selectList(Wrappers.emptyWrapper()); |
4 | logger.info("读取数据:{}", list.toString()); |
5 | } |
可以看到,这次就读到了插入事务里未提交的张三。READ_UNCOMMITTED
作为级别最低的隔离级别,一般很少会用。
然后我们来测试READ_COMMITTED
,还是上面完全一样的代码,把查询方法的隔离级别改成READ_COMMITTED
,就会发现张三看不到了。
1 | (isolation = Isolation.READ_COMMITTED) |
2 | public void testSelect() { |
3 | List<TestTable> list = testTableMapper.selectList(Wrappers.emptyWrapper()); |
4 | logger.info("读取数据:{}", list.toString()); |
5 | } |
然后我们插入一条张三的数据:
写一个更新的方法,把张三改名为李四:
1 |
|
2 | public void testUpdate() { |
3 | int result = testTableMapper.update(null, Wrappers.lambdaUpdate(TestTable.class) |
4 | .set(TestTable::getName,"李四").eq(TestTable::getName, "张三")); |
5 | if (result == 1) |
6 | logger.info("更新数据成功"); |
7 | } |
我们把查询方法查询两次,分别打印查询到的数据,中间休眠五秒,休眠期间执行更新方法,看看两次有什么区别(注意,要把 mybatis-plus 的一个配置local-cache-scope
改成statement
,不然通过 id 查询数据的话,第二次会直接走缓存,没有实际去数据库查。):
1 | (isolation = Isolation.READ_COMMITTED) |
2 | public void testSelect() { |
3 | TestTable data = testTableMapper.selectById(1); |
4 | logger.info("读取数据:{}", data); |
5 | try { |
6 | Thread.sleep(3000); |
7 | } catch (InterruptedException e) { |
8 | e.printStackTrace(); |
9 | } |
10 | data = testTableMapper.selectById(1); |
11 | logger.info("读取数据:{}", data); |
12 | } |
从日志里可以看出,查询方法第一次查询的时候,名字还是张三,执行更新方法之后,就变成了李四。也就是说,在事务还没提交的时候,受到了其他事务的影响。
当我们把查询方法的隔离级别改成REPEATABLE_READ
的时候,这个问题就不复存在了,你会发现两次查询都是张三。
由于实质上的幻读已经被 InnoDB 干掉了,我们就先不看怎么用SERIALIZABLE
级别来解决幻读了,而是直接看SERIALIZABLE
级别能做什么吧。
这个代码比较简单
1 | (isolation = Isolation.SERIALIZABLE) |
2 | public void testSelect() { |
3 | logger.info("进入testSelect方法"); |
4 | List<TestTable> list = testTableMapper.selectList(Wrappers.emptyWrapper()); |
5 | logger.info("读取数据:{}", list.toString()); |
6 | try { |
7 | Thread.sleep(5000); |
8 | } catch (InterruptedException e) { |
9 | e.printStackTrace(); |
10 | } |
11 | } |
连续执行两次这个方法,你就会发现在第一次的线程休眠结束之前,第二次会阻塞。SERIALIZABLE
的作用就是把事务串行化,所有该类型的事务都会排队一个一个的执行。同样的,如果另一个方法也是SERIALIZABLE
级别,他和testSelect
方法也是只能串行执行。比如下面这种,我们增加一个查询方法:
1 | (isolation = Isolation.SERIALIZABLE) |
2 | public void testSelect2() { |
3 | List<TestTable> list = testTableMapper.selectList(Wrappers.emptyWrapper()); |
4 | logger.info("读取数据:{}", list.toString()); |
5 | } |
同样的,testSelect2
也会阻塞等待前一个事务的执行完毕。但是注意当我们把testSelect2
的隔离级别改回默认,即REPEATABLE_READ
,就不会阻塞等待了。
事务的传播级别
首先要清楚什么是事务的传播级别:用来描述由某一个事务传播行为修饰的方法被嵌套进另一个方法的时事务如何传播。
1 | public void methodA(){ |
2 | methodB(); |
3 | //doSomething |
4 | } |
5 | |
6 | (Propagation=XXX) |
7 | public void methodB(){ |
8 | //doSomething |
9 | } |
代码中methodA()
方法嵌套调用了methodB()
方法,methodB()
的事务传播行为由@Transaction(Propagation=XXX)
设置决定。这里需要注意的是methodA()
并没有开启事务,某一个事务传播行为修饰的方法并不是必须要在开启事务的外围方法中调用。
Spring 中有七大传播级别:
- PROPAGATION_REQUIRED:如果当前没有事务,就新建一个事务,如果已经存在一个事务中,加入到这个事务中。如果被调用端发生异常,那么调用端和被调用端事务都将回滚。这是最常见的选择。
- PROPAGATION_SUPPORTS:支持当前事务,如果当前没有事务,就以非事务的方式执行。
- PROPAGATION_MANDATORY:使用当前的事务,如果当前没有事务,就抛出异常。
- PROPAGATION_REQUIRES_NEW:新建事务,如果外部存在事务,把外部事务挂起。这个内部的事务将被完全提交或回滚而不依赖于外部事务,它拥有自己的隔离范围,自己的锁,等等。
- PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
- PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,就抛出异常。
- PROPAGATION_NESTED:如果当前方法正有一个事务在运行中,则该方法应该运行在一个嵌套事务中,被嵌套的事务可以独立于被封装的事务中进行提交或者回滚。
好,我们开始一个一个的测试,加深理解。
注意,由于内部方法抛出运行时异常,会在外层方法里面继续抛出,导致预期外的结果,所以我们不能用抛出异常的方式进行使用。所以下面的测试都是手动回滚。
首先新建两个ServiceA
和ServiceB
,A 中注入 B,当然还有两个mapper
,然后还是用之前的数据库表,先把表清空。两个类文件如下:
1 |
|
2 | public class ServiceA { |
3 | |
4 | TestTableMapper testTableMapper; |
5 | |
6 | ServiceB serviceB; |
7 | } |
8 |
|
9 | public class ServiceB { |
10 | |
11 | TestTableMapper testTableMapper; |
12 | } |
为什么需要两个Service
而不是在同一个里面进行方法调用,这个涉及到Spring
的依赖注入机制,这里先不展开。
我们用插入方法来测试,先插入一条记录,然后抛出异常,通过看数据库中是否真正执行了插入,来判断事务的执行状态。当然也可以打开日志的debug
级别,直接看数据库的执行日志。
PROPAGATION_REQUIRED
如果没有事务,就新建事务,如果已经有了就加入。
我们先测试 A 没有事务,B 有事务的情况:
1 | //这是ServiceA |
2 | public void test() { |
3 | serviceB.insert(); |
4 | } |
5 | |
6 | //这是ServiceB |
7 | (propagation = Propagation.REQUIRED) |
8 | public void insert() { |
9 | TestTable someOne = new TestTable(); |
10 | someOne.setId(1); |
11 | someOne.setName("法外狂徒张三"); |
12 | someOne.setAge(19); |
13 | testTableMapper.insert(someOne); |
14 | TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); |
15 | } |
可以看出数据没有实际插入进去,事务进行了回滚。然后我们把ServiceA
中的方法加上事务。
1 |
|
2 | public void test() { |
3 | serviceB.insert(); |
4 | } |
一样的结果。
如果调用端出错了呢?我们把ServiceB
中的回滚去掉,让ServiceA
中回滚试试:
1 |
|
2 | public void test() { |
3 | serviceB.insert(); |
4 | TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); |
5 | } |
可以看出同样发生了回滚,两个事务其实合为一个了。
PROPAGATION_SUPPORTS
如果不存在外层事务,就不开启事务,如果有就加入外部事务运行。
我们去掉ServiceB
的事务,A 的还带着,在 A 中回滚。
1 |
|
2 | public void test() {//ServiceA |
3 | serviceB.insert(); |
4 | throw new RuntimeException("出错啦"); |
5 | } |
6 | |
7 | (propagation = Propagation.SUPPORTS) |
8 | public void insert() {//ServiceB |
9 | TestTable someOne = new TestTable(); |
10 | someOne.setId(1); |
11 | someOne.setName("法外狂徒张三"); |
12 | someOne.setAge(19); |
13 | testTableMapper.insert(someOne); |
14 | } |
可以看出还是发生了回滚,证明 B 虽然自身没有异常,但是由于加入了 A 的事务,所以一起跟着回滚了。
我们把 A 的事务也去掉。
1 | public void test() {//ServiceA |
2 | serviceB.insert(); |
3 | throw new RuntimeException("出错啦"); |
4 | } |
可以看到数据成功插入了,没有发生回滚。
PROPAGATION_MANDATORY
必须要有事务,没有事务就抛异常。
我们还是去掉 A 的事务和异常抛出,把 B 的传播级别改成PROPAGATION_MANDATORY
。
1 | public void test() {//ServiceA |
2 | serviceB.insert(); |
3 | } |
4 | |
5 | (propagation = Propagation.MANDATORY) |
6 | public void insert() {//ServiceB |
7 | TestTable someOne = new TestTable(); |
8 | someOne.setId(1); |
9 | someOne.setName("法外狂徒张三"); |
10 | someOne.setAge(19); |
11 | testTableMapper.insert(someOne); |
12 | } |
可以看到直接抛出了异常
1 | org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory' |
我们给ServiceA
加上事务:
1 |
|
2 | public void test() {//ServiceA |
3 | serviceB.insert(); |
4 | } |
整个事务顺利完成,没有问题。
PROPAGATION_REQUIRES_NEW
无论当前事务上下文中有没有事务,都会开启一个新的事务,如果有了外部事务就挂起,内部的事务将被完全提交或回滚而不依赖于外部事务。
我们先写一个外部的事务 ServiceA,调用了内部事务 ServiceB,让事务 A 进行回滚:
1 |
|
2 | public void test() {//ServiceA 中的方法,外部事务 |
3 | System.out.println("执行ServiceA"); |
4 | serviceB.insert(); |
5 | TestTable someOne = new TestTable(); |
6 | someOne.setId(2); |
7 | someOne.setName("法外狂徒李四"); |
8 | someOne.setAge(28); |
9 | testTableMapper.insert(someOne); |
10 | TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); |
11 | } |
12 | |
13 | (propagation = Propagation.REQUIRES_NEW) |
14 | public void insert() {//ServiceB 的方法,内部事务 |
15 | System.out.println("执行ServiceB"); |
16 | TestTable someOne = new TestTable(); |
17 | someOne.setId(1); |
18 | someOne.setName("法外狂徒张三"); |
19 | someOne.setAge(18); |
20 | testTableMapper.insert(someOne); |
21 | } |
执行可以看到,外部事务 ServiceA 插入的内容被回滚了,内部事务 ServiceB 的动作执行不受影响。
类似的,如果我们反过来,B 中回滚而 A 不回滚,B 也不会影响到 A,二者相互独立。
PROPAGATION_NOT_SUPPORTED
这个传播级别下,内部事务总是以非事务的形式运行,不管外面有没有事务,自身都是没有事务。
我们还是做测试,先让内部事务抛出异常:
1 | (propagation = Propagation.NOT_SUPPORTED) |
2 | public void insert() {//ServiceB 内部事务 |
3 | System.out.println("执行ServiceB"); |
4 | TestTable someOne = new TestTable(); |
5 | someOne.setId(1); |
6 | someOne.setName("法外狂徒张三"); |
7 | someOne.setAge(18); |
8 | testTableMapper.insert(someOne); |
9 | throw new RuntimeException("RuntimeException"); |
10 | } |
11 | |
12 |
|
13 | public void test() {//ServiceA 外部事务 |
14 | System.out.println("执行ServiceA"); |
15 | TestTable someOne = new TestTable(); |
16 | someOne.setId(2); |
17 | someOne.setName("法外狂徒李四"); |
18 | someOne.setAge(28); |
19 | testTableMapper.insert(someOne); |
20 | serviceB.insert(); |
21 | } |
可以看出内部事务由于没有事务,所以虽然抛出异常但是数据库操作正常结束,没有回滚。当然外面的事务收到了影响,因为 catch 到了内部事务的异常。
类似的,如果外面事务抛出异常,内部事务也不会回滚。
PROPAGATION_NEVER
这个级别更简单,外部不能有事务,有就抛异常。示例都不用写了,非常好理解,抛出的异常如下:
1 | org.springframework.transaction.IllegalTransactionStateException: Existing transaction found for transaction marked with propagation 'never' |
PROPAGATION_NESTED
这个级别很有意思,如果外面没有事务就创建事务,如果有的话就嵌套进去。这个嵌套的意思是指的“暂存点”,如果子事务发生异常,会直接回滚到这个暂存点,而不会导致整体事务的回滚。
废话少说看测试:
1 | (propagation = Propagation.NESTED) |
2 | public void insert() {//ServiceB,内部事务,插入然后抛出异常 |
3 | System.out.println("执行ServiceB"); |
4 | TestTable someOne = new TestTable(); |
5 | someOne.setId(1); |
6 | someOne.setName("法外狂徒张三"); |
7 | someOne.setAge(18); |
8 | testTableMapper.insert(someOne); |
9 | TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); |
10 | } |
11 | |
12 |
|
13 | public void test() {//ServiceA,外部事务,在调用 B 的前后分别插入。 |
14 | System.out.println("执行ServiceA"); |
15 | TestTable someOne = new TestTable(); |
16 | someOne.setId(2); |
17 | someOne.setName("法外狂徒李四"); |
18 | someOne.setAge(28); |
19 | testTableMapper.insert(someOne); |
20 | serviceB.insert(); |
21 | someOne.setId(3); |
22 | someOne.setName("王五"); |
23 | someOne.setAge(66); |
24 | testTableMapper.insert(someOne); |
25 | } |
这个例子里面,内部事务回滚了,但是外部没有回滚。但是外部事务回滚,内部事务也会跟着回滚。
看出来跟REQUIRED
,REQUIRES_NEW
之间的不同了吗?区别如下:
- REQUIRED:内外是一个整体,无论内部还是外部的回滚,都会导致二者全回滚。
- REQUIRES_NEW:内外相互独立,互相完全不影响。
- NESTED:内部加入外部,但是外部不受内部影响。内部回滚的话外部正常,外部回滚的话内部会跟着回滚。
注意事项
- 在需要事务管理的地方加
@Transactional
注解。@Transactional
注解可以被应用于接口定义和接口方法、类定义和类的public
方法上。 @Transactional
注解只能应用到public
可见度的方法上。 如果你在protected
、private
或者package-visible
的方法上使用@Transactional
注解,它也不会报错, 但是这个被注解的方法将不会展示已配置的事务设置。@Transactional
的事务开启 ,或者是基于接口的 或者是基于类的代理被创建。所以在同一个类中一个方法调用另一个方法有事务的方法,事务是不会起作用的。- 在接口上使用
@Transactional
注解,只能当你设置了基于接口的代理时它才生效。
面试常见问题
//TODO 后续补充