之前在《MySQL事务详解》中讲解了事务的概念、事务引发的问题以及通过不同的隔离级别避免这些问题。
《最详细的MySQL锁(悲观锁 乐观锁 共享锁 排它锁等)讲解》 讲解了各种锁(包括下面会提到的记录锁,临键锁)。
事务并发引起的问题:丢失更新、不可重复度、脏读和幻读。
这里就来讲讲InnoDB引擎中四种隔离级别是怎么实现的?
InnoDB使用不同的锁策略(Locking Strategy)以及MVCC机制来实现不同的隔离级别。
读未提交(Read Uncommitted)
原理
事务在读数据的时候不对数据加锁。
事务在修改数据的时候只对数据增加行级共享锁。
现象
事务1读取某行记录时,事务2也能对这行记录进行读取、更新(因为事务一并未对数据增加任何锁)。
当事务2对该记录进行更新时,事务1再次读取该记录,能读到事务2对该记录的修改版本(因为事务二只增加了共享读锁,事务一可以再增加共享读锁读取数据),即使该修改尚未被提交。
事务1更新某行记录时,事务2不能对这行记录做更新,直到事务1结束。(因为事务一对数据增加了共享读锁,事务二不能增加排他写锁进行数据的修改)。
读已提交(Read Committed)
原理
普通读是快照读,这是一种不加锁的一致性读,底层使用MVCC实现。
加锁的select, update, delete等语句,除了在外键约束检查(foreign-key constraint checking)以及重复键检查(duplicate-key checking)时会封锁区间,其他时刻都只使用记录锁。
可重复读(Repeatable Read)
原理
普通的select使用快照读。
加锁的select(select…in share mode/select…for update),update,delete等语句,它们的锁,依赖于它们是否在唯一索引上使用了唯一的查询条件,或者范围查询条件:
- 在唯一索引上使用唯一的查询条件,会使用记录锁,而不会封锁记录之间的间隔,即不会使用间隙锁与临键锁。
- 范围查询条件为非唯一值时,会使用临键锁,锁住索引记录之间的范围,避免范围间插入记录,以避免产生幻影,以及避免不可重复读。
表t有个字段a,由1、2、5这三个值组成,如事务a执行select * from t where a>2 for update;得到5。若此时事务b插入了4这个值(在数据库允许的情况下),在执行事务a中的select,会得到4和5。默认情况下事务b的操作会被阻塞,因为在这种隔离级别下,InnoDB采用临键锁锁住了2到正无穷这个范围,因此在这个范围内的插入操作都会被阻塞。但是注意如果事务a用的是select * from t where a>2 ;则事务b的插入操作不会被阻塞。
串行化(Serializable)
原理
这种事务隔离级别下,所有select语句会被隐式的转化为select…in share mode.
这会导致,如果有未提交的事务正在修改某些行,所有读取这些行的操作都会被阻塞。
这是一致性最好,并发性最差的隔离级别。
多版本并发控制 MVCC
MVCC即多版本并发控制,为了实现更好的并发,可以使得快照读操作不用加锁。
InnoDB实现了行锁、表锁、一致性非锁定读,其中一致性非锁定读就是通过MVCC实现的。如果当前读取的行正在执行delete或者update操作,读操作不会因此去等待锁的释放,而是通过读取行的快照数据实现一致性读。
下图展示了InnoDB存储引擎非锁定的一致性读:
从上如可以看出,每行记录可能有多个版本(提交一次事务得到一个版本),快照数据就是当前行数据之前的历史版本。一个行记录可能有不止一个快照数据,一般称这种技术为行多版本控制,由此带来的并发控制,称之为多版本并发控制。
MVCC 只在 READ COMMITED 和 REPEATABLE READ 这两个事务隔离性级别中使用。这是因为MVCC 和其他两个不兼容,READ UNCOMMITED 总是读取数据表最新的数据,而Seriablizable则会对每一个读都加共享锁。 但是 READ COMMITED 和 REPEATABLE READ对于快照数据的定义不同,READ COMMITED 隔离级别下,对于快照数据,非一致性读总是读取被锁定的行最新一份快照数据。而在REPEATABLE READ隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本,保证了可重复读。
在InnoDB中,会在每行数据后添加两个额外的隐藏的值来实现MVCC,这两个值一个记录这行数据何时被创建,另外一个记录这行数据何时过期(即何时被删除)。 在实际操作中,存储的并不是时间,而是系统的版本号,每开启一个新事务,系统的版本号就会递增。
通过MVCC,虽然每行记录都需要额外的存储空间,更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用,大多数读操作都不用加锁,读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,也只锁住必要行。
select (不加锁)满足两个条件的结果才会被返回:
- 创建版本号<= 当前事务版本号,小于意味着在该事务之前没有其他事务对其进行修改,等于意味着事务自身对其进行了修改;
- 删除版本号 > 当前事务版本号 ,意味着删除操作是在当前事务之后进行的,或者删除版本未定义,意味着这一行只是进行了插入,还没有删除过。
INSERT:为新插入的每一行保存当前事务的版本号作为创建版本号
DELETE:为删除的行保存当前事务的版本号为删除版本号
UPDATE:为修改的每一行保存当前事务的版本号作为创建版本号
快照读和当前读
MySQL中的读,和事务隔离级别中的读,是不一样的, 在REPEATABLE READ 级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,是不及时的数据(存储在缓存等地方的数据),不是数据库当前的数据!这也就是《MySQL事务详解》中提到的两个事务查询同一种表为空,第一个事务向表中插入主键id为1的数据,第二个事务再次查询该表还是为空,但是插入主键id为1的数据时出现主键重复的报错。
对于这种读取历史数据(缓存数据)的方式,我们叫它快照读 (snapshot read),而读取数据库当前版本数据的方式,叫当前读 (current read)。很显然,在MVCC中:
快照读:读取快照数据,快照数据是指改行的之前版本的数据,该实现是通过undo段来完成。而undo用来在事务中回滚数据,因此快照数据本身没有额外开销。此外,快照读不需要上锁,因为没有事务会对历史的数据进行修改。
当前读:插入/更新/删除操作属于当前读,处理的都是当前真实的数据,需要加锁。
事务的隔离级别实际上都是定义了当前读的级别,MySQL为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。而update、insert这些“当前读”,就需要另外的模块来解决了。这是因为update、insert的时候肯定要读取数据库中的值来与当前事务要写入的值进行对比,看看在该事务所处理的数据在这一段时间内有没有被其他事务所操作(就是先读取数据库中数据的版本号与当前的版本号做检查)。
READ COMMITED 和 REPEATABLE READ隔离级别下的不同快照读实例
开始事务a,执行select语句,开始事务b,对id=1的数据进行update操作。
事务a:
事务b:
事务a继续执行select语句:
这是无论在读已提交还是可重复读隔离级别下,结果都是一样的。
接着,提交事务b:
这是产生了一个新的快照数据,在读已提交隔离级别下得到结果:
在可重复度隔离界别下得到结果: