事务的特性和隔离级别

事务

在执行SQL语句的时候,某些业务要求,一系列操作必须全部执行,而不能仅执行一部分。例如,一个转账操作:

1
2
3
4
5
-- 从id=1的账户给id=2的账户转账100元
-- 第一步:将id=1的A账户余额减去100
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 第二步:将id=2的B账户余额加上100
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

这两条SQL语句必须全部执行,或者,由于某些原因,如果第一条语句成功,第二条语句失败,就必须全部撤销。

这种把多条语句作为一个整体进行操作的功能,被称为数据库事务。数据库事务可以确保该事务范围内的所有操作都可以全部成功或者全部失败。如果事务失败,那么效果就和没有执行这些SQL一样,不会对数据库数据有任何改动。

事务管理(ACID)

讲到事务,一般就有一些评判标准,就像volatile有一致性和可见性而没有原子性一样,对于事务,也有四种特性:

原子性(Atomicity)

这个原子性和并发中的原子性类似,但不完全一样。并发中的原子性表示操作不可分割,要么执行完,要么不执行。这个放在数据库也是一样的:

举个例子,A向B转账,会出现两个步骤:

  • A扣除100块
  • B获得100块

这两个步骤要么不执行,要么一起执行,这就是原子性。

一致性(Consistency)

就以上面的例子而言,一致性就是必须符合运算逻辑:

  • A必须扣除的数值是100,而不是90或者是其他数值。
  • B收到的数值必须是A扣除的数值,假如A因特殊原因只扣到50,B也只能收到50。

持久性(Durability)

表示事务结束后的数据不随着外界原因导致数据丢失

操作前A:800,B:200
操作后A:600,B:400
如果在操作前(事务还没有提交)服务器宕机或者断电,那么重启数据库以后,数据状态应该为
A:800,B:200
如果在操作后(事务已经提交)服务器宕机或者断电,那么重启数据库以后,数据状态应该为
A:600,B:400

隔离性(Isolation)

针对多个用户同时操作,主要是排除其他事务对本次事务的影响。就比如多个事务同时进行,存在ABCD互相转账,要根据实际情况来进行数据的隔离。

隔离的级别

未提交读(Read uncommitted)

未提交读是最低的隔离级别,但这种隔离级别有着最高的效率,在这种情况允许一个未提交的事务去读取另一个未提交的事务,可以让一个事务被数据库处理之前,已经把两个事务应该处理的数据处理完毕。

但是如此高效率的处理方式,容易引起一个糟糕的现象,叫做脏读:

在T3时刻,事务2去读取了事务1的数据,并进行了处理,然后提交。但是这个时候,如果发生了因为第一类丢失更新而导致的回滚,(第一类丢失更新是指在高并发情况下产生的数据丢不一致,但现已经被克服,所以会产生回滚),但是因为数据是在事务2被处理完成的,如果将事务1进行回滚,将不会回滚为2,而是直接为0。这样会导致最后的订单数少于总库存量。

还有一种情况下:

  • T1 事务读取账户余额 100 块,取钱,将余额修改为 50 块,但并没有提交。

  • T2 事务读取了 尚未提交 的数据,认为余额是 50 块。

  • T1 事务回滚。

  • T2 事务存钱 100 块,将余额计算为 50 + 100 = 150 块,此为脏数据,实际余额应该为 200 块。

在高并发情况下,很容易会发生第一类丢失更新,这个时候,事务1执行过程中修改了数据X,在未提交前,事务2读取了X,而事务1却回滚了,这样事务2就形成了脏读。也就是说,当前事务读到的数据是别的事务想要修改成为的但是没有修改成功的数据。

读写提交(Read committed)

是指一个事务只能读取到另外一个事务已经提交的数据。

就以上述例子而言,因为总是读取到尚未提交的数据,才导致的数据不一致,那么,就只读取已经提交的数据便可以的,如此克服脏读:

但是读写提交也会导致下面的问题:那就是当我们事务2读取到了库存之后,事务1马上提交事务,导致事务2读取到的库存是脏的、错误的库存。

T3时刻读取到库存为1,由于事务尚未提交,所以就导致了事务2误以为还能够进行减库存,但随之事务1便进行了提交,最后发现减库存失败的现象,这种现象会导致订单的数量多于商品的库存量。

可重复读(Repeatable Read)

这正是 MySQL InnoDB 的默认事务隔离级别
它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。同时也克服了不可重读的场景:

此级别可能出现的问题——幻读(Phantom Read):当用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影” 行:

很多人容易搞混不可重复读和幻读,确实这两者有些相似。但不可重复读重点在于 update 和 delete,而幻读的重点在于 insert。

InnoDB 存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)机制解决了该问题

可串行化(Serializable)

  • 这是最高的隔离级别
  • 它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。
  • 在这个级别,可能导致大量的超时现象和锁竞争

InnoDB 实现方式:这种事务的隔离级别下,所有 select 语句都会被隐式的转化为 select … in share mode。

首先这些读数据读错了,但是不管脏读,不可重复读,还是幻读,都是读操作似乎不会对数据库的数据造成影响,但是我们代码里面有后续的操作,一般都要用读出来的值进行对数据加工处理,然后就会对数据造成影响。

在SpingBoot中,可以通过application.yml来统一设置事务的隔离级别,也可以在方法上使用@Translational(isolation=isolation.Serializable),指定事务的隔离级别。

InnoDB下幻读是如何在RR级别下被解决的?

RR(Repeatable Read)

一个事务A在事务执行过程中第一次读取的值和第二次读取的值一致(解决了不可重复读),但是其他事务B 的insert 或者 delete的操作,会影响到俩次查询的条数(现象:幻读)

MVCC实现原理

MVCC(Multi Version Concurrency Control的简称),代表多版本并发控制。与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)。
MVCC最大的优势:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能

MVCC是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。当然存储的并不是实际的时间值,而是系统版本号(system version number)。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。
下面看一下在REPEATABLE READ隔离级别下,MVCC具体是如何操作的。

  • SELECT

    InnoDB会根据以下两个条件检查每行记录:

    1. InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
    2. 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。

    只有符合上述两个条件的记录,才能返回作为查询结果

  • INSERT

    InnoDB为新插入的每一行保存当前系统版本号作为行版本号。

  • DELETE

    InnoDB为删除的每一行保存当前系统版本号作为行删除标识。

  • UPDATE

    InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。
    保存这两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作

举例说明

1
2
3
create table mvcctest( 
id int primary key auto_increment,
name varchar(20));

transaction 1:

1
2
3
4
start transaction;
insert into mvcctest values(NULL,'mi');
insert into mvcctest values(NULL,'kong');
commit;

假设系统初始事务ID为1:

ID NAME 创建时间 过期时间
1 mi 1 undefined
2 kong 1 undefined

transaction 2:

1
2
3
4
start transaction;
select * from mvcctest; //(1)
select * from mvcctest; //(2)
commit
SELECT

假设当执行事务2的过程中,准备执行语句(2)时,开始执行事务3:

transaction 3:

1
2
3
start transaction;
insert into mvcctest values(NULL,'qu');
commit;
ID NAME 创建时间 过期时间
1 mi 1 undefined
2 kong 1 undefined
3 qu 3 undefined

事务3执行完毕,开始执行事务2 语句2,由于事务2只能查询创建时间小于等于2的,所以事务3新增的记录在事务2中是查不出来的,这就通过乐观锁的方式避免了幻读的产生

UPDATE

假设当执行事务2的过程中,准备执行语句(2)时,开始执行事务4:

transaction session 4:

1
2
3
start transaction;
update mvcctest set name = 'fan' where id = 2;
commit;

InnoDB执行UPDATE,实际上是新插入了一行记录,并保存其创建时间为当前事务的ID,同时保存当前事务ID到要UPDATE的行的删除时间

ID NAME 创建时间 过期时间
1 mi 1 undefined
2 kong 1 4
2 fan 4 undefined

事务4执行完毕,开始执行事务2 语句2,由于事务2只能查询创建时间小于等于2的,所以事务修改的记录在事务2中是查不出来的,这样就保证了事务在两次读取时读取到的数据的状态是一致的

DELETE

假设当执行事务2的过程中,准备执行语句(2)时,开始执行事务5:

transaction session 5:

1
2
3
start transaction;
delete from mvcctest where id = 2;
commit;
ID NAME 创建时间 过期时间
1 mi 1 undefined
2 kong 1 5

事务5执行完毕,开始执行事务2 语句2,由于事务2只能查询创建时间小于等于2、并且过期时间大于等于2,所以id=2的记录在事务2 语句2中,也是可以查出来的,这样就保证了事务在两次读取时读取到的数据的状态是一致的

InnoDB锁机制

