我们每天都在通过Spring和ORM框架简化我们的开发工作,框架简化了我们对事务处理的开发难度,我在工作中发现很多开发人员只会机械式的添加@Transactional注解,也不管是否真的执行了事务,以及事务的隔离级别,不同异常处理对事务的影响(当然出现这种情况项目管理方面也是存在问题的)。我构思良久将自己的经验写成这篇博文,希望与大家共勉。
事务基础知识
什么是事务和事务的特性等基础问题就不在此赘述,如果你还不知道那你应该反思了
事务的隔离级别
搞清楚事务的隔离级别说的到底是啥?
要搞清楚这个问题我们,先看下什么是隔离 "隔离,指断绝接触;断绝往来。",这个含义很明显就是要断绝一个事务和外界的往来。在来看一下隔离级别,解决的业务场景——隔离级别解决的业务场景是并发和多线程。综上所述,事务的隔离级别就是我们多个线程或者并发开启事务操作的时候,数据库要进行隔离操作,保证数据的准确性,它解决了数据脏读、不可重复读、幻读等问题。通过一个表解释这几种错误的含义。
术语 | 含义 |
---|---|
脏读 | A事务读取到了B事务还未提交的数据,如果B未提交的事务回滚了,那么A事务读取的数据就是无效的,这就是数据脏读 |
不可重复读 | 在同一个事务中,多次读取同一数据返回的结果不一致,这是由于读取事务在进行操作的过程中,如果出现更新事务,它必须等待更新事务执行成功提交完成后才能继续读取数据,这就导致读取事务在前后读取的数据不一致的状况出现 |
幻读 | A事务读取了几行记录后,B事务插入了新数据,并且提交了插入操作,在后续操作中A事务就会多出几行原本不存在的数据,就像A事务出现幻觉,这就是幻读 |
事务的四种隔离级别
隔离级别 | 含义 | 未解决的问题 | 解决的问题 |
---|---|---|---|
Read uncommitted | 允许一个事务读取另一个事务未提交的数据 | 脏读 | 无 |
Read committed | 允许并发事务在已提交后读取(就是在并事务中一个事务要等待另一个事务提交后才能读取) | 不可重复读、幻读 | 脏读 |
Repeatable read | 可重复读,对相同数据多次读取是一致的,除非数据被这个事务本身修改,也就是说在读取事务开启后,不允许在提交事务,必须等读取事务结束 | 幻读 | 不可重复读 |
Serializable | 串行化事务,它是最高的隔离级别,但是也是所有隔离级别中最慢的 | 无 | 脏读、不可重复读、幻读 |
便于理解我写一个例子, 使用Spring Boot + Mybatis,表结构如下所示:
- 业务处理过程
/**
* 模拟查询用户账户信息
*/
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_UNCOMMITTED)
public void findOne() throws InterruptedException {
Thread.sleep(1000);
Account account = accountMapper.findOne(1);
System.out.println(account);
}
/**
* 模拟更新用户余额
*/
@Transactional(rollbackFor = Exception.class)
public void updateAccount() throws InterruptedException {
int modifyNum = accountMapper.updateAccount(1);
Thread.sleep(1000);
int i = 1 / 0;
}
复制代码
<select id = "findOne" parameterType = "java.lang.Integer" resultMap = "baseColum">
select id, name, money from account where id = #{id}
</select>
<update id = "updateAccount">
update account set money = money - 100 where id = #{id}
</update>
复制代码
@Test
public void readUnCommitTest() {
CountDownLatch downLatch = new CountDownLatch(2);
new Thread(() -> {
try {
accountService.updateAccount();
} catch (Exception e) {
e.printStackTrace();
}
downLatch.countDown();
}).start();
new Thread(() -> {
try {
accountService.findOne();
} catch (Exception e) {
e.printStackTrace();
}
downLatch.countDown();
}).start();
downLatch.await(6, TimeUnit.SECONDS);
}
复制代码
这里我定义了两个方法,分别是一个查询方法和一个更新方法,同时创建了两个线程同时执行测试方法,通过定义不同的隔离级别我们分别来看一下,事务的执行情况。这里数据库Money的初始值均设置为1000。
- Read uncommitted
我们将查询方法的隔离级别定义为Read uncommitted,模拟更新过程中出现异常的情况,执行测试方法,控制台输出 Account{id=1, name='张三', money=900.0}, **查询方法查询到了更新方法还未提交的数据,但是更新方法遇到异常后执行了回滚操作,实际数据库数据并为发生改变,数据库发生了脏读。**如果这种情况发生在生产环境中,你的老板一定会砍死你的。
- Read committed
我们首先更改一下查询方法的隔离级别(代码就不贴了),同样的模拟更新出现异常,执行测试方法,控制台输出 Account{id=1, name='张三', money=1000.0},虽然查询方法出现了异常回滚了数据,但是我们查询方法查询的数据依旧是准确的,解决了脏读问题。
- Repeatable read
我们首先看看可重复读的效果,直接通过语句模拟:
让我们在看看设置隔离级别为,不可重复读取之后的效果:
事务的传播行为
事务的传播行为到底说的啥?
和之前一样,搞清楚一个问题时我们先要搞清楚它到底代表着什么,它的应用场景是什么,这样对于我们理解是非常有好处的,那么到底啥是事务的传播行为了,传播肯定是要两个人在一起才可以,一个人也没法传播呀(开车了)。事务的传播肯定也是需要在两个或以上事务中进行的,所以事务的传播行为定义为在一个事务中调用另一个事务,或者事务之间相互调用,事务如何传播即事务如何传递,它继续使用这个事务,还是新建一个事务?,至于场景显而易见,一个事务方法被其他事务方法调用时使用。
事务的七种传播行为
类型 | 解释 |
---|---|
PROPAGATION_REQUIRED | 支持当前事务,如果不存在则创建一个事务,这也是Spring 默认的传播行为 |
PROPAGATION_SUPPORTS | 支持当前事务,如果当前事务不存在,就不使用事务 |
PROPAGATION_MANDATORY | 支持当前事务,如果事务不存在则抛出异常 |
PROPAGATION_REQUIRES_NEW | 如果当前事务存在,则挂起当前事务,新建一个事务(会形成两个独立的事务,互不干涉 |
PROPAGATION_NOT_SUPPORTED | 以非事务方法运行,如果事务存在,则挂起事务,需要 JtaTransactionManager 的支持 |
PROPAGATION_NEVER | 以非事务的方式运行,如果有事务存在则抛出异常 |
PROPAGATION_NESTED | 如果当前事务存在则嵌套事务存在 |
- PROPAGATION_REQUIRED
我们通过代码演示一下这种传播行为,修改之前的代码如下:
/**
* 新增账户信息
*/
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
public void addAccount() {
Account account = new Account();
account.setId(4);
account.setName("赵六");
account.setMoney(1000);
int i = accountMapper.save(account);
updateAccount();
}
/**
* 模拟更新用户余额
*/
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
public void updateAccount() {
int modifyNum = accountMapper.updateAccount(1);
System.out.println("update success");
//int i = 1 / 0;
}
复制代码
xml:
<insert id = "save" parameterType="com.xiaoxiao.entity.Account">
insert into account(id, name, money) values(#{account.id}, #{account.name}, #{account.money})
</insert>
复制代码
单独执行 updateAccount 方法时会创建一个新的事务方法继续执行,当执行 addAccount 方法是创建了一个新的事务执行到更新方法时,就会加入这个事务,同时可以看到我在后面加了个by zero 的异常,如果更新方法发生异常,会导致两个方法同时回滚,因为他们本身就在同一个事务中。
- PROPAGATION_SUPPORTS
将 updateAccount 传播行为更改为PROPAGATION_SUPPORTS,addAccount 则不变,当单独执行更新方法时它总是以非事务的方式执行,即使遇到错误也并未回滚,这是因为它是以非事务方式执行的,当我们调用插入方法它则会加入到插入方法的事务中执行,这时遇到错误都会执行回滚操作。
- PROPAGATION_MANDATORY
将更新方法的传播行为修改为 PROPAGATION_MANDATORY, 当单独执行更新方法时,程序抛出 IllegalTransactionStateException 异常,当执行插入方法时更新方法加入到插入方法的事务中继续执行,遇到错误同时回滚。
- PROPAGATION_REQUIRES_NEW
这种传播行为需要 JtaTransactionManager 事务管理器,我们修改一下代码
/**
* 新增账户信息
*/
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
public void addAccount() {
Account account = new Account();
account.setId(4);
account.setName("赵六");
account.setMoney(1000);
int i = accountMapper.save(account);
updateAccount();
int num = 1 / 0;
}
/**
* 模拟更新用户余额
*/
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void updateAccount() {
int modifyNum = accountMapper.updateAccount(1);
System.out.println("update success");
}
复制代码
这里我们在插入方法中人为的制造了一个异常,单独执行更新方法的时候它会新创建一个事务,执行插入方法的时候因为,这里已经存在一个事务了,所以在执行更新方法的时候它会将外层事务挂起,直到执行完毕,如果在执行更新方法结束后遇到异常,更新方法还是会提交,外层事务则会回滚。
- PROPAGATION_NESTED
将更新方法的传播行为修改为 PROPAGATION_NESTED, 单独执行更新方法的时候因为上下文中并没有事务存在,它会按照 PROPAGATION_REQUIRED 行为进行执行,如果调用插入方法的时候,它则会嵌套在插入事务中执行,如果插入方法后续执行过程中出现异常,则插入更新方法也会回滚,这和 PROPAGATION_REQUIRES_NEW 行为正好相反。如果更新方法出现异常,它并不会回滚外层方法。
编程式事务与声明式事务
编程式事务和声明式事务在面试中经常会被提起,其实区分两者很简单,编程式事务就是需要我们对事务进行手动管理,我们需要手动编写事务处理代码的都可以被称为编程事务。 因为事务管理的代码多有重复,为了将其复用,利用了AOP的思想,通过对需要事务管理的方法进行前后拦截,来进行事务管理的,同时框架甚至帮助我们简化了这部分代码,我们只需要手动加注解的,这类都是声明式事务。 马上我们就可以知道,编程事务比较复杂,容易导致重复代码,代码侵入大,但是相对于声明式事务更为灵活,可定制性高。声明式事务简单,代码重复少,耦合低,但是灵活性较差。
虽然声明简化了我们工作,但是你永远不知道,提需求的同学的需求有多么惨绝人寰,我在实际工作中遇到过一个方法中部分需要事务管理,部分不需要事务管理,部分出错需要回滚,部分不让回滚的需求。
不同异常处理,对事务的影响
在进行事务编程过程中,不同的异常处理也会导致事务最终结果差异,下面我就具体讨论一下不同异常处理对事务的影响。 如果你读过《阿里巴巴Java开发规约》,或者使用过规约插件的话,一定会发现规约中有一条如下:
最后插件还贴心的帮你准备了三个正例,为什么阿里巴巴要求你这写,我们首先看个例子:
/**
* 反例1
*/
@Transactional
public void updateAccount {
try {
int modifyNum = accountMapper.updateAccount(1);
int num = 1 / 0;
} catch (Exception e) {
e.printStackTrace();
}
}
复制代码
有很多人处理异常的时候喜欢大而全,使用一个try块包含整个代码块,包括我刚开始工作的时候也是这样的,这样处理异常其实是非常LOW的,而且它还会带来隐患,观察上面所述代码,你认为事务在碰到异常时真的会回滚吗,答案是是否定的,应为声明式事务是基于AOP来帮我们处理异常的,你却在这里处理了异常,对于AOP来说它是感知不到异常的,所以事务并不会回滚。
/**
* 反例2
*/
@Transactional
public void updateAccount() throws FileNotFoundException {
int modifyNum = accountMapper.updateAccount(1);
//我的电脑并没有这个文件
InputStream inputStream = new FileInputStream(new File("G://A.txt"));
}
复制代码
前面提到了《阿里巴巴Java开发规约》要求我们必须指定异常类型,为什么要制定异常类型,因为**Spring框架的事务基础架构代码将默认只在抛出运行时和unchecked exceptions时才标识事务回滚。**这里首先我们需要明白异常的分类:
**Java中的异常分为错误和异常,其中错误是我们无法解决和处理的比如OOM,但是异常是我们可以进行处理的,其中Excetion 又分为运行时异常和分运行时异常,RuntimeException 及其子类都属于运行时异常,其他则相反。异常又分检查异常和非检查异常,其中Exception中除RuntimeException都属于可查异常,不可查异常RuntimeException及其子类和Error都属于不可查异常。很好理解基本上我们在日常工作中强制我们捕获的都属于检查异常如 IOException,FileNotFoundException。**如果你对异常处理感兴趣,我接下来会写一篇关于异常处理的博文,可以继续关注哦。回到事务,上例的事务会成功回滚吗,不会因为Spring默认只回滚运行时异常和非检查异常,即使我们抛出了FileNotFoundException,也不会回滚,因为他是检查异常,稍微修改一下代码。
/**
* 正例
*/
@Transactional(rollbackFor = Exception.class)
public void updateAccount() throws FileNotFoundException {
int modifyNum = accountMapper.updateAccount(1);
//我的电脑并没有这个文件
InputStream inputStream = new FileInputStream(new File("G://A.txt"));
}
复制代码
或
@Transactional(rollbackFor = Exception.class)
public void updateAccount() {
try {
int modifyNum = accountMapper.updateAccount(1);
InputStream inputStream = new FileInputStream(new File("G://A.txt"));
} catch (FileNotFoundException e) {
throw new RuntimeException("文件不存在", e);
}
}
复制代码
让检查异常回滚:在整个方法前加上 @Transactional(rollbackFor=Exception.class)
让非检查异常不回滚: @Transactional(notRollbackFor=RunTimeException.class)
在嵌套方法中异常处理尤为重要,举个例子:
/**
* 新增账户信息
*/
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
public void addAccount() {
Account account = new Account();
account.setId(4);
account.setName("赵六");
account.setMoney(1000);
int i = accountMapper.save(account);
updateAccount();
}
/**
* 模拟更新用户余额
*/
@Transactional(rollbackFor = Exception.class)
public void updateAccount() {
try {
int modifyNum = accountMapper.updateAccount(1);
int num = 1 / 0;
} catch (Exception e) {
e.printStackTrace();
}
}
复制代码
如果像上面这样,更新方法出现异常,添加方法并不会回滚,我们是使用了 REQUIRED 隔离级别,显而易见我们是想它们在同一个事务中的,当时异常捕获不当,就无法达到这种效果,这样的例子还有很多,在对事物做异常处理的时候一定要谨慎,多测试。对大段代码进行 try-catch,这是不负责任的表现。catch 时请分清稳定代码和非稳定代码,稳定代码指的是无论如何不会出错的代码。对于非稳定代码的 catch 尽可能进行区分异常类型,再做对应的异常处理。阿里规范告诉我们Transactional注解事务不要滥用。事务会影响数据库的QPS,另外使用事务的地方需要考虑各方面的回滚方案,包括缓存回滚、搜索引擎回滚、消息补偿、统计修正等。
总结
事务处理在开发中绝对是重中之重,特别是对于金融银行类项目,如果出了问题肯定是要卷铺盖走人的。如果你真正做跟钱相关的项目你一定会理解我说的。一定要带着敬畏的心情组织你的代码,同时一定要多测试、测试、测试,多思考多从开发过程中总结经验。
非常感谢你的阅读,如果你觉得不错的话就点个赞吧,如果你发现错误也可以在评论区给出批评。