事务定义的是一系列数据库操作的序列,这个序列是一个不可分割的逻辑单元,在其中的操作要么全部完成,要么全部无法完成。Spring事务通过 Transactional.isolation
属性进行定义,其具体值则存储在 Isolation
枚举中。Spring对事务隔离级别的定义与数据库隔离级别的定义是完全一致的,因而本文主要从数据库的层面对事务进行讲解。
在事务的定义上,其主要有四大特性:原子性、一致性、隔离性和持久性,简称为ACID。这四大特性的含义如下:
关于事务的持久性需要说明的是,从事务的角度能够保证数据能够一致性的保存在磁盘上,即使数据库发生故障也能够从故障中恢复,但是如果是数据库之外的问题,比如RAID卡损坏,自然灾害等,这种问题在数据库层面是无法避免的,其也不属于事务的范畴。事务能够保证数据的高可靠性,但是事务并不能保证系统的高可用行。
事务能够始终保证数据保持在一种一致的状态,但是如果严格按照事务的定义来处理事务,那么事务的执行效率将会很低,因为只有保证了所有事务的串行执行才能保证事务,因而在事务规范中为事务定义了四种隔离级别:Read uncommitted、Read committed、Repeatable read和Serializable。关于这四种隔离级别,其主要区别在于三个点:脏读、不可重复读和幻读。这三个点的主要含义如下:
关于事务的四种隔离级别,其主要区别点也就在于是否能够解决这三个问题。这四种事务的隔离级别主要区别如下:
从事务隔离级别的定义上可以看出,Serializable级别隔离性最高,但是其效率也最低,因为其要求所有操作相同记录的事务都串行的执行。这里需要说明的是,对于MySql而言,其默认事务级别是Repeatable read,虽然在定义上讲,这种隔离级别无法解决幻读的问题,但是MySql使用了一种 Next key-lock
的算法来实现Repeatable read,这种算法是能够解决幻读问题的。关于 Next key-lock
算法,在进行查询时,其不仅会将当前的操作记录锁住,也会将查询所涉及到的范围锁住。也就是说,其他事务如果想要在当前事务查询的范围内进行数据操作,那么其是会被阻塞的,因而MySql在Repeatable read隔离级别下就已经具备了Serializable隔离级别的事务隔离性。
关于四种事务隔离级别的演示,我们主要使用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) );
关于下面的演示过程,这里都省略了事务隔离级别切换的命令,读者可以自行进行切换。
首先我们开启两个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,其表示当前事务只能读取其他事务已经提交的数据,但是无法解决不可重复读的问题。
这里的演示方式与脏读类似,首先在会话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,在定义上,其解决了不可重复读的问题,但是没解决幻读的问题,这里由于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
去掉,那么其还是会出现幻读的问题的。
关于序列化读,这里就比较简单。对于一个事务而言,其所有的操作都会锁定所操作的数据和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中已经被锁定了。
本文首先对事务的四个特性进行了讲解,然后讲解了事务存在的三个问题,接着讲解了事务定义的四种隔离级别是如何解决这三个问题的,最后通过示例讲解了这四个隔离级别的区别。