vlambda博客
学习文章列表

带你读懂Spring 事务——事务的隔离级别(超详细,快藏)

不了解事务的铁汁可以先看前两篇,讲的超详细,有问题还请您指点一二




特别提示:本文所进行的实验都是在MySQL 5.7.30下使用InnoDB引擎进行的

一、什么是事务隔离

锤子在《带你读懂Spring事务》的第一篇中介绍过事务的定义以及它的四个特性,其中有一个特性就是隔离性,当时说了后面细讲,所以今天就来聊一聊事务的隔离特性。

根据事务的定义,我们已经知道了一个事务并不是一个单一的操作,它其实包含了多步操作,同时事务的执行的也可以是并发的,就是同时开启多个事务,进行业务操作,并发执行的事务里面的多步操作对着相同的数据进行查找修改,如果没有一个规则,在高并发的环境下可以引发的结果就可想而知,这个时候我们就需要定义一种规则,根据不同的业务场景来保证我们数据的可控性,这种规则就是事务的隔离级别,我们通俗的讲,事务的隔离级别就是一种控制并发执行的事务对数据操作的规则。

在标准SQL(SQL 92)中定义了四种事务的隔离级别:READ UNCOMMITTED(读未提交)、READ COMMITTED(读已提交)、REPEATABLE READ(可重复读)、SERIALIZABLE(串行化),其中隔离级别最宽松的是读未提交,最严格的是可串行化,当事务隔离级别较低时会引起一些数据问题(后文会讲解),当事务隔离级别设置为可串行化的时候,也就意味着事务的执行就类似于串行了,这个时候性能就会受到影响,所以在实际的业务中,是根据自己的需求来设置合理的事务的隔离级别,在性能和数据安全的两者之间找一个平衡点

在不同的数据库中事务的隔离级别还有小小的不同,在常用的关系型数据库中Oracle的事务隔离级别就只有三种:读已提交、串行化、只读(Read-Only)。在Oracle中增加了一个只读级别,而去掉了读未提交和可重复读两个级别。


Oracle中的读已提交和串行化的设计与标准SQL没有太多区别,我们主要说一下这个只读级别,它就是类似于一种快照的方式,处于只读级别的事务只能看到事务执行前就已经提交的数据,且事务中不能执行 INSERT , UPDATE ,及 DELETE 语句,就相当于在事务开始的时候,对数据创建了一个快照,整个事务的执行都在该快照的上进行读取,且不能修改。


另外几种常用的关系型数据库MySQL,MariaDB,PostgreSQL都是提供了按照标准SQL的四种隔离级别,那么接下来锤子就和大家一起看看这四种事务隔离级别都究竟是怎样的

二、四种事务隔离级别

同样在开始介绍四种事务隔离之前,锤子还是要设计一个场景帮助大家理解。

1.现有表A,两个字段 id和money,id为主键,money为金钱,数据库中一条id=1,money=20的数据,如下表所示

id money
1 20

2.有如下事务操作

时间 事务1 事务2
T1 begin 事务1
T2 select money from A where id = 1
T3
begin 事务2
T4
update A set money=100 where id=1
T5 select money from A where id = 1
T6
update A set money=200 where id=1
T7 select money from A where id = 1
T8
commit
T9 select money from A where id = 1
T10 commit

READ UNCOMMITTED——读未提交

事务执行过程中可以读取到并发执行的其他事务中未提交的数据。在这种事务隔离级别下可能会引起的问题包括:脏读,不可重复读和幻读。

举栗子

设置场景中事务的隔离级别是读未提交,那么现象是什么样的呢?

时序描述如下

  • T1 时刻,开启事务1

  • T2 时刻,事务1,查询A表id=1的记录的money,结果为money=20

  • T3 时刻,开启事务2

  • T4 时刻,事务2,更新A表id为1的记录的money为100

  • T5 时刻,事务1,查询A表id=1的记录的money,结果为money=100(读取到T4时刻,事务2更新未提交数据)

  • T6 时刻,事务2,更新A表id为1的记录的money为200

  • T7 时刻,事务1,查询A表id=1的记录的money,结果为money=200(读取到T6时刻,事务2更新未提交数据)

  • T8 时刻,事务2提交

  • T9 时刻,事务1,查询A表id=1的记录的money,结果为money=200

  • T10 时刻,事务1提交

从上面描述可以看出,事务1在T5和T7时刻查询的数据都是事务2更新了但是还没有进行提交的数据,事务2是在T8时刻才提交,所以这就是读未提交,读取到了其他事务没有提交的数据。

前面锤子也说了,这是事务隔离中最低的级别,这个隔离级别下的事务会读取到其他事务未提交的数据,所以在其他事务回滚的时候,当前事务读取的数据就成了脏数据(脏读我们后文细讲)

READ COMMITTED——读已提交

事务执行过程中只能读到其他事务已经提交的数据。读已提交保证了并发执行的事务不会读到其他事务未提交的修改数据,避免了脏读问题。在这种事务隔离级别下可能引发的问题包括:不可重复读和幻读

举栗子

设置场景中事务的隔离级别是读已提交,那么现象是什么样的呢?

时序描述如下

  • T1 时刻,开启事务1

  • T2 时刻,事务1,查询A表id=1的记录的money,结果为money=20

  • T3 时刻,开启事务2

  • T4 时刻,事务2,更新A表id为1的记录的money为100

  • T5 时刻,事务1,查询A表id=1的记录的money,结果为money=20

  • T6 时刻,事务2,更新A表id为1的记录的money为200

  • T7 时刻,事务1,查询A表id=1的记录的money,结果为money=20

  • T8 时刻,事务2提交

  • T9 时刻,事务1,查询A表id=1的记录的money,结果为money=200读取到事务2提交的数据

  • T10 时刻,事务1提交

从上面的描述可以看出,在读已提交的事务隔离级别下,事务1不会读到事务2中修改但未提交的数据,在T8时刻事务2提交后,T9时刻事务1读取的数据才发生变化,这种现象就是读已提交。

REPEATABLE READ——可重复读

当前事务执行开始后,所读取到的数据都是该事务刚开始时所读取的数据和自己事务内修改的数据。这种事务隔离级别下,无论其他事务对数据怎么修改,在当前事务下读取到的数据都是该事务开始时的数据,所以这种隔离级别下可以避免不可重复读的问题,但还是有可能出现幻读,那是为什么呢?

答案我们在下文第三部分讲解幻读时详细讲,现在我们还是先看看在可重复读隔离级别下,上面场景会变成什么样。

举栗子

设置场景中事务的隔离级别是可重复读,那么现象是什么样的呢?

时序描述如下

  • T1 时刻,开启事务1

  • T2 时刻,事务1,查询A表id=1的记录的money,结果为money=20

  • T3 时刻,开启事务2

  • T4 时刻,事务2,更新A表id为1的记录的money为100

  • T5 时刻,事务1,查询A表id=1的记录的money,结果为money=20

  • T6 时刻,事务2,更新A表id为1的记录的money为200

  • T7 时刻,事务1,查询A表id=1的记录的money,结果为money=20

  • T8 时刻,事务2提交

  • T9 时刻,事务1,查询A表id=1的记录的money,结果为money=20

  • T10 时刻,事务1提交

从上面描述看出,不论事务2对数据进行了几次更新,到后面即使事务2进行事务提交,事务1里面始终读取的还是自己开始事务前的数据,这种情况就是一种快照读(类似于Oracle下的Read-Only级别),但是这种情况下事务中也是可以进行insert,update和delete操作的。

SERIALIZABLE——串行化

事务的执行是串行执行。这种隔离级别下,可以避免脏读、不可重复读和幻读等问题,但是由于事务一个一个执行,所以性能就比较低。

所以在这种隔离级别下,上面的场景中是事务1执行完成后,才会执行事务2,两个事务在执行时不会相互有影响。

三、可能会引发的三种问题以及危害

讲完了标准SQL的事务的四种隔离级别,那么接下来,锤子就带大家,看一下,这四种隔离级别下,会发生的问题。前面咱们也提到了主要有三种问题:脏读、不可重复读和幻读。那么这些问题要怎么理解?锤子接下来,会一个一个举例进行详细讲解

脏读

脏读其实就是读到了,其他事务回滚前的未提交的脏数据。四种事务隔离级别中只有读未提交才会出现这种问题。咱们举个简单的栗子,理解一下脏读。

这样就由脏读造成了一个实际生产中超卖问题,在实际生产中,脏读会导致很多问题,所以我们在使用事务的时候,不要轻易将事务隔离级别设置为读未提交,一定要仔细思考后再选择。

不可重复读

不可重复读就是在一个事务多次相同的读取可能会读出不同的数据。读未提交和读已提交的隔离级别下都可能会出现不可重复读的问题。


  • 在读未提交隔离级别下,当前事务是可以读到其他事务未提交的数据,所以其他事务一直对数据进行修改的话,当前事务多次读取的数据就会不同。



  • 在读已提交的隔离级别下,当前事务只能读到其他事务已提交的数据,所以在其他事务修改还未提交时,当前事务的多次读取是一样的,但是一旦其他事务提交了修改,当前事务再读取到的数据,就与之前不一致了,这也是不可重复读的现场。