数据库使用锁是为了支持更好的并发,提供数据的完整性和一致性。InnoDB是一个支持行锁的存储引擎,锁的类型有:共享锁(S)、排他锁(X)、意向共享(IS)、意向排他(IX)。为了提供更好的并发,InnoDB提供了非锁定读:不需要等待访问行上的锁释放,读取行的一个快照。

InnoDB有三种行锁的算法:

Record Lock

单个行记录上的锁。record lock锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。所以说当一条sql没有走任何索引时,那么将会在每一条聚集索引后面加X锁,这个类似于表锁,但原理上和表锁应该是完全不同的。

Gap Lock

间隙锁定是对索引记录之间的间隙的锁定,或者是对第一个或最后一个索引记录之前的间隙的锁定。例如,SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;防止其他事务将value 15插入column中t.c1,无论该列 中是否已有这样的值,因为该范围中所有现有值之间的间隙都是锁定的。

间隙可能跨越单个索引值,多个索引值,甚至为空。

间隙锁是性能和并发性之间权衡的一部分,并且在某些事务隔离级别而非其他级别中使用。

对于使用唯一索引来锁定唯一行来锁定行的语句,不需要间隙锁定。(这不包括搜索条件仅包含多列唯一索引的某些列的情况;在这种情况下,会发生间隙锁定。)例如,如果该id列具有唯一索引,则以下语句仅使用一个具有id值100 的行的索引记录锁定,其他会话是否在前面的间隙中插入行并不重要:

1
SELECT * FROM child WHERE id = 100;

如果id未建立索引或索引不唯一,则该语句会锁定前面的间隙。

在这里还值得注意的是,可以通过不同的事务将冲突的锁保持在间隙上。例如,事务A可以在间隙上保留一个共享的间隙锁(间隙S锁),而事务B可以在同一间隙上保留排他的间隙锁(间隙X锁)。允许冲突的间隙锁的原因是,如果从索引中清除记录,则必须合并由不同事务保留在记录上的间隙锁。

间隙锁定InnoDB是“ 纯粹抑制性的 ”,这意味着它们的唯一目的是防止其他事务插入间隙。间隙锁可以共存。一个事务进行的间隙锁定不会阻止另一事务对相同的间隙进行间隙锁定。共享和专用间隙锁之间没有区别。它们彼此不冲突,并且执行相同的功能。

间隙锁定可以显式禁用。如果将事务隔离级别更改为,则会发生这种情况 READ COMMITTED。在这种情况下,将禁用间隙锁定来进行搜索和索引扫描,并且间隙锁定仅用于外键约束检查和重复键检查。

使用READ COMMITTED隔离级别还有其他影响 。MySQL评估WHERE条件后,将释放不匹配行的记录锁。对于 UPDATE语句,请InnoDB 执行“ 半一致 ”读取,以便将最新的提交版本返回给MySQL,以便MySQL可以确定行是否与的WHERE 条件匹配UPDATE。

Next-Key Lock

Next-Key Lock是索引记录上的记录锁定和索引记录之前的间隙上的间隙锁定的组合。

InnoDB执行行级锁定,以使其在搜索或扫描表索引时对遇到的索引记录设置共享或排他锁。因此,行级锁实际上是索引记录锁。索引记录上的Next-Key Lock也会影响该索引记录之前的“ 间隙 ”。即,Next-Key Lock是索引记录锁定加上索引记录之前的间隙上的间隙锁定。如果一个会话R在索引中的记录上具有共享或排他锁 ,则另一会话不能R在索引顺序之前的间隙中插入新的索引记录 。

假定索引包含值10、11、13和20。此索引的可能的Next-Key Lock涵盖以下间隔,其中,圆括号表示排除区间端点,方括号表示包括端点:

1
2
3
4
5
(negative infinity, 10]
(10, 11]
(11, 13]
(13, 20]
(20, positive infinity)

对于最后一个间隔,Next-Key Lock将间隙锁定在索引中的最大值之上,并且“ supreumum ” 伪记录的值高于索引中实际的任何值。最高不是真正的索引记录,因此,实际上,此Next-Key Lock仅锁定最大索引值之后的间隙。

默认情况下,InnoDB以 REPEATABLE READ事务隔离级别运行。在这种情况下,请InnoDB使用next-key锁进行搜索和索引扫描。

用于Next-Key Lock事务数据出现类似于在下面SHOW ENGINE INNODB STATUS和 InnoDB的监视器 输出:

1
2
3
4
5
6
7
8
9
RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t` 
trx id 10080 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000274f; asc 'O;;
2: len 7; hex b60000019d0110; asc ;;