Spring事务隔离级别详解
事务定义的是一系列数据库操作的序列,这个序列是一个不可分割的逻辑单元,在其中的操作要么全部完成,要么全部无法完成。Spring事务通过Transactional.isolation属性进行定义,其具体值则存储在Isolation枚举中。Spring对事务隔离级别的定义与数据库隔离级别的定义是完全一致的,因而本文主要从数据库的层面对事务进行讲解。
1. 事务
在事务的定义上,其主要有四大特性:原子性、一致性、隔离性和持久性,简称为ACID。这四大特性的含义如下:
- 原子性:在一个事务中的操作都是一个逻辑的单元,在执行事务序列时,这些操作要么全部成功,要么全部失败;
- 一致性:对于事务操作,其始终能够保证在事务操作成功或者失败回滚时能够达到一种一致的状态,简而言之,事务中的操作要么全部成功,要么全部失败;
- 隔离性:各个事务之间的执行是相互不可见的,在事务还未执行成功时,其他的事务只能看到当前事务开始执行的状态,只有在当前事务执行完成之后才能看到执行之后的状态;
- 持久性:事务的持久性指的是事务一旦执行成功,那么其所做的修改将永久的保存在数据库中,此时即使数据库崩溃,修改的数据也不会丢失。
关于事务的持久性需要说明的是,从事务的角度能够保证数据能够一致性的保存在磁盘上,即使数据库发生故障也能够从故障中恢复,但是如果是数据库之外的问题,比如RAID卡损坏,自然灾害等,这种问题在数据库层面是无法避免的,其也不属于事务的范畴。事务能够保证数据的高可靠性,但是事务并不能保证系统的高可用行。
事务能够始终保证数据保持在一种一致的状态,但是如果严格按照事务的定义来处理事务,那么事务的执行效率将会很低,因为只有保证了所有事务的串行执行才能保证事务,因而在事务规范中为事务定义了四种隔离级别:Read uncommitted、Read committed、Repeatable read和Serializable。关于这四种隔离级别,其主要区别在于三个点:脏读、不可重复读和幻读。这三个点的主要含义如下:
- 脏读:脏读表示一个事务能够读取另一个事务中还未提交的数据。比如,某个事务尝试插入记录A,此时该事务还未提交,然后另一个事务尝试读取记录A,这时其是会成功读取到记录A的;
- 不可重复读:不可重复读表示当前事务对同一记录的两次重复读取结果不一致。比如一个事务首先读取一条记录A,读完之后另一个事务将该记录修改并且成功提交了,然后当前事务再次读取记录A,此时该事务会发现两次读取的结果不一致;
- 幻读:幻读指的是一个事务在进行一次查询之后发现某个记录不存在,然后会根据这个结果进行下一步操作,此时如果另一个事务成功插入了该记录,那么对于第一个事务而言,其进行下一步操作(比如插入该记录)的时候很可能会报错。从事务使用的角度来看,在检查一条记录不存在之后,其进行插入应该完全没问题的,但是这里却抛出主键冲突的异常。
关于事务的四种隔离级别,其主要区别点也就在于是否能够解决这三个问题。这四种事务的隔离级别主要区别如下:
- Read uncommitted:这是隔离性最低的一种隔离级别,在这种隔离级别下,当前事务能够读取到其他事务已经更改但还未提交的记录,也就是脏读;
- Read committed:顾名思义,这种隔离级别只能读取到其他事务已经提交的数据,也就解决了脏读的问题,但是其无法解决不可重复读和幻读的问题;
- Repeatable read:从事务的定义上,这种隔离级别能够解决脏读和不可重复读的问题,但是其无法解决幻读的问题;
- Serializable:也称为序列化读,这是隔离性最高的一种隔离级别,所有的事务执行(包括查询)都会为所处理的数据加锁,操作同一数据的事务将会串行的等待。
从事务隔离级别的定义上可以看出,Serializable级别隔离性最高,但是其效率也最低,因为其要求所有操作相同记录的事务都串行的执行。这里需要说明的是,对于MySql而言,其默认事务级别是Repeatable read,虽然在定义上讲,这种隔离级别无法解决幻读的问题,但是MySql使用了一种Next key-lock的算法来实现Repeatable read,这种算法是能够解决幻读问题的。关于Next key-lock算法,在进行查询时,其不仅会将当前的操作记录锁住,也会将查询所涉及到的范围锁住。也就是说,其他事务如果想要在当前事务查询的范围内进行数据操作,那么其是会被阻塞的,因而MySql在Repeatable read隔离级别下就已经具备了Serializable隔离级别的事务隔离性。
2. 示例演示
关于四种事务隔离级别的演示,我们主要使用MySql客户端进行。这里首先需要说明的几个命令是关于事务的几个基本操作命令:
-- 设置当前会话的事务隔离级别,需要严格注意区分命令中的大小写,这里四种隔离级别分别是:Read uncommitted,Read committed,Repeatable read,Serializable SET session TRANSACTION ISOLATION LEVEL Read uncommitted; -- 查看当前会话的事务隔离级别 show variables like 'transaction_isolation'; -- 开始一个事务 start transaction; -- 回滚当前事务 rollback; -- 提交当前事务 commit;
首先我们建立如下的数据库表结构:
create table user( id bigint auto_increment comment '主键', name varchar(20) not null default '' comment '名称', age int(3) not null default 0 comment '年龄', primary key(id) );
关于下面的演示过程,这里都省略了事务隔离级别切换的命令,读者可以自行进行切换。
- Read uncommitted
首先我们开启两个Mysql命令行,并且设置事务隔离级别为Read uncommitted。对于Read uncommitted,理论上在一个会话中开启事务之后,另一个会话插入一条未提交的数据,当前会话是可以读取到这条记录的。这里我们首先在会话A中执行如下命令:
-- 会话A mysql> start transaction; Query OK, 0 rows affected (0.00 sec)
然后在会话B中开启事务,并插入一条记录:
-- 会话B mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> insert into user(id, name, age) value (1, 'Mary', 23); Query OK, 1 row affected (0.00 sec)
此时再在会话A中尝试读取该记录:
-- 会话A mysql> select * from user where id=1; +----+------+------+ | id | name | age | +----+------+------+ | 1 | Mary | 23 | +----+------+------+ 1 row in set (0.00 sec)
可以看到,在A和B两个会话事务都未提交的情况下,会话A中的事务是能够成功读取到会话B中事务未提交的数据的,这也就产生了脏读的问题。
- Read committed
关于Read committed,其表示当前事务只能读取其他事务已经提交的数据,但是无法解决不可重复读的问题。
- 读取已提交的数据
这里的演示方式与脏读类似,首先在会话A中开启事务,然后在会话B中也开启事务,并且插入一条数据,此时在会话A中尝试读取该记录,应该是无法读取到结果的,如果在会话B中提交该事务之后,会话A中则应该可以读取到这条记录。
-- 会话A mysql> start transaction; Query OK, 0 rows affected (0.00 sec)
然后在会话B中开启事务并插入一条记录:
-- 会话B mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> insert into user(id, name, age) value (1, 'Mary', 23); Query OK, 1 row affected (0.01 sec)
此时在会话A中读取该记录应该是无法读取到的:
-- 会话A mysql> select * from user where id=1; Empty set (0.00 sec)
可以看到,这里会话A中的事务是无法读取到会话B中事务插入的还未提交的数据的。此时我们提交会话B中的事务,并且再次在会话A中尝试读取该记录:
-- 会话B mysql> commit; Query OK, 0 rows affected (0.06 sec) -- 会话A mysql> select * from user where id=1; +----+------+------+ | id | name | age | +----+------+------+ | 1 | Mary | 23 | +----+------+------+ 1 row in set (0.00 sec)
可以看到,在会话B提交了事务之后,会话A是能够获取到会话B进行的修改的。
- 不可重复读
关于不可重复读,理论上,一个事务中,在对同一条记录的多次重复读取,得到的结果应该是始终一致的。这里Read committed隔离级别是没有这个特性的,因而如果我们在会话A中读取一条记录,然后在会话B中修改该记录并且提交,接着在会话A中再次进行读取,那么此时会话A中读取到的应该是修改之后的值。首先我们在会话A中开启事务,并且读取一条记录:
-- 会话A mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where id=1; +----+------+------+ | id | name | age | +----+------+------+ | 1 | Mary | 23 | +----+------+------+ 1 row in set (0.00 sec)
然后在会话B中开启事务,修改一条记录,并且提交:
-- 会话B mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> update user set age=25 where id=1; Query OK, 1 row affected (0.01 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql> commit; Query OK, 0 rows affected (0.04 sec)
接着我们在会话A中再次读取该记录:
-- 会话A mysql> select * from user where id=1; +----+------+------+ | id | name | age | +----+------+------+ | 1 | Mary | 25 | +----+------+------+ 1 row in set (0.00 sec)
可以看到,会话A在事务还未提交的情况下,其重复读取同一条记录,两次读取的结果居然不一致,这也就是不可重复读。
- Repeatable read
关于Repeatable read,在定义上,其解决了不可重复读的问题,但是没解决幻读的问题,这里由于MySql使用了Next key-lock算法,因而在这个隔离级别下,其也解决了幻读的问题。这里我们会对着两种情况依次进行演示。
- 可重复读
这里可重复读的演示与上面不可重复读的演示方式是一样的,只是这里将隔离级别设置为Repeatable read。
-- 会话A mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where id=1; +----+------+------+ | id | name | age | +----+------+------+ | 1 | Mary | 25 | +----+------+------+ 1 row in set (0.00 sec)
然后在会话B中开启事务,修改该记录,并且提交:
-- 会话B mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> update user set age=30 where id=1; Query OK, 1 row affected (0.01 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql> commit; Query OK, 0 rows affected (0.05 sec)
这里会话B在事务中修改了id为1的记录的值,此时我们再次在会话A中读取该记录:
-- 会话A mysql> select * from user where id=1; +----+------+------+ | id | name | age | +----+------+------+ | 1 | Mary | 25 | +----+------+------+ 1 row in set (0.00 sec)
可以看到,在会话A还未提交的时候,其读取的结果始终是一致的,并未受到会话B中已经提交的事务的影响。
- 幻读
关于幻读,最典型的示例就是在一个事务中进行数据插入时,MySQL首先会先检查该数据是否存在,如果不存在则插入数据,这个过程中,如果另一个事务也插入了同样的数据,那么这个事务是会被阻塞的,如果当前事务提交了,那么另一个事务就会抛出主键冲突的异常。
-- 会话A mysql> select * from user where id=2 for update; Empty set (0.01 sec) mysql> insert into user (id, name, age) value (2, 'Jack', 24); Query OK, 1 row affected (0.00 sec)
此时在会话B中开启事务,并且尝试插入同一条数据,那么其是会被阻塞的:
-- 会话B mysql> start transaction; Query OK, 0 rows affected (0.00 sec) mysql> insert into user (id, name, age) value (2, 'Bob', 28);
可以看到,这里会话B中的插入操作是被阻塞了的。如果此时将会话A中的事务提交,那么会话B中将会抛出异常:
-- 会话B mysql> insert into user (id, name, age) value (2, 'Bob', 28); ERROR 1062 (23000): Duplicate entry '2' for key 'PRIMARY'
这里关于幻读需要说明的一点是,MySql在Repeatable read级别解决的幻读问题是在MySQL级别处理的,比如上面的示例中,我们在会话A中查询时加上了for update,该命令会针对目标记录加锁,如果目标记录不存在,则会加上Gap锁,这样后面在同一事物中插入是可以成功的。如果在同一事物中只是单纯的查询,然后进行插入,那么还是会出现幻读的问题的。也就是说上面的示例中,如果将for update去掉,那么其还是会出现幻读的问题的。
- Serializable
关于序列化读,这里就比较简单。对于一个事务而言,其所有的操作都会锁定所操作的数据和Gap,此时另外的事务只能等待该事务完成才能进行下一步操作。这里我们以两个事务同时查询同一事务中的同一记录为例进行展示:
-- 会话A mysql> select * from user where id=2; +----+------+------+ | id | name | age | +----+------+------+ | 2 | Jack | 24 | +----+------+------+ 1 row in set (0.00 sec)
这里会话A是可以正常读取记录的,此时我们在会话B中尝试使用加锁的方式读取同一记录:
-- 会话B mysql> select * from user where id=2 for update; ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
可以看到,会话B中的事务尝试加锁是失败的,因为目标记录在会话A中已经被锁定了。
3. 小结
本文首先对事务的四个特性进行了讲解,然后讲解了事务存在的三个问题,接着讲解了事务定义的四种隔离级别是如何解决这三个问题的,最后通过示例讲解了这四个隔离级别的区别。