这样看起来在读已提交隔离级别下,当前事务都是读取的最新的已提交到数据库的数据,也不会有脏数据,好像并没有什么不妥,而且也合情合理,本来数据就是要读取最新的操作嘛,那么为什么不可重复读,还要被列为一个问题呢?相信你看了下面的例子就明白了

场景如下:

某电商平台做活动,用户在平台消费5000-10000元的送一个手机,消费超过10000元的送电脑。现在有生成获奖用户报表的事务如下:
1.开始事务
2.查询消费在5000到10000元的用户
3.打印送手机用户名单
4.查询消费在10000元以上的用户
5.打印送电脑用户名单
6.结束事务

锤子是该电商平台的用户,锤子之前的消费是6000元,在上述第2步的时候,锤子符合送手机的条件,而在上述第3步操作的时候,锤子又在电商平台消费了5000元,那么上述事务走到第4步的时候,锤子也符合了送电脑的条件,那么最终锤子就即获得了手机又获得了电脑,这个时候锤子是高兴了,可是电商平台就不高兴了,要吊起来打写这个功能的程序员哥哥喽。

看完上面的场景是不是发现,总是读取最新的数据并不是最好的,在某些场景下就是需要快照读,特别是对截至时间要求非常精确的地方,在事务开始的那一刻所有数据就应该固定,在整个事务的过程中,所有数据读取都要以事务开始的那一刻为准。

要处理这种情形下的问题,就要提高一下事务隔离级别到可重复读,在查出送手机用户的名单后加行锁,这样锤子又消费5000元的操作完成就在生成整个名单之后了,这就保证了锤子不会收到两个奖品。所以铁汁们在开发中要注意了,千万别踩到这样的坑,不然要被吊起来打了。

幻读

其他事务在一个尚未提交的当前事务的读取的行的范围中插入新行或删除现有行,会对当前事务的对数据的读取产生幻象。幻读在读未提交、读已提交和可重复读三种事务隔离级别下都会出现。

那么下面咱们就来举栗子说明一下幻读

场景如下:

某电商平台还在做活动,用户在平台消费5000-10000元的送一个手机,消费超过10000元的送电脑。现在有生成获奖用户报表的事务如下:
1.开始事务
2.查询消费在5000到10000元的用户
3.打印送手机用户名单
4.查询消费在10000元以上的用户
5.打印送电脑用户名单
6.结束事务

这个时候锤子和郝大都还不是该电商平台的用户,他们看到好消息,都想参加这个活动,于是两个人都去注册用户并消费。重点来了生成中间获奖名单的事务执行到第3步的时刻,锤子和郝大在此刻都注册了用户,并且锤子消费了6000元,郝大消费了12000元,两个人都以为可以得到自己想要的奖品了,结果最后中奖名单出来后,发现郝大获得了电脑,而锤子什么也没有,可是明明锤子和郝大一起注册的用户,但是郝大却获得了奖品,锤子却没获得。

上面描述的这种现象就是读已提交隔离级别下的一种幻读现象,两个用户同时注册(同时向表中插入数据),且各自都符合不同的奖品条件要求,但是一个有奖品,一个没有奖品,就会让人感觉,这个福利有内幕的感觉。这就是读已提交下幻读造成的一种影响。

同样上面的场景,如果事务隔离级别提高到可重复读,那么在不改变上述流程的情况下,在MySQL下就不会出现幻读了,因为他们的注册事务是在生成中奖名单之后,所以郝大和锤子都不会有奖品。因为在MySql的设计中可重复读的事务隔离级别的数据读取是快照读,即使其他事务进行insert或是delete,在当前事务中仅仅读取的话是读不到其他事务提交的数据,但是这并不代表MySQL中的可重复读隔离级别就可以完全避免幻读了

上面的场景下,我们提升事务隔离级别到可重复读,然后再修改一下生产获奖名单的事务,在第3步的后面添加一步update的操作(将用户表中所有用户记录的更新时间都更新一下),那么在update之后,再执行查询消费在10000元以上的用户的时候,郝大的数据又会被查出来,这个时候,又出现了,同时注册的两个人郝大有奖品,锤子没有奖品。

那么上面为什么进行一次update后,郝大的数据又会被查出来呢?

想知道这个原因还要知道两个概念:当前读和快照读(详解如下)

  • 当前读:读取的是最新版本数据, 会对读取的记录加锁, 阻塞并发事务修改相同记录,避免出现安全问题。

  • 快照读:可能读取到的数据不是最新版本而是历史版本,而且读取时不会加锁。

现在知道了这两个概念,那么下面就描述一下,MySQL在可重复读的隔离级别下开启事务时,默认是使用的快照读,所以在整个事务中如果只有查询,那么查询的都是快照数据,就不会受到其他事务影响,但是我们上面又在事务中添加了一个更新语句,当进行更新时快照读就会变成当前读,因为在事务中更新数据是需要对数据进行加锁,直到事务提交才会释放锁,所有由快照读变为当前读后,读取的数据就是最新的,也就把后来添加的郝大账户计算了进去。


