SpringBoot 事务
1、Spring 事务概述
本地事务(区别于分布式事务)也叫数据库事务,MySQL的InnoDB存储引擎就已经支持了事务,Spring中的事务是在底层数据库事务的基础上进一步封装,可以在不同的项目、不同的操作中对事务的传播行为和隔离级别做细粒度的控制。
1.1 Spring事务管理的两种方式
Spring支持两种事务管理方式:编程式事务和声明式事务。
(1)编程式事务
对于编程式事务而言,Spring推荐使用TransactionTemplate,在需要开启事务的代码处,通过类似数据库开启事务的BEGIN、COMMIT这种需要进行编码开启事务和提交事务或者回滚的,即需要在代码中显式地进行编码从而实现事务的效果。
实际开发中我们编程式事务用的很少,遇到需要开启事务的地方我们更倾向于选择声明式事务(@Transcation注解)的方式,下面会提到。
(2)声明式事务
Spring的声明式事务是基于AOP,本质是对需要开启事务的地方前后通过aop织入的方式进行拦截,即在执行方法前创建或者加入一个事务,执行完方法后根据执行情况提交或者回滚事务。在实际开发过程中,声明式事务多选择通过注解 @Transcation
修饰要开启事务的方法。
相比编程式事务,声明式事务不需要对业务代码进行事务相关编码这种侵入式的开发,仅需要使用注解即可,使业务代码不受污染。声明式事务相比编程式事务也有不足,那就是声明式事务的粒度只能在方法级别上,而编程式事务可以作用到代码块的级别上。
1.2 事务的ACID特性
再复习一下事务的ACID特性,这一点在Mysql学习中已经介绍了。
原子性(Atomicity)
:事务是一个原子操作,由一系列动作组成。事务的原子性确保动作要么全部完成,要么完全不起作用。一致性(Consistency):
一旦事务完成(不管成功还是失败),系统必须确保它所建模的业务处于一致的状态,而不会是部分完成部分失败。在现实中的数据不应该被破坏。隔离性(Isolation)
:可能有许多事务会同时处理相同的数据,因此每个事务都应该与其他事务隔离开来,防止数据损坏。换句话说就是事务并发的问题,下沉到数据库就是同一时刻有多个线程对同一张表或者同一行数据进行写操作,数据库中需要加行锁或者表锁或者乐观锁保证事务的隔离性,在Spring中可以通过对@Transcation注解传入事务隔离类别参数指定;持久性(Durability)
:一旦事务提交,那么它对数据库中数据状态的变更就会永久保存到数据库中。即使发生系统崩溃或机器宕机等故障,只要数据库能够重新启动,那么一定能够将其恢复到事务成功结束时的状态。
关于事务一致性和原子性的区分,可以这样理解:C是我们最终要达到的目的,AID是达到目的采取的手段。
1.3 事务并发的问题
在介绍mysql数据库事务的时候也详细介绍了这部分,可以想象到的是同一时刻可能有多个事务同时对数据库进行操作(尤其是写操作),如果这些数据作用在同一张表甚至同一行数据,会带来如下事务并发问题(从问题严重性从大到小排序):
脏写(Dirty Write):
事务A先更新某行数据,事务B后更新该行数据,此时如果事务A回滚了,那么该行数据的值就会回滚成功事务A更新前的数据值,事务B会发现自己更新的数据不见了,这就是脏写;脏读(Dirty Read):
脏读就是一个事务读取了另一个事务修改后为提交的数据,此时如果修改的事务回滚了,那么第一个事务相当于读到了一个不存在的数据,这就是脏读;不可重复度(Non-Repeatable Read):
事务A对一条记录不断的查询,事务B对这条记录不断的UPDATE,导致每次事务A查询时得到的记录都是不一样的,就造成了不可重复读;幻读(Phantom):
事务A对一条记录不断的查询,事务B同时向这张表里INSERT符合事务A查询条件的记录,导致每次事务A按照相同的条件查询时得到的记录数目都是不一样的,就造成了幻读。幻读与不可重复度的区别是:不可重复读的结果强调每次读取时的记录内容不同,幻读的结果强调每次读取记录时都读取到了之前没读取到的记录,即记录数在递增。
1.4 事务隔离级别
事务的隔离级别定义一个事务可以接受其他事务并发活动影响的程度,与Java里锁的机制类似,当有多个事务同时访问(尤其是并发写的情况)一份记录时,应该通过某些机制让这些事务串行执行以此保证事务的隔离性,但服务器又想同一时刻尽可能多地处理访问请求,因此数据库设立了不同的隔离级别,牺牲部分隔离性而获得性能上的提升。
sql标准中有四种隔离级别,如下:
- READ UNCOMMITTED :未提交读;
- READ COMMITTED: 已提交读;
- REPEATABLE READ :可重复读;
- SERIALIZABLE: 可串行化。
前面提到过为了追求性能,SQL会牺牲一部分并发安全性,SQL标准中规定,针对不同的隔离级别,可以允许不同程度严重程度的并发事务问题发生,具体如下:
隔离级别 | 脏读 | 不可重复度 | 幻读 |
---|---|---|---|
READ UNCOMMITTED | 允许 | 允许 | 允许 |
READ COMMITTED | 不允许 | 允许 | 允许 |
REPEATABLE READ | 不允许 | 不允许 | 允许 |
SERIALIZABLE | 不允许 | 不允许 | 不允许 |
说明:
- 由于脏写问题太严重,上面所有隔离级别都不会允许脏写发生;
- MySQL的InnoDB引擎默认的隔离级别是REPEATABLE READ。
2、@Transcation注解
需要注意的是,@Transcation注解保证的事务是数据库的操作,要与分布式事务(比如Seata)保证的rpc调用的事务区分开。即@Transcation注解作用的方法,方法体是插入表数据、删除表数据这些数据库表的操作,而分布式事务Seata保证的是调用若干服务的接口,结果的事务性。
2.1 @Transcation注解的使用
@Transactional 可以作用在接口、类、类方法:
- 作用于类:当把@Transactional 注解放在类上时,表示所有该类的public方法都配置相同的事务属性信息;
- 作用于方法:当类配置了@Transactional,方法也配置了@Transactional,方法的事务会覆盖类的事务配置信息;
- 作用于接口:不推荐这种使用方法,因为一旦标注在Interface上并且配置了Spring AOP 使用CGLib动态代理,将会导致@Transactional注解失效。
至于@Transactional注解的propagation属性和isolation 属性以及其他属性放在后面介绍。
2.2 @Transcation注解使用Demo
2.2.1 需求澄清
写一个创建用户接口,做两件事情:
- 插入用户信息表;
- 插入操作日志记录表。
开启事务保证上面两个操作是原子性的。
2.2.2 代码
完整代码工程在:https://gitee.com/woshuangguoyang/spring-boot-study
这里说明一下核心代码部分:
UserServiceImpl:
package com.jerry.springbootstudy.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.jerry.springbootstudy.mapper.UserMapper;
import com.jerry.springbootstudy.model.dto.UserDto;
import com.jerry.springbootstudy.model.po.OperLogPo;
import com.jerry.springbootstudy.model.po.UserPo;
import com.jerry.springbootstudy.service.OperLogService;
import com.jerry.springbootstudy.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.UUID;
/**
* @Description: 用户实现类
* @Author: Jerry
* @Date: 2021-05-03 00:23
*/
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private OperLogService operLogService;
@Override
@Transactional(rollbackFor = Exception.class)
public void createUser(UserDto userDto) {
// 插入用户表记录
UserPo userPo = new UserPo();
userPo.setId(userDto.getId());
userPo.setName(userDto.getName());
userPo.setCreateTime(new Date());
userPo.setUpdateTime(new Date());
userMapper.createUser(userPo);
// 引入异常
int a = 10 / 0;
// 插入操作日志表记录
OperLogPo operLogPo = new OperLogPo();
operLogPo.setId(UUID.randomUUID().toString());
operLogPo.setAction("create");
JSONObject operObj = new JSONObject();
operObj.put("className", userDto.getClass());
operObj.put("objectEntity", userDto.toString());
operLogPo.setObj(JSON.toJSONString(operObj));
operLogPo.setCreateTime(new Date());
operLogPo.setUpdateTime(new Date());
operLogService.addOperLog(operLogPo);
}
}
测试结果:
- 当createUser方法没有@Transactional注解修饰时,调用createUser接口,user表中插入了数据,而operation_log表里没有插入数据;
- 当createUser方法有@Transactional注解修饰时,调用createUser接口,user表和operation_log表均没有插入数据。
测试结果中的第二条说明:@Transactional注解修饰的方法,保证了操作的原子性的,即使抛出异常前执行插入用户表操作是没问题的,最终结果也没有真正执行插入用户表,插入用户表和插入操作日志表这两个操作实现了原子性,即createUser方法的开启事务声明生效。
2.3 @Transcation注解失效的6种场景
在实际编码中,有时我们使用@Transactional注解并没有生效,总结一下可能是以下原因导致:
- @Transactional注解作用在了非public修饰的方法上;
- @Transactional 注解属性 propagation 设置错误,错误地设置成了如下三种之一:TransactionDefinition.PROPAGATION_SUPPORTS、TransactionDefinition.PROPAGATION_NOT_SUPPORTED 和T ransactionDefinition.PROPAGATION_NEVER;
- @Transactional 注解属性 rollbackFor 设置错误;
- 同一个类中没有被@Transactional修饰的方法调用了该类中另一个被@Transactional修饰的方法,导致@Transactional失效;
- 异常被你的 catch“吃了”导致@Transactional失效;
- 数据库引擎不支持事务。
其中1、4、5是比较容易出错的场景。
2.3.1 @Transactional注解作用在了非public修饰的方法上
如果@Transactional注解应用在非public 修饰的方法上,Transactional将会失效;protected、private 修饰的方法上使用 @Transactional 注解,虽然事务无效,但不会有任何报错。
之所以会失效是因为在Spring AOP 代理时,如上图所示 TransactionInterceptor (事务拦截器)在目标方法执行前后进行拦截,DynamicAdvisedInterceptor(CglibAopProxy 的内部类)的 intercept 方法或 JdkDynamicAopProxy 的 invoke 方法会间接调用 AbstractFallbackTransactionAttributeSource的computeTransactionAttribute 方法,获取Transactional 注解的事务配置信息。
protected TransactionAttribute computeTransactionAttribute(Method method,
Class<?> targetClass) {
// Don't allow no-public methods as required.
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null;
}
此方法会检查目标方法的修饰符是否为 public,不是 public则不会获取@Transactional 的属性配置信息。
网上看的博客直接搬过来的,看到源码这些个方法头大.....
2.3.2 @Transactional 注解属性 propagation 设置错误
propagation属性是Spring事务传播机制,支持7种事务传播设置,其中下面3种事务传播机制的设置,会使@Transactional失效:
TransactionDefinition.PROPAGATION_SUPPORTS
:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行;TransactionDefinition.PROPAGATION_NOT_SUPPORTED
:以非事务方式运行,如果当前存在事务,则把当前事务挂起;TransactionDefinition.PROPAGATION_NEVER
:以非事务方式运行,如果当前存在事务,则抛出异常。
其实这种propagation属性设置错误导致@Transactional失效的概率也比较低,因为开发过程中我们基本会将propagation属性缺省,此时@Transactional的事务默认是开启的TransactionDefinition.PROPAGATION_REQUIRED
,也是支持事务的。
2.3.3 @Transactional 注解属性 rollbackFor 设置错误
@Transactional注解中的rollbackFor
参数可以指定能够触发事务回滚的异常类型。Spring默认抛出了非受查unchecked异常(继承自 RuntimeException 的异常)或者 Error才回滚事务,其他异常不会触发回滚事务。如果在事务中抛出其他类型的异常,但却期望 Spring 能够回滚事务,就需要指定rollbackFor属性。若在目标方法中抛出的异常是 rollbackFor 指定的异常的子类,事务同样会回滚。一般编码中会加个@Transactional(rollbackFor = Exception.class),意思是任何异常都会回滚。
2.3.4 同一个类中的方法调用了该类中另一个被@Transactional修饰的方法
这种失效场景是经常发生的。开发中避免不了会对同一个类里面的方法调用,比如有一个类Test,它有一个方法A,且方法A没有声明注解事务,方法A中再调用本类的方法B(不论方法B是用public还是private修饰),且B方法有声明注解事务,则外部调用方法A之后,方法B的事务是不会起作用的。
上述@Transactional注解失效的原因还是Spring的AOP导致的。原因:spring 在扫描bean的时候会扫描方法上是否包含@Transactional注解,如果包含,spring会为这个bean动态地生成一个子类(即代理类,proxy),代理类是继承原来那个bean的。此时,当这个有注解的方法被调用的时候,实际上是由代理类来调用的,代理类在调用之前就会启动transaction。然而,如果这个有注解的方法是被同一个类中的其他方法调用的,那么该方法的调用并没有通过代理类,而是直接通过原来的那个bean,所以就不会启动transaction,我们看到的现象就是@Transactional注解无效。
为什么一个方法a()调用同一个类中另外一个方法b()的时候,b()不是通过代理类来调用的呢?可以看下面的例子(为了简化,用伪代码表示):
@Service
class A{
@Transactinal
method b() {...}
method a() { //标记1
b();
}
}
//Spring扫描注解后,创建了另外一个代理类,并为有注解的方法插入一个startTransaction()方法:
class proxy$A{
A objectA = new A();
method b() { //标记2
// 开启事务
startTransaction();
objectA.b();
}
method a() { //标记3
objectA.a(); //由于a()没有注解,所以不会启动transaction,而是直接调用A的实例的a()方法
}
}
当我们调用A的bean的a()方法的时候,也是被proxy\(A拦截,执行proxy\)A.a()(标记3),然而,由以上代码可知,这时候它调用的是objectA.a(),也就是由原来的bean来调用a()方法了,所以代码跑到了“标记1”。由此可见,“标记2”并没有被执行到,所以startTransaction()方法也没有运行。
这个问题可以被推广到一般性:在同一个类中,一个方法调用另外一个有注解(比如@Async,@Transational)的方法,注解是不会生效的。
2.3.5 异常被你的 catch“吃了”导致@Transactional失效
这种场景也是会经常发生的。被@Transactional注解修饰的方法中,如果存在try-catch子句,且在try块中存在需要保证原子性的操作,则此时如果第一个操作成功,第二个操作抛出异常,则第一个操作仍然会被执行,即此时@Transactional注解会失效。
举个例子如下:
@Transactional
private Integer A() throws Exception {
int insert = 0;
try {
CityInfoDict cityInfoDict = new CityInfoDict();
cityInfoDict.setCityName("2");
cityInfoDict.setParentCityId(2);
// 第一个操作
insert = cityInfoDictMapper.insert(cityInfoDict);
// 第二个操作,且insertB中会抛异常
b.insertB();
} catch (Exception e) {
e.printStackTrace();
}
}
此时这两个操作并不会保证原子性,即@Transactional注解失效。原因是:第二个b.insertB()
操作抛出异常后,b对应的服务ServiceB表示当前事务需要回滚,但是在外层服务中由于手动捕获了这个异常并进行了处理,外层服务认为当前事务已经捕获并进行了处理,当前事务是可以进行正常提交的,因此会出现第一个操作被提交并成功执行了,而第二个操作由于抛异常并没有成功执行,表现为整体的方法的@Transactional注解失效。
2.3.6 数据库引擎不支持事务
这种情况出现的概率并不高,事务能否生效数据库引擎是否支持事务是关键。常用的MySQL数据库默认使用支持事务的innodb引擎。一旦数据库引擎切换成不支持事务的myisam,那事务就从根本上失效了。
2.4 @Transactional注解的其他参数
@Transactional 注解属性说明:
参数名 | 说明 |
---|---|
value | 当在xml配置文件中配置多个TransactionManager的时候,可以指定使用哪个事务管理器 |
propagation | 事务的传播行为,默认为Propagation.REQUIRED(表示启动事务)。PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务,如果没有事务,则以非事务的方式继续进行。 PROPAGATION_NOT_SUPPORTED:以非事务的方法运行,如果当前存在事务,则将事务挂起。PROPAGATION_NEVER:以非事务的方法运行,如果当前存在事务,则抛出异常。 |
isolation | 事务的隔离等级,默认为Isolation.DEFAULT。必须返回 TransactionDefinition 接口上定义的ISOLATION_XXX 常量之一。只有结合PROPAGATION_REQUIRED 或者 PROPAGATION_REQUIRES_NEW 一起声明才有意义。 |
timeout | 默认值为 -1(不超时),单位秒。表示事务必须在规定的时间内处理完成,否则超时。 |
readOnly | 默认false。该事务是否只读。 |
rollbackFor | 用于指定能够触发回滚的异常类型。多个类型以,(英文逗号)隔开。 |
rollbackForClassName | 定义异常的名字,这些异常会触发回滚机制。多个类型以,(英文逗号)隔开。 |
noRollbackFor | 抛出异常,不回滚。多个类型以,(英文逗号)隔开。 |
noRollbackForClassName | 定义异常的名字,抛出异常,不回滚。多个类型以,(英文逗号)隔开。 |
2.4.1 timeout 属性
为了使一个应用程序很好地执行,它的事务不能运行太长时间。假设事务的运行时间变得格外的长,由于事务涉及对数据库表的锁定,所以长时间运行的事务会不必要地占用数据库资源。因此对事务有超时时间设定的需求。
@Transactional注解中的参数timeout
即事务的超时时间,默认值为 -1。如果超过该时间但事务还没有完成,则自动回滚事务。
2.4.2 readOnly属性
readOnly :指定事务是否为只读事务,默认值为 false;为了忽略那些不需要事务的方法,比如读取数据,可以设置 read-only 为 true。
3、Spring 事务的传播机制
事务的传播性一般用在事务嵌套的场景,比如一个事务方法里面调用了另外一个事务方法,那么两个方法是各自作为独立的方法提交还是内层的事务合并到外层的事务一起提交,这就是需要事务传播机制的配置来确定怎么样执行。
Spring中常用的事务传播机制有7种,如下:
PROPAGATION_REQUIRED
Spring默认的传播机制,能满足绝大部分业务需求,如果外层有事务,则当前事务加入到外层事务,一块提交,一块回滚;如果外层没有事务,新建一个事务执行。PROPAGATION_REQUES_NEW
该事务传播机制是每次都会新开启一个事务,同时把外层事务挂起,当当前事务执行完毕,恢复上层事务的执行。如果外层没有事务,执行当前新开启的事务即可。PROPAGATION_SUPPORT
如果外层有事务,则加入外层事务,如果外层没有事务,则直接使用非事务方式执行。完全依赖外层的事务。PROPAGATION_NOT_SUPPORT
该传播机制不支持事务,如果外层存在事务则挂起,执行完当前代码,再恢复外层事务,无论是否异常都不会回滚当前的代码。PROPAGATION_NEVER
该传播机制不支持外层事务,即如果外层有事务就抛出异常。PROPAGATION_MANDATORY
与NEVER相反,如果外层没有事务,则抛出异常。PROPAGATION_NESTED
该传播机制的特点是可以保存状态保存点,当前事务回滚到某一个点,从而避免所有的嵌套事务都回滚,即各自回滚各自的,如果子事务没有把异常吃掉,基本还是会引起全部回滚的。
在Spring中的@Transactional注解中可以通过propagation
参数来指定开启上面哪一种事务传播机制,比如:@Transactional(propagation=Propagation.REQUIRED)
,实际开发中propagation
参数一般是缺省的,即默认开启的是PROPAGATION_REQUIRED机制。
4、Spring 事务的隔离级别
1.4小节中介绍了sql中的事务隔离,Spring事务的本质是基于底层数据库事务,而数据库事务本质是通过数据库锁(表锁、行锁、乐观锁等)保证的,所以Spring事务可以理解为是对底层数据库事务的一层封装。
Spring中声明事务隔离级别是通过@Transactional注解中的isolation
参数, 比如:@Transactional(isolation = Isolation.READ_UNCOMMITTED)
,实际开发中isolation
参数一般也是缺省的,默认开启的是ISOLATION_DEFAULT
级别,即数据库默认的事务隔离级别,MySql的默认事务隔离级别是REPEATABLE READ
(不可重复读)。
以下是Spring中支持的5种事务隔离级别:
常量 | 解释 |
---|---|
ISOLATION_DEFAULT | 这是个 Spring 默认的隔离级别,使用数据库默认的事务隔离级别。另外四个与 JDBC 的隔离级别相对应。 |
ISOLATION_READ_UNCOMMITTED | 事务最低的隔离级别,它充许另外一个事务可以看到这个事务未提交的数据。这种隔离级别会产生脏读,不可重复读和幻读。 |
ISOLATION_READ_COMMITTED | 保证一个事务修改的数据提交后才能被另外一个事务读取。可以避免脏读,但不能避免不可重复读和幻读。 |
ISOLATION_REPEATABLE_READ | 这种事务隔离级别可以避免脏读,不可重复读,但不能避免幻读。 |
ISOLATION_SERIALIZABLE | 这是花费最高代价但是最可靠的事务隔离级别,事务被处理为顺序执行。可以避免脏读、不可重复读和幻读,但也是所有隔离级别中最慢的,因为它通常是通过完全锁定当前事务所涉及的数据库的表锁来完成的。 |
当Spring中的事务隔离级别与底层数据库事务隔离级别不同时,Spring中的代码逻辑还是按照Spring的事务隔离级别来。