昨天读到了一篇文章[1],里面讲,面试官说mysql的可重复读级别下有解决幻读的方式,最后公布了答案,是在sql后面加for update。这么说倒是没错,但是这种问法给我一种奇怪的感觉,因为for update无论在哪个隔离级别下,都能提供x锁进行锁定防止幻读,而next key lock,是x锁的三种实现算法之一,next key lock,也不应该翻译为间隙锁。下面我们一一讲来。
一,mysql的锁
mysql的锁,
按照读或写,分成s锁和x锁
按照锁的范围,分成表锁和行锁
按照意向锁的读或写,分成is锁和ix锁。其中is锁和ix锁是表锁,但不是真的用排他锁锁表,而是一种共享锁,旨在告诉其他线程表中有s锁或者x锁;而s锁和x锁是真的加了排他锁,s锁是对读共享对写排他,x锁是对其他线程的任何操作都排他,且s锁和x锁都是行锁。
而最后一个,mysql行锁的三种算法。
行锁(Record Lock)
,只锁单行
记录本身。
间隙锁(Gap Lock)
,锁住记录两边的记录,但是对记录本身,不加锁。
临键锁(Next Key Lock)
,即锁住记录本身,也锁住记录两边,这种锁的范围是左闭右开。还有一种锁,叫做Previous Key Lock
,和Next Key Lock类似,区别只是锁的范围左开右闭。它们都来自于谓词锁
,即锁住满足某一查询条件的所有数据项,它不仅包括当前在数据库中满足条件的记录,也包括即将要插入,更新或删除到数据库并满足查询条件的数据项。
下图来自《MySQL技术内幕 InnoDB存储引擎》一书。
第一个需要清晰指出的误区是,Next Key Lock翻译为临键锁[2],而不是间隙锁。next意为下一个,key意为键;而gap指空挡、间隙,因此Gap Lock才是间隙锁。
第二个误区是,Next Key Lock确实能够解决幻读问题,但是不止在可重复读(rr)级别下,你在所有级别下,在sql后面加for update都能施加x锁(有时是临键锁有时是行锁,这个后面讲)解决幻读问题。
二,mysql解决并发问题的机制
mysql有两种解决并发问题的机制,一种叫做基于一致性的非锁定读(Multi Version Concurrent Control, MVCC)
,也叫快照读
;另一种叫做基于一致性的锁定读(Lock Based Concurrent Control, LBCC)
也叫当前读
。
mysql四种隔离级别,读未提交,就是没有并发控制;读已提交和可重复读默认走的MVCC;串行化走的LBCC;但是不管哪个级别,加了lock in share mode(s锁)和for update(x锁)都是走的LBCC了。
简单来说,读已提交和可重复读不加锁走MVCC,加锁走LBCC。串行化就走LBCC没法走MVCC;读未提交默认没并发控制,加了所走LBCC,不过这种级别应该也应用不广。
MVCC其实就是不直接读取记录本身,只读写操作形成的undo log,undo log其实就是一种数据快照,其中包括记录所有数据,此外还多了一个指向其他undo log的指针(roll_ptr)和生成此undo log的事务id(trix_id),因此MVCC被称为快照读。这些不同事务写操作的undo log形成一个undo log链表,在查询的时候事务去undo log链表中进行查询,读已提交和可重复读的区别就是,读已提交每次查询都生成一个ReadView,而可重复读只有第一次查询生成一个ReadView。ReadView如何控制查询和解决不可重复读问题的,可以看往期文章[3]。
LBCC是直接去读取记录本身,这个没有什么好说的。
需要特别说明的是,MVCC和LBCC都是针对读请求的并发机制,对于写请求,会直接对要修改的行加锁进行操作,写操作在事务中会形成undo log并不持久化到硬盘,事务提交后持久化到硬盘并删除无用的undo log。
三,Next Key Lock如何解决幻读问题
要知道Next Key Lock如何解决幻读问题,首先要知道什么是幻读。幻读问题,简单的说就是a事务进行了两次相同的查询,第二次查询结果比第一次多了一些结果,原因是b事务在a事务两次查询中间,向其查询范围内插入了数据并提交,导致a事务第二次查询范围内多出了这些b事务提交的数据。好,接下来我们看一下例子。
假设有这样的一个sql,其中a是辅助索引,a的值有1、3、5、8、10。
select * from t where a = 5 for update;
由于加了x锁,走的LBCC,x锁的具体实现为临键锁,锁住(3, 5)+5+(5,8),即(3,8)。为什么使用临键锁锁住一整个区域呢,用行锁Record Lock只锁住一行不行吗,毕竟我们的查询条件只是a=5。
答案是不行。行锁的定义就是只锁住一行,而且a是辅助索引,并非唯一索引,意味着a=5可能有多行,只靠Record Lock锁不住多行a=5的记录。不仅如此,还有可能新插入的a=5的记录插入在原本a=5索引的最左侧或最右侧,因此要使用Next Key Lock锁住接近a=5的所有索引值对应的记录。即(3, 5)范围内有可能插入一个新的a=5的记录,(5,8)范围内也有可能插入一个新的a=5的记录,因此要锁住临近a=5的一整个范围。整个可能被插入的范围都被锁住,那么新插入数据的可能性就不存在了,幻读因此不可能出现。
至此,我们解释了为什么不能直接使用行锁来加锁,也解释了临键锁为什么能防止幻读。
四,Next Key Lock降级为Record Lock
前面我们解释了为什么普通索引要使用临键锁。其实还有一种情况,即等值查询时,使用的索引是唯一索引,那么Next Key Lock会被mysql降级为Record Lock。
因为唯一索引的数据是唯一的,不管查找的唯一索引的数据有或者没有,都只需要锁住一行即可,使用行锁锁住一行就能锁住等值查询的要找的那一条记录。
五,没有LBCC,可重复读只靠默认的MVCC能解决幻读问题吗
在可重复读级别下,只靠MVCC能够防止幻读吗?先说答案,部分情况下能,部分情况下,不能。
1,能防止幻读的情况
先说一下能防止幻读的原因。a事务进行了两次相同的查询,b事务在这两次查询中间,插入了在a事务查询范围内的数据,并提交,导致a事务第二次查询比第一次查询多出了数据,这就是幻读,两次相同的查询但是结果不一样,多了数据。
出现这种幻读的情况下,分为两种情况,事务b比事务a早开启,和事务b比事务a晚开启。
(1)假设事务b比事务a早开启,在事务a两次查询中间提交。
那么事务a第一次查询时生成了一个ReadView,在事务a第二次查询时,还会使用这个ReadView去进行查询(因为是可重复读级别下)。首先事务a会发现根据这个ReadView,事务b的trx_id在ReadView的min_trx_id和max_trx_id之间,表示事务b可能是活跃的事务,是不是还需要查看m_ids列表;
第二步,其中的m_ids列表(包括了生成ReadView时所有活跃的事务id)会包含事务b的trx_id,因此会认为事务b还是一个活跃事务,活跃事务的数据属于脏数据,因此不会被事务a读取。
注,ReadView的使用规则,查看文章[3]。
(2),假设事务b比事务a晚开启,在事务a两次查询中间提交。则事务b在提交数据后生成的undo log的trx_id就是事务b的trx_id,事务b要比事务a晚开启,意味着事务b的trx_id比事务a第一次查询时生成的ReadView的max_trx_id要大,则事务a在第二次查询时,可以通过ReadView知道,事务b是生成ReadView以后才开启,则事务a不会读取将来的数据。
两种情况列举下来,均可以避免幻读。
2,不能防止幻读的情况
那么什么时候不能防止幻读呢?在防止幻读时举的例子如下:
a事务进行了两次相同的查询,b事务在这两次查询中间,插入了在a事务查询范围内的数据,并提交,导致a事务第二次查询比第一次查询多出了数据
我们只需要在事务a第二次查询数据前,对事务b提交的数据做修改,然后再进行事务a的第二次查询。只用添加这么一步,就会出现幻读。
这是因为update本身不会根据MVCC,具体说就是ReadView去查看是否可以修改,update本身是加悲观锁直接对记录进行修改的,也就是说在事务b插入并提交以后,事务a是可以找到这条数据并成功修改的,并因此生成了undo log,并且undo log的trx_id就是事务a自己。
此时事务a再进行第二次查询,事务a查到符合范围的数据中,有一个自己修改的undo log快照,符合条件,于是就作为结果展示出来了,因此产生了幻读。
以上,就是关于可重复读、MVCC、LBCC、临键锁、间隙锁的一些解释。
参考文章:
[1],Mysql 间隙锁原理,以及Repeatable Read隔离级别下可以防止幻读原理(百度)
[2],详解 MySql InnoDB中的三种行锁(记录锁、间隙锁与临键锁)
[3],【MySQL】基于MVCC的RR、RC级别事务原理简述