到此我们把四种隔离级别和会引发的三种问题都进行了分析,所以大家在实际使用中要根据自己的业务进行合理选择,避免被老板吊着打

四、Spring中事务的隔离级别使用

前面写了这么多好像跟Spring都没有什么关系,哈哈哈,那么接下来,锤子带大家看一下,Spring中事务隔离级别的使用。

事务隔离级别枚举类Isolation

在Spring中事务的隔离级别也被封装成了一个枚举类org.springframework.transaction.annotation包下的Isolation类,同样的这个类枚举类也是与@Transactional结合使用,这个枚举类中定义的事务隔离级别与TransactionDefinition接口下定义的事务隔离级别相对应。

贴一下源码:

 
   
   
 
  1. package org.springframework.transaction.annotation;


  2. import org.springframework.transaction.TransactionDefinition;


  3. /**

  4. * Enumeration that represents transaction isolation levels for use

  5. * with the {@link Transactional} annotation, corresponding to the

  6. * {@link TransactionDefinition} interface.

  7. *

  8. * @author Colin Sampaleanu

  9. * @author Juergen Hoeller

  10. * @since 1.2

  11. */

  12. public enum Isolation {

  13. ...

  14. }

Spring中定义的五个隔离级别选项

  • ISOLATION_DEFAULT

使用数据库的默认级别,对于Oracle来说默认事务隔离级别是读已提交,对于MySQL来说默认事务隔离级别是可重复读,使用方式就是 @Transactional(isolation=Isolation.DEFAULT),看下源码注释

 
   
   
 
  1. /**

  2. * Use the default isolation level of the underlying datastore.

  3. * All other levels correspond to the JDBC isolation levels.

  4. * @see java.sql.Connection

  5. */

  6. DEFAULT(TransactionDefinition.ISOLATION_DEFAULT),

  • ISOLATION_READ_UNCOMMITTED

读未提交,含义上文有讲,这里不再赘述,看下源码注释

 
   
   
 
  1. /**

  2. * A constant indicating that dirty reads, non-repeatable reads and phantom reads

  3. * can occur. This level allows a row changed by one transaction to be read by

  4. * another transaction before any changes in that row have been committed

  5. * (a "dirty read"). If any of the changes are rolled back, the second

  6. * transaction will have retrieved an invalid row.

  7. * @see java.sql.Connection#TRANSACTION_READ_UNCOMMITTED

  8. */

  9. READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED),

  • ISOLATION_READ_COMMITTED

读已提交,源码注释如下

 
   
   
 
  1. /**

  2. * A constant indicating that dirty reads are prevented; non-repeatable reads

  3. * and phantom reads can occur. This level only prohibits a transaction

  4. * from reading a row with uncommitted changes in it.

  5. * @see java.sql.Connection#TRANSACTION_READ_COMMITTED

  6. */

  7. READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED),

  • ISOLATION_REPEATABLE_READ

可重复读,源码注释如下

 
   
   
 
  1. /**

  2. * A constant indicating that dirty reads are prevented; non-repeatable reads

  3. * and phantom reads can occur. This level only prohibits a transaction

  4. * from reading a row with uncommitted changes in it.

  5. * @see java.sql.Connection#TRANSACTION_READ_COMMITTED

  6. */

  7. READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED),

  • ISOLATION_SERIALIZABLE

串行化,源码注释如下

 
   
   
 
  1. /**

  2. * A constant indicating that dirty reads, non-repeatable reads and phantom

  3. * reads are prevented. This level includes the prohibitions in

  4. * {@code ISOLATION_REPEATABLE_READ} and further prohibits the situation

  5. * where one transaction reads all rows that satisfy a {@code WHERE}

  6. * condition, a second transaction inserts a row that satisfies that

  7. * {@code WHERE} condition, and the first transaction rereads for the

  8. * same condition, retrieving the additional "phantom" row in the second read.

  9. * @see java.sql.Connection#TRANSACTION_SERIALIZABLE

  10. */

  11. SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE);

总结:在本文中已经较为详细的讲解了事务的集中隔离级别和会引发的问题,从事务的隔离级别的设计中我们也能体会到,在数据安全和性能中,设计者也是一直在找一个平衡点,绝对的数据安全,就会导致性能变慢,同样追求绝对的性能,数据的安全和准确性就得不到保障。所以设计并不是一个非黑即白的,我们自己在设计东西的时候,是要考虑需求的全面性,然后根据现实再去设计,在不同的方面作取舍,这样或许才是一个好的功能和产品。