解决并发事务带来问题的两种基本方式
并发事务访问相同记录的情况大致可以划分为3种。
- 读-读情况:并发事务相继续读取相同的记录。读取操作本身不会对记录有任何影响,不会引起什么问题,所以允许这种情况的发生。
- 写-写情况:并发事务相继对相同的记录进行改动。
- 读-写或者写-读情况:也就是一个事务进行读取操作,另一个事务进行改动操作。
写-写情况
首先来看写-写情况。
前面章节说过,在写-写情况下会发生脏写的现象,任何一种隔离级别都不允许这种现象的发生。所以在多个未提交事务相继对一条记录进行改动时,需要让他们排队执行。这个排队的过程其实是通过为该记录加锁来实现的。这个锁本质上是一个内存中的结构,在事务执行之前本来是没有锁的,也就是说一开始是没有锁结构与记录进行关联的。
当一个事务想对这条记录进行改动时,首先会看看内存中有没有与这条记录关联的锁结构;如果没有,就会在内存中生成一个锁结构与之关联。比如,事务T1要对这条记录进行改动,就需要生成一个锁结构与之关联。
其实锁结构中有很多信息,不过为了方便理解,我们现在只把两个比较重要的属性拿了出来。
- trx信息:表示这个锁结构是与哪个事务关联的。
- is_waiting:表示当前事务是否在等待。
在事务T1改动这条记录前,就生成了一个锁结构与该记录关联。因为之前没有别的事务为这条记录加锁,所以is_waiting属性是false。我们把这个场景称为获取锁成功,或者加锁成功,然后就可以继续执行操作了。
在事务T1提交之前,另一个事务T2也想对该条记录进行改动,那么T2先去看看有没有锁结构与这条记录关联。在发现有一个锁结构与之关联后,T2也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true,表示需要等待。我们把这个场景称为获取锁失败,或者加锁失败,或者没有成功地获取到锁。
事务T1提交之后,就会把它生成的锁结构释放掉,然后检测一下还有没有与该记录关联的锁结构。结果发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让T2继续执行。此时事务T2就算获取到锁了。
我们总结下后续内容中可能会用到的几种说法,以免大家混淆。
- 获取锁成功,或者加锁成功:在内存中生成了对应的锁结构,而且锁结构的is_waiting属性为false,也就是事务可以继续执行操作。当然并不是所有的加锁操作都需要生成对应的锁结构,有时候会有一种加隐式锁的说法。隐式锁并不会生成实际的锁结构,但是仍然可以起到保护记录的作用。我们把为记录添加隐式锁的情况也认为是获取锁成功。
- 获取锁失败,或者加锁失败,或者没有获取到锁:在内存中生成了对应的锁结构,不过锁结构的is_waiting属性为true,也就是事务需要等待,不可以继续执行操作。
- 不加锁,不需要在内存中生成对应的锁结构,可以直接执行操作。不包括为记录加隐式锁的情况。
读-写或写-读情况
前面说过,在读-写或写-读情况下会出现脏读,不可重复读、幻读的现象。
SQL 92标准规定,不同隔离级别有如下特点:
- 在READ UNCOMMITTED隔离级别下,脏读、不可重复读、幻读都可能发生。
- 在READ COMMITTED隔离级别下,不可重复读、幻读可能发生,脏读不可能发生。
- 在REPEATABLE READ隔离级别下,幻读可能发生,脏读和不可重复读不可能发生。
- 在SERIALIZABLE隔离级别下,上述现象都不可能发生。
不过各个数据库厂商对SQL标准的支持可能不一样。MySQL与SQL标准不同的一点就是,MySQL在REPEATABLE READ隔离级别下很大程度地避免了幻读现象。
怎么避免脏读、不可重复读、幻读这些现象呢?其实有两种可选的解决方案。
- 方案1:读操作使用多版本并发控制(MVCC),写操作进行加锁。
MVCC就是通过生成一个ReadView,然后通过ReadView找到符合条件的记录版本(历史版本是由undo日志构建的)。其实就像是在生成ReadView的那个时刻,时间静止了,查询语句只能读到在生成ReadView的那个提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改则是看不到的。写操作肯定是针对的是最新版本的记录,该记录的历史版本和改动记录的最新版本这两者并不冲突,也是就采用MVCC时,读-写操作并不冲突。
我们之前说过,普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时,都会生成一个Read View。ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象。在REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的现象。
- 方案2:读、写操作都采用加锁的方式。
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本。比如在银行存款的事务中,我们需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成后,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这也就意味着读操作和写操作也得像写-写操作那样排队执行。
脏读现象的产生是因为当前事务读取了另一个未提交事务写的一条记录。如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法在读取该记录时再获取到锁了,所以也就不会出现脏读现象了。
不可重复读现象的产生是因为当前事务先读取一条记录,另外一个事务对该记录进行了改动。如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改记录,自然也就不会出现不可重复读现象了。
幻读现象的产生是因为某个事务读取了符合某些搜索条件的记录,之后别的事务又插入符合相同搜索条件的新记录,导致该事务再次读取相同搜索条件的记录时,可以读到别的事务插入的新记录,这些新插入的记录被称为幻影记录。采用加锁的方式避免幻读现象就有那么一点点麻烦,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以在读取的时候加锁就有问题了。
很明显,如果采用MVCC方式,读-写操作彼此不冲突,性能更高;如果采用加锁的方式,读-写操作彼此需要排队执行,从而影响性能。一般情况下,我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是在某些特殊的业务场景中,要求必须采用加锁的方式执行。
一致性读
事务利用MVCC进行的读取操作称为一致性读(Consistent Read),或者一致性无锁读。所有普通的SELECT语句在READ COMMITTED、REPETABLE READ隔离级别下都算是一致性读。
一致性读并不会对表中的任何记录进行加锁操作,其他事务可以自由地对表中的记录进行改动。
锁定读
1、共享锁和独占锁
MySQL设计者将锁分成几类:
- 共享锁(Shared Lock):简称S锁。在事务要读取一条记录时,需要先获取该记录的S锁。
- 独占锁(Exclusive Lock):也常称为排它锁,简称X锁。在事务要改动一条记录时,需要先获取该记录的X锁。
假如事务T1首先获取了一条记录的S锁,之后事务T2接着也要访问这条记录: - 如果事务T2想要再获取一个记录的S锁,那么事务T2也会获得该锁,这也就意味着事务T1和T2在该记录上同时持有S锁;
- 如果事务T2想要再获取一个记录的X锁,那么此操作会被阻塞,直到事务T1提交之后将S锁释放掉为止。
如果事务T1首先获取了一条记录的X锁,那么之后无论事务T2是想获取该记录的S锁还是X锁,都会被阻塞,直到事务T1提交之后将X锁释放掉为止。所以S锁和S锁是兼容的,S锁和X锁是不兼容的,X锁和X锁也是不兼容的。
兼容性 | X锁 | S锁 |
---|---|---|
X锁 | 不兼容 | 不兼容 |
S锁 | 不兼容 | 兼容 |
2、锁定读语句
我们前面说过,为了采用加锁的方式避免脏读,不可重复读,幻读这些现象,在读取一条记录的时候需要获取该记录的S锁。其实这是不严谨的,有时候我们想在读取记录时就获取记录的X锁,从而全面禁止别的事务读写该记录。我们把这种在读取记录前就为该记录加锁的读取方式称为锁定读(Locking read)。MySQL的设计者提供了下面两种特殊的SELECT语句格式来支持锁定读。
- 对读取的记录加S锁。
SELECT ... LOCK IN SHARE MODE;
也就是在普通的SELECT语句后面加上LOCK IN SHARE MODE。如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样就可以允许别的事务继续获取这些记录的S锁,但是不能获取这些记录的X锁(比如使用SELECT… FOR UPDATE语句来读取这些记录,或者直接改动这些记录时)。如果别的事务想要获取这些记录的X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉为止。
- 对读取的记录加X锁。
SELECT ... FOR UPDATE;
也就是在普通的SELECT语句后面加上FOR UPDATE。如果当前事务执行了该语句。那么它会为读取到的记录加X锁,这样既不能允许别的事务获取这些记录的S锁(比如别的事务使用SELECT… FOR UPDATE语句未获取这些记录,或者直接改动这些记录时)。如果别的事务想要获取这些记录的S锁或者X锁,那么它们会被阻塞,直到当前事务提交之后将这些记录上的X锁时放掉为止。
写操作
平常我们用到的写操作无非是DELETE、UPDATE、INSERT这三种。
- DELETE:对一条记录执行DELETE操作的过程其实是先在B+树中定位到这条记录的为止,然后获取这条记录的X锁,最后再执行DELETE MARK操作。我们可以把这个先定位待删除记录在B+树中的位置,然后获取这条记录的X锁的过程看成是一个获取X锁的锁定读。
- UPDATE操作:在对一条记录进行UPDATE操作时分为下面3种情况。
1、如果未修改该记录的键值并且被更新的列所占用的存储空间在修改前后未发生变化,则先在B+树中定位到这条记录的位置,然后再获取记录的X锁,最后在原记录的位置进行修改操作。其实也可以把这个先定位待修改记录在B+树的位置,然后再获取记录的X锁的过程看成是一个获取X锁的锁定读。
2、如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+树中定位到这条记录的位置,然后获取记录的X锁,之后将该条记录彻底删除掉,最后再插入一条新的记录。可以把这个先定位待修改记录在B+树中的位置,然后再获取记录X锁的过程,看成是一个获取X锁的锁定读,与被彻底删除的记录关联的锁也会被转移到这条新记录上来。
3、如果修改了该记录的键值,则相当于在原记录上执行DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETE和INSERT的规则进行了。 - INSERT:一般情况下,新插入一条记录受隐式锁保护,不需要在内存中为其生成对应的锁结构。
在一个事务中加的锁一般在事务提交或中止时才会释放。
多粒度锁
前面提到的锁都是针对记录的,可以将其称为行级锁或者行锁。对一条记录加行锁,影响的也只是这条记录而已,我们就说这个行锁的粒度比较细。其实一个事务也可以在表级别进行加锁,自然就将其称为表级锁或者表锁。对一个表加锁,会影响表中所有记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁(S锁)和独占锁(X锁)。
-
给表加S锁
如果一个事务给表加了S锁,那么: -
别的事务可以继续获得该表的S锁;
-
别的事务可以继续获得该表中某些记录的S锁;
-
别的事务不可以继续获得该表的X锁;
-
别的事务不可以继续获得该表中某些记录的X锁;
-
给表加X锁
如果一个事务给表加了X锁(意味着该事务要独占这个表),那么: -
别的事务不可以继续获得该表的S锁
-
别的事务不可以继续获得该表中某些记录的S锁
-
别的事务不可以继续获得该表的X锁
-
别的事务不可以继续获得该表中某些记录的X锁
上面的文字看着有点啰嗦。为了更好的理解这个表级别的S锁和X锁,我们以大学教学楼中的教室来分析加锁的情况。
- 教室一般是公用的,我们可以随便选一间教室进去上自习。当然,教室不是自家的,一间教室可以容纳很多同学同时上自习。每当一个同学进去上自习,就相当于在教室门口挂了一把S锁,如果很多同学都进去上自习,就相当于教室门口挂了很多把S锁(类似行级别的S锁)。
- 有时教室会进行检修,比如换地板、换天花板、换灯管啥的,这些维修项目并不能同时开展。如果教室针对某个项目进行检修,就不允许同学来上自习,也不允许其他维修项目进行,此时相当于教室门口挂了一把X锁(类似行级别的X锁)。
上边提到的这两种锁都是针对教室而言的,不过我们有时会有一些特殊的需求。
- 有上级领导要来参观教学楼环境。校领导不想影响同学们上自习,但是此时不能有教室处于维修状态,于是可以在教学楼门口放置一把S锁(类似表级别的S锁)。此时:
- 来上自习的学生看到教学楼门口有S锁,可以继续进入教学楼上自习。
- 修理工看到教学楼门口有S锁,则先在教学楼门口等着,啥时候上级领导走了,把教学楼的S锁撤换掉后,再进入教学楼维修。
- 学校要占用教学楼进行考试。此时不允许教学楼中有正在上自习的教室,也不允许对教室进行维修,于是可以在教学楼门口放置一把X锁(类似表级别的X锁)。此时:
- 来上自习的学生看到教学楼门口有X锁,则需要在教学楼门口等着,啥时候考试结束,把教学楼的X锁撤换掉后,再进入教学楼上自习。
- 修理工看到教学楼门口有X锁,则先在教学楼门口等着,啥时候考试结束,把教学楼的X锁撤掉后,再进入教学楼维修。
但是这里存在下面两个问题:
- 如果想对教学楼整体上S锁,首先需要确保教学楼中没有正在维修的教室,如果有正在维修的教室,则需要等到维修结束才可以对教学楼整体上S锁。
- 如果想对教学楼整体上X锁,首先需要确保教学楼中没有上自习的教室以及正在维修的教室,如果有上自习的教室或者正在维修的教室,则需要等到上自习的所有同学都上完自习离开,以及维修工维修完教室离开后才可以对教学楼整体上X锁。
InnoDB提出了一种称为意向锁(Intention Lock)的东西。
-
意向共享锁(Intention Shared Lock):简称IS锁,当事务准备在某条记录上加S锁时,需要现在表级别加一个IS锁。
-
意向独占锁(Intention Exclusive Lock):简称IX锁,当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。
-
如果有学生到教室中上自习,那么它先在整栋教学楼门口放一把IS锁(表级锁),然后再到教室门口放一把S锁(行锁)。
-
如果有维修工到教室进行维修,那么它先在整栋教学楼门口放一把IX锁(表级锁),然后再到教室门口放一把X锁(行锁)。
之后:
- 如果有上级领导要参观教学楼,也就是想要再教学楼门口放S锁(表锁)时,首先要看一下教学楼门口有没有IX锁;如果有,则意味着有教室在维修,需要等到维修结束把IX锁撤换掉后,才可以在整栋教学楼上加S锁。
- 如果有考试要占用教学楼,也就是想在教学楼门口前放X锁(表锁)时,首先要看一下教学楼门口有没有IS锁或IX锁。如果有,则意味着有教室在上自习或者在维修,需要等到学生们上完自习或者维修结束把IS锁和IX锁撤掉后,才可以在整栋教学楼上加X锁。
总结一下:IS锁、IX锁时表级锁,他们的提出仅仅是为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁记录;也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。
MySQL中的行锁和表锁
其他存储引擎中的锁
对于MyISAM、MEMORY、MERGE这些存储引擎来说,它们只支持表级锁,而且这些存储引擎并不支持事务,所以当我们为使用这些存储引擎的表加锁时,一般都是针对当前会话来说的。
InnoDB存储引擎中的锁
InnoDB存储引擎既支持表级锁,也支持行级锁。表级锁比较粗,占用资源较少,不过有时我们仅仅需要锁住几条记录,如果使用表级锁,效果相当于为表中所有记录都加锁,所以性能比较差。行级锁粒度细,可以实现更精准的并发控制,但是占用资源较多。
1、InnoDB中的表级锁
表级别的S锁、X锁
在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。
另外,在对某个表执行一些诸如ALTER TABLE、DROP TABLE的DDL语句时,其他事务在对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE语句,会发生阻塞。同理,某个事务在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,其他事务会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称为元数据锁(Metadata Lock,MDL)的东西实现的,一般情况下也不会使用InnoDB存储引擎自己提供的表级别的S锁和X锁。
- 表级别的IS锁、IX锁:当使用InnoDB存储引擎的表的某些记录加S锁之前,需要先在表级别加一个IS锁;当对使用InnoDB存储引擎的表的某些记录加X锁之前,需要先在表级别加一个IX锁。IS锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时,判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。
表级别的AUTO-INC锁
在使用MySQL的过程中,我们可以为表的某个列添加AUTO_INCREMENT 属性,之后插入记录时,可以不指定该列的值,系统会自动为它赋予递增的值。
系统自动给AUTO_INCRMENT修饰的列进行递增赋值的实现方式主要有下面两个。 - 采用AUTO-INC锁,也就是在执行插入语句时就加一个表级别的AUTO-INC锁,然后为每条待插入记录的AUTO_INCRMENT修饰的列分配递增的值。在该语句执行结束后,再把AUTO-INC锁释放掉。这样一来,一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,从而保证一个语句中分配的递增值是连续的。如果我们的插入语句在执行前并不确定具体要插入多少条记录(无法预计即将插入记录的数量),比如使用INSERT…SELECT、REPLACE…SELECT或者LOADDATA这种插入语句,一般是使用AUTO-INC锁为AUTO_INCREMENT修饰的列生成对应的值。
需要注意的是,这个AUTO-INC锁的作用范围只是单个插入语句,在插入语句执行完成后,这个锁就被释放了。
- 采用一个轻量级锁,在为插入语句生成AUTO_INCREMENT修饰的列时获取这个轻量级锁,然后在生成本次插入语句需要用到AUTO_INCREMENT修饰的列的值之后,就把该轻量级锁释放掉,而不需要等待整个插入语句执行完成后才释放锁。
如果我们的插入语句在执行前就可以确定具体要插入多少条记录,比如前文关于表t的例子中,在语句执行前就可以确定要插入2条记录,那么一般采用轻量级锁的方式对AUTO_INCREMENT修饰的列进行赋值。这种方式可以避免锁表,可以提升插入性能。
InnoDB提供了一个名为innodb_autoinc_lock_mode的系统变量,用来控制到底使用上述两种方式中的哪一种来为AUTO_INCREMENT修饰的列进行赋值。当innodb_autoinc_lock_mode的值为0时,一律采用AUTO-INC锁;当innodb_autoinc_lock_mode的值为2时,一律采用轻量级锁。当innodb_autoinc_lock_mode的值为1时两种方式混着来(也就是在插入记录的数量确定时采用轻量级锁,不确定时使用AUTO-INC锁)。不过innodb_autoinc_lock_mode的值为2时,可能会造成不同事务中的插入语句为AUTO_INCREMENT修饰的列生成的值是交叉的,这在主从复制的场景中是不安全的。
2、InnoDB中的行级锁
- Record Lock
前面提到的记录锁就是这种类型,也就是仅仅把一条记录锁上。名字叫LOCK_REC_NOT_GAP。
记录锁是有S锁和X锁之分的。当一个事务获取了一条记录的S记录锁后,其他事务也可以继续获取该记录的S记录锁,但不可以获取X记录锁。当一个事务获取了一条记录的X型记录锁后,其他事务既不可以获取该记录的S型记录锁,也不可以继续获取X型记录锁。 - Gap Lock
前面讲到,MySQL在RR隔离级别下是可以很大程度上解决幻读现象的。解决方案有两种:使用MVCC方案解决,使用加锁方案解决。但是在使用加锁方案解决时有个问题就是事务在第一次执行读取操作时,那些幻影记录还不存在。我们无法给这些幻影记录添加记录锁。InnoDB提出了一种Gap Lock。
如上图,为number值为8的记录加了gap锁,这意味着不允许别的事务在number值为8的记录前面的间隙插入新的记录,其实就是number列的值在区间(3,8)的新记录是不允许立即插入的。比如有另外一个事务想插入一条number值为4的新记录,首先要定位到该条新记录的下一条记录,也就是number值为8的记录,而这条记录上又有一个gap锁,所以就会阻塞插入操作。直到拥有这个gap锁的事务提交之后将该gap锁释放掉,其他事务才可以插入number列的值在区间(3,8)中的新记录。这个gap锁的提出仅仅是为了防止插入幻影记录而提出的。虽然gap锁有共享gap锁和独占gap锁这样的说法,但是它们起到的作用都是相同的。而且如果对一条记录加了gap锁(无论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁。再强调一遍,gap锁的作用仅仅是为了防止插入幻影记录而已。 - Next-Key Lock
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前面的间隙插入新记录。此时使用Next-Key Lock。Next-Key Lock锁本质上是记录锁和间隙锁的合体。它既能保护该条记录,又能阻止别的事务将新记录插入到被保护记录前面的间隙中。 - Insert Intention Lock
一个事务在插入一条记录时,需要判断插入位置是否已被别的事务加了Gap锁。如果有的话,插入操作是需要等待,直到拥有gap锁的那个事务提交为止。此时提供了一种插入意向锁。
在一个事务中加的锁一般在事务提交或中止时才会释放。 - 隐式锁
在内存中生成锁结构并且维护它们并不是一件零成本的事情,InnoDB存储引擎提出了一个隐式锁的概念。比如一般情况下执行INSERT语句是不需要在内存中生成锁结构的(当然,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁),但是这可能会导致一些问题。比方说一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务执行如下操作。
立即使用SELECT… LOCK IN SHARE MODE语句读取这条记录(也就是要获取这条记录的S锁),或者使用SELECT…FOR UPDATE语句读取这条记录(也就是要获取这条记录的X锁)。
如果允许这种情况发生,那么可能出现脏读现象。
立即修改这条记录(也就是要获取这条记录的X锁)。
如果允许这种情况发生,那么可能出现脏写现象。
这时就需要事务ID了。我们把聚簇索引和二级索引中的记录分开看一下:
1、对于聚簇索引来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务的事务id。在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的就是当前事务的事务id。如果其他事务此时想要对该记录添加S锁或者X锁,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务。如果不是的话就可以正常获取;如果是的话,那么就帮助创建一个X锁的锁结构,该锁结构的is_waiting属性为false;然后为自己也创建一个锁结构,该锁结构的is_waiting属性为true,之后自己进入等待状态。
2、对于二级索引来说,本身并没有trx_id隐藏列,但是在二级索引页面的PAGE HEADER中有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大事务id。如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃的事务id,那么就说明对该页面做修改的事务已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后通过回表操作找到它对应的聚簇索引记录,然后再重复1的做法。
通过上文得知,一个事务对新插入的记录可以不显式地加锁,但是由于事务id的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构,最后进入等待状态。
InnoDB中的行级锁类型有下面这些:
- Record Lock:记录锁,只对记录本身加锁。
- Gap Lock:锁住记录前的间隙,防止别的事务向该间隙插入新的记录。
- Next-key lock:Record Lock和Gap Lock的结合体,既保护记录本身,也防止别的事务向该间隙插入新的记录。
- Insert Intention Lock:仅仅是为了解决当前事务插入记录时因为碰到别的事务加的gap锁而进入等待状态时,也生成一个锁结构。
- 隐式锁:依靠记录的trx_id属性来保护不被别的事务改动记录。
InnoDB锁的内存结构
InnoDB存储引擎的锁都在内存中对应着一个结构。有时为了节省锁结构,会把符合下面的条件的锁放到同一个锁结构中。
- 在同一个事务中进行加锁操作
- 被加锁的记录在同一个页面中
- 加锁的类型是一样的
- 等待状态是一样的
- 锁所在的事务信息:无论是表级锁还是行级锁,一个锁属于一个事务,这里记载着锁对应的事务信息。
- 索引信息:对于行级锁来说,需要记录一下加锁的记录属于哪个索引。
- 表/行锁信息:表级锁结构和行级锁结构在这个位置的内容是不同的,具体表现为锁记载着这是对哪个表加的锁,还有其他一些信息;而行锁记载了下面3个重要的信息:
- Space ID:记录所在的表空间。
- Page Number:记录所在的页号。
- n_bits:对于行级锁来说,一条记录对应着一个比特;一个页面中包含了很多条记录,用不同的比特来区分到底是为了哪一条记录加了锁。
- type_mode:这是一个32比特的数,被分成lock_mode、lock_type和rec_lock_type这3个部分。
lock_mode:可选值如下:
LOCK_IS表示共享意向锁,也就是IS锁。
LOCK_IX表示独占意向锁,也就是IX锁。
LOCK_S表示共享锁,也就是S锁。
LOCK_X表示独占锁,也就是X锁。
LOCK_AUTO_INC表示AUTO_INC锁。
lock_type:可选值如下:
LOCK_TABLE 表示表级锁。
LOCK_REC 表示行级锁。
语句加锁分析
普通的SELECT语句
在不同的隔离级别下,普通的SELECT语句具有不同的表现,具体如下:
- 在READ UNCOMMITTED隔离级别下,不加锁,直接读取记录的最新版本;可能出现脏读,不可重复读和幻读现象。
- 在READ COMMITTED隔离级别下,不加锁,在每次执行普通SELECT语句时都会生成一个Read View,这样就避免了脏读现象,但是没有避免不可重复读和幻读现象。
- 在REPEATABLE READ隔离级别下,不加锁;只在第一次执行普通的SELECT语句时生成一个ReadView,这样就把脏读、不可重复读和幻读都避免了。
不过这里有一个小插曲:
#事务T1,REPEATABLE READ隔离级别下
mysql> BEGIN;
Query OK, 0 rows affected(0.00 sec)
mysql> SELECT * FROM hero WHERE number = 30;
Empty set (0.01 sec)
#事务T2执行了:INSERT INTO hero VALUES(30,'g关羽','魏');语句并提交
mysql> UPDATE hero SET country = '蜀' WHERE number = 30;
Query OK, 1 rows affected(0.01 sec)
mysql> SELECT * FROM hero WHERE number = 30;
+-----------+-----------+----------------------+
| number | name | country |
+-----------+-----------+----------------------+
| 30 | g关羽 | 蜀 |
+-----------+-----------+----------------------+
1 rows in set
在REPEATABLE READ隔离级别下,T1第一次执行普通的SELECT语句时生成了一个Read View,之后T2向hero表中新插入一条记录并提交。Read View并不能阻止T1执行UPDATE或者DELETE语句来改动这个新插入的记录(由于T2已经提交,因此改动该记录并不会造成阻塞),但是这样一来,这条新记录的trx_id隐藏列的值就变成了T1事务的id。之后T1再使用普通SELECT语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返回给客户端。因此这个特殊现象的存在,我们也可以认为InnoDB中的MVCC并不能完全禁止幻读。
锁定读语句
- 语句1:SELECT…LOCK IN SHARE MODE
- 语句2:SELECT…FOR UPDATE
- 语句3:UPDATE
- 语句4:DELETE
语句1和语句2是MySQL中规定的两种锁定读的语法格式,而语句3和语句4由于在执行过程中需要首先定位到被改动的记录并给记录加锁,因此也可以认为是一种锁定读。这里先引入两个概念:
1、匹配模式(match mode):我们知道,在使用索引执行查询时,查询优化器首先会生成若干个扫描区间。针对每一个扫描区间,我们都可以在该扫描区间中快速地定位到第一条记录,然后沿着这条记录所在的单向链表就可以访问到扫描区间内的其他记录,直到某条记录不在该扫描区间中位置。如果被扫描的区间是一个单点扫描区间,我们就可以说此时的匹配模式为精确匹配。比如我们为某个表的ab两个列建立了一个联合索引idx_a_b(a,b),我们列举几种不同的查询情况。
- 如果形成扫描区间的边界条件是a=1,那么它对应的扫描区间就是[1,1]。InnoDB认为这是一个单点扫描区间。如果查询优化器决定通过访问这个扫描区间中的记录来执行查询,那么此时的匹配模式就是精确匹配。
- 如果形成扫描区间的搜索条件是a=1 AND b=1,那么它对应的扫描区间就是[(1,1),(1,1)]。InnoDB认为这是一个单点扫描区间,那么此时也是精确匹配。
- 如果形成的扫描区间的搜索条件是a=1 AND b>=1,那么对应的扫描区间就是[(1,1),(1,正无穷)]。InnoDB认为这个扫描区间不算是一个单点扫描区间。如果查询优化器决定通过访问这个扫描区间中的记录来执行查询,那么此时的匹配模式就不是精确匹配。
2、唯一性搜索(unique search):
如果在扫描某个扫描区间的记录前,就能事先确定该扫描区间内最多只包含一条记录的话,那么就把这种情况称作唯一性搜索。那么,怎么确定某个扫描区间最多只包含一条记录呢?其实查询只要符合下面这些条件,就可以确定最多只包含一条记录了:
- 匹配模式为精确匹配
- 使用的索引是主键或者唯一二级索引
- 如果使用的索引是唯一二级索引,那么搜索条件不能为索引列 IS NULL的形式
- 如果索引中包含多个列,那么在生成扫描区间时,每一个列都得被用到
由于在语句的执行过程中,对记录进行加锁的影响因素太多了,所以我们决定先分析在一般情况下,在语句执行过程中该如何对记录进行加锁,然后再列举一些比较特殊的情况进行分析。
另外需要注意的一点是,事务在执行过程中所获取的锁一般在事务提交或者回滚时才会释放,但是在隔离级别不大于READ COMMITTED时,在某些情况下也会提前将一些不符合搜索条件的记录上的锁释放掉。
一般情况下,读取某个扫描区间中记录的过程如下所示。
步骤1、首先快速地在B+树叶子节点中定位到该扫描区间中的第一条记录,把该记录作为当前记录。
步骤2、为当前记录加锁。
一般情况下,对于锁定读的语句,在隔离级别不大于READ COMMITTED时,会为当前记录加记录锁。在隔离级别不小于REPETABLE READ时,会为当前记录加next-key锁。
步骤3、判断索引条件下推是否成立。
前文介绍过索引条件下推(ICP)的功能,用来把查询中与被使用索引有关的搜索条件下推到存储引擎中判断,而不是返回到server层判断。不过需要注意的是,索引条件下推只是为了减少回表次数,也就是减少读取完整的聚簇索引记录的次数,从而减少IO操作。所以它只适用于二级索引,不适用于聚簇索引。另外索引下推仅适用于SELECT语句,不适用于UPDATE、DELETE这些需要改动记录的语句。
在存在索引条件下推的条件时,如果当前记录符合索引条件下推的条件,则跳到步骤4继续执行;如果不符合,则直接获取到当前记录所在的单向链表的下一条记录,将该记录作为新的当前记录,并跳到步骤2.另外,步骤3还会判断当前记录是否符合形成扫描区间的边界条件,如果不符合,则跳过步骤4和步骤5,直接向server层返回一个查询完毕的信息。这里需要注意的是,步骤3不会释放锁。
步骤4、执行回表操作。
如果读取的是二级索引记录,则需要进行回表操作,获取到对应的聚簇索引记录并给该聚簇索引记录加记录锁。
步骤5、判断边界条件是否成立。
如果该记录符合边界条件,则跳到步骤6继续执行,否则在隔离级别不大于READ COMMITTED时,就要释放掉加在该记录上的锁(在隔离级别不小于REPEATABLE READ时,不释放加在该记录上的锁),并且向server层返回一个查询完毕信息。
步骤6、server层判断其余搜索条件是否成立。
除了索引条件下推的情况下,server层还需要判断其他搜索条件是否成立。如果成立,则将该记录发送到客户端,否则在隔离级别不大于READ COMMITTED时,就要释放掉加在该记录上的锁(在隔离级别不小于REPETABLE READ时,不释放加在该记录上的锁)。
步骤7、获取当前记录所在的单向链表的下一条记录,并将其作为新的当前记录,并跳回到步骤2.
实例1:
SELECT * FROM hero WHERE number > 1 AND number <= 15 AND country = '魏' LOCK IN SHARE MODE;
给定了一个语句后,从查询计划可以看出,查询优化器将通过range访问方法来读取聚簇索引记录中的一些记录。很显然,我们可以通过搜索条件number > 1 AND number <= 15来生成扫描区间(1, 15],也就是需要扫描number值在(1, 15]区间中的所有聚簇索引记录。
我们先来分析该语句在隔离级别不大于READ COMMITTED时的加锁过程。
对number值为3的聚簇索引记录的加锁过程进行分析
步骤1:读取在(1, 15]扫描区间的第一条聚簇索引记录,也就是number值为3的聚簇索引记录。
步骤2:为number值为3的聚簇索引记录加S型记录锁。
步骤3:由于读取的是聚簇索引记录,所以没有索引条件下推的条件。
步骤4:由于读取的本身就是聚簇索引记录,所以不需要执行回表操作。
步骤5:形成扫描区间(1, 15]的边界条件是number > 1 AND number <=15,很显然number值为3的聚簇索引记录符合该边界条件。
步骤6:server层继续判断number值为3的聚簇索引记录是否符合number > 1 AND number <= 15 AND country = ‘魏’。很显然不符合,所以释放掉加在该记录上的锁。
步骤7:获取number值为3的聚簇索引记录所在单向链表的下一条记录,也就是number值为8的聚簇索引记录。
对number值为3的聚簇索引记录的加锁过程进行分析
步骤2:为number值为8的聚簇索引记录加S型记录锁。
步骤3:由于读取的是聚簇索引记录,所以没有索引条件下推的条件。
步骤4:由于读取的本身就是聚簇索引记录,所以不需要执行回表操作。
步骤5:形成扫描区间(1,15]的边界条件时number > 1 AND number <= 15,很显然number值为8的聚簇索引记录符合该边界条件。
步骤6:server层继续判断number值为8的聚簇索引记录是否符合条件number > 1 AND number <= 15 AND country = ‘魏’。很显然符合,所以将其发送到客户端,并且不释放加在该记录上的锁。
步骤7:获取number值为8的聚簇索引记录所在单向链表的下一条记录,也就是number值为15的聚簇索引记录。
对number值为15的聚簇索引记录的加锁过程进行分析
步骤2:为number值为15的聚簇索引记录加S型记录锁。
步骤3:由于读取的是聚簇索引记录,所以没有索引条件下推的条件。
步骤4:由于读取的本身就是聚簇索引记录,所以不需要执行回表操作。
步骤5:形成扫描区间(1,15]的边界条件时number > 1 AND number <= 15,很显然number值为15的聚簇索引记录符合该边界条件。
步骤6:server层继续判断number值为8的聚簇索引记录是否符合条件number > 1 AND number <= 15 AND country = ‘魏’。很显然符合,所以将其发送到客户端,并且不释放加在该记录上的锁。
步骤7:获取number值为15的聚簇索引记录所在单向链表的下一条记录,也就是number值为20的聚簇索引记录。
对number值为20的聚簇索引记录的加锁过程进行分析
步骤2:为number值为20的聚簇索引记录加S型记录锁。
步骤3:由于读取的是聚簇索引记录,所以没有索引条件下推的条件。
步骤4:由于读取的本身就是聚簇索引记录,所以不需要执行回表操作。
步骤5:形成扫描区间(1,15]的边界条件时number > 1 AND number <= 15,很显然number值为20的聚簇索引记录不符合该边界条件。释放掉加在该记录上的锁,并给server层返回一个查询完毕的信息。
步骤6:server层收到存储引擎返回的查询完毕信息,结束查询。
综上所述,在隔离级别不大于READ COMMITTED的情况下,该语句在执行过程中的加锁效果如下所示。
如上图所示,我们使用带圆圈的数字对各个记录的加锁顺序进行了标记。需要注意的是,对于number值为3、20的聚簇索引记录来说,都是先加锁,后释放锁。
下面再分析该语句在隔离级别不小于REPEATABLE READ时的加锁过程。
对number值为3的聚簇索引记录的加锁过程进行分析
步骤1:读取(1,15]扫描区间的第一条聚簇索引记录,也就是number值为3的聚簇索引记录。
步骤2:为number值为3的聚簇索引记录加S型next-key锁。
步骤3:由于读取的是聚簇索引记录,所以没有索引条件下推的条件。
步骤4:由于读取的本身就是聚簇索引记录,所以不需要执行回表操作。
步骤5:形成扫描区间(1, 15]的边界条件是number > 1 AND number <= 15,很显然number值为3的聚簇索引记录符合该边界条件。
步骤6:server层继续判断number值为3的聚簇索引记录是否符合number > 1 AND number <= 15 AND country = ‘魏’。很显然不符合,但是由于现在的隔离级别不小于REPETABLE READ,所以不会释放掉加在该记录上的锁。
步骤7:获取number值为3的聚簇索引所在单向链表的下一条记录,也就是number值为8的聚簇索引记录。
对number值为8的聚簇索引记录的加锁过程进行分析
步骤2:为number值为8的聚簇索引记录加S型next-key锁。
步骤3:由于读取的是聚簇索引记录,所以没有索引条件下推的条件。
步骤4:由于读取的本身就是聚簇索引记录,所以不需要执行回表操作。
步骤5:形成扫描区间(1, 15]的边界条件是number > 1 AND number <= 15,很显然number值为8的聚簇索引记录符合该边界条件。
步骤6:server层继续判断number值为8的聚簇索引记录是否符合number > 1 AND number <= 15 AND country = ‘魏’。很显然符合,所以将其发送到客户端,并且不释放加在该记录上的锁。
步骤7:获取number值为8的聚簇索引所在单向链表的下一条记录,也就是number值为15的聚簇索引记录。
对number值为15的聚簇索引记录的加锁过程进行分析
步骤2:为number值为15的聚簇索引记录加S型next-key锁。
步骤3:由于读取的是聚簇索引记录,所以没有索引条件下推的条件。
步骤4:由于读取的本身就是聚簇索引记录,所以不需要执行回表操作。
步骤5:形成扫描区间(1, 15]的边界条件是number > 1 AND number <= 15,很显然number值为15的聚簇索引记录符合该边界条件。
步骤6:server层继续判断number值为15的聚簇索引记录是否符合number > 1 AND number <= 15 AND country = ‘魏’。很显然符合,所以将其发送到客户端,并且不释放加在该记录上的锁。
步骤7:获取number值为15的聚簇索引所在单向链表的下一条记录,也就是number值为20的聚簇索引记录。
对number值为20的聚簇索引记录的加锁过程进行分析
步骤2:为number值为20的聚簇索引记录加S型next-key锁。
步骤3:由于读取的是聚簇索引记录,所以没有索引条件下推的条件。
步骤4:由于读取的本身就是聚簇索引记录,所以不需要执行回表操作。
步骤5:形成扫描区间(1, 15]的边界条件是number > 1 AND number <= 15,很显然number值为20的聚簇索引记录不符合该边界条件。但是由于现在的隔离级别不小于REPETABLE READ,所以不会释放掉加在该记录上的锁,之后给server层返回一个查询完毕的信息。
步骤6:server层收到存储引擎返回的查询完毕信息,结束查询。
综上所述,在隔离级别不小于REPETABLE READ的情况下,该语句在执行过程中的加锁效果如下图所示:
上图中,我们最终为number值为3,、8、15、20这几条记录都加了S型next-key锁,并且在语句执行过程中并没有释放某个记录上的锁。这一点与在隔离级别不大于READ COMMITTED的加锁情况是很不一样的,需要大家注意。
实例2:
SELECT * FROM hero FORCE INDEX(idx_name) WHERE name > 'c曹操' AND name <= 'x荀彧' AND country != '吴' LOCK IN SHARE MODE;
我们通过执行计划可以看出,查询优化器将通过range访问方法来读取二级索引idx_name中的一些记录。很显然,我们可以通过搜索条件name > ‘c曹操’ AND name <= 'x荀彧’来生成扫描区间(‘c曹操’,‘x荀彧’],也就是需要扫描name值在(‘c曹操’,‘x荀彧’]区间中的所有二级索引记录。另外,在执行计划的Extra列提示了额外信息Using index condition,这意味着执行该查询时将使用到索引条件下推的条件。
我们首先分析该语句在隔离级别不大于READ COMMITTED时的加锁过程。
对name值为’l刘备’的二级索引记录的加锁过程进行分析
步骤1:读取在(‘c曹操’,‘x荀彧’]扫描区间的第一条二级索引记录,也就是name值为’l刘备’的二级索引记录。
步骤2:为name值为’l刘备’的二级索引记录加S型记录锁。
步骤3:本语句的索引条件下推的条件为name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’l刘备’的二级索引记录符合索引条件下推的条件。
步骤4:我们读取的是二级索引记录,所以需要对该记录执行回表操作,找到相应的聚簇索引记录,也就是number值为1的聚簇索引记录,然后为该聚簇索引记录加一个S型记录锁。
步骤5:形成扫描区间(‘c曹操’,‘x荀彧’]的边界条件是name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’l刘备’的二级索引记录符合该边界条件。
步骤6:server层继续判断name值为’l刘备’的二级索引记录对应的聚簇索引记录是否符合条件country != ‘吴’。很显然符合,所以将其发送到客户端,并且不释放加在该记录上的锁。
步骤7:获取name值为’l刘备’的二级索引记录所在的单向链表的下一条记录,也就是name值为’s孙权’的二级索引记录。
对name值为’s孙权’的二级索引记录的加锁过程进行分析
步骤2:为name值为’s孙权’的二级索引记录加S型记录锁。
步骤3:本语句的索引条件下推的条件为name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’s孙权’的二级索引记录符合索引条件下推的条件。
步骤4:我们读取的是二级索引记录,所以需要对该记录执行回表操作,找到相应的聚簇索引记录,也就是number值为20的聚簇索引记录,然后为该聚簇索引记录加一个S型记录锁。
步骤5:形成扫描区间(‘c曹操’,‘x荀彧’]的边界条件是name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’s孙权’的二级索引记录符合该边界条件。
步骤6:server层继续判断name值为’s孙权’的二级索引记录对应的聚簇索引记录是否符合条件country != ‘吴’。很显然不符合,所以释放掉加在该二级索引记录以及对应的聚簇索引记录上的锁。
步骤7:获取name值为’s孙权’的二级索引记录所在的单向链表的下一条记录,也就是name值为’x荀彧’的二级索引记录。
对name值为’x荀彧’的二级索引记录的加锁过程进行分析
步骤2:为name值为’x荀彧’的二级索引记录加S型记录锁。
步骤3:本语句的索引条件下推的条件为name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’x荀彧’的二级索引记录符合索引条件下推的条件。
步骤4:我们读取的是二级索引记录,所以需要对该记录执行回表操作,找到相应的聚簇索引记录,也就是number值为15的聚簇索引记录,然后为该聚簇索引记录加一个S型记录锁。
步骤5:形成扫描区间(‘c曹操’,‘x荀彧’]的边界条件是name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’x荀彧’的二级索引记录符合该边界条件。
步骤6:server层继续判断name值为’x荀彧’的二级索引记录对应的聚簇索引记录是否符合条件country != ‘吴’。很显然符合,所以将其发送到客户端,并且不释放加在该记录上的锁。
步骤7:获取name值为’x荀彧’的二级索引记录所在的单向链表的下一条记录,也就是name值为’z诸葛亮’的二级索引记录。
对name值为’z诸葛亮’的二级索引记录的加锁过程进行分析
步骤2:为name值为’z诸葛亮’的二级索引记录加S型记录锁。
步骤3:本语句的索引条件下推的条件为name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’z诸葛亮’的二级索引记录不符合索引条件下推的条件,由于它还不符合边界条件,所以就不再去找当前记录的下一条记录了。因此跳过步骤4和步骤5,直接向server层报告查询完毕的信息。
步骤4:本步骤被跳过
步骤5:本步骤被跳过
步骤6:server层收到存储引擎报告的查询完毕信息,结束查询。
综上所述,在隔离级别不大于READ COMMITTED的情况下,该语句在执行过程中的加锁效果如下图所示
需要注意的是,对于name值为’s孙权’的二级索引记录,以及number值为20的聚簇索引记录来说,都是先加锁,后释放锁。另外,name值为’z诸葛亮’的二级索引记录在步骤3中被判断为不符合边界条件,而且该步骤并不会释放加在该记录上的锁,而是直接向server层报告查询完毕信息,因此导致整个查询语句在执行结束后也不会释放加在name值为’z诸葛亮’的二级索引上的锁。
我们再来分析该语句在隔离级别不小于REPETABLE READ时的加锁过程。
对name值为’l刘备’的二级索引记录的加锁过程进行分析
步骤1:读取在(‘c曹操’,‘x荀彧’]扫描区间的第一条二级索引记录,也就是name值为’l刘备’的二级索引记录。
步骤2:为name值为’l刘备’的二级索引记录加S型next-key锁。
步骤3:本语句的索引条件下推的条件为name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’l刘备’的二级索引记录符合索引条件下推的条件。
步骤4:我们读取的是二级索引记录,所以需要对该记录执行回表操作,找到相应的聚簇索引记录,也就是number值为1的聚簇索引记录,然后为该聚簇索引记录加一个S型记录锁。
步骤5:形成扫描区间(‘c曹操’,‘x荀彧’]的边界条件是name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’l刘备’的二级索引记录符合该边界条件。
步骤6:server层继续判断name值为’l刘备’的二级索引记录对应的聚簇索引记录是否符合条件country != ‘吴’。很显然符合,所以将其发送到客户端,并且不释放加在该记录上的锁。
步骤7:获取name值为’l刘备’的二级索引记录所在的单向链表的下一条记录,也就是name值为’s孙权’的二级索引记录。
对name值为’s孙权’的二级索引记录的加锁过程进行分析
步骤2:为name值为’s孙权’的二级索引记录加S型next-key锁。
步骤3:本语句的索引条件下推的条件为name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’s孙权’的二级索引记录符合索引条件下推的条件。
步骤4:我们读取的是二级索引记录,所以需要对该记录执行回表操作,找到相应的聚簇索引记录,也就是number值为20的聚簇索引记录,然后为该聚簇索引记录加一个S型记录锁。
步骤5:形成扫描区间(‘c曹操’,‘x荀彧’]的边界条件是name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’s孙权’的二级索引记录符合该边界条件。
步骤6:server层继续判断name值为’s孙权’的二级索引记录对应的聚簇索引记录是否符合条件country != ‘吴’。很显然不符合,但是由于现在的隔离级别不小于REPETABLE READ,所以不会释放掉加在该记录上的锁。
步骤7:获取name值为’s孙权’的二级索引记录所在的单向链表的下一条记录,也就是name值为’x荀彧’的二级索引记录。
对name值为’x荀彧’的二级索引记录的加锁过程进行分析
步骤2:为name值为’x荀彧’的二级索引记录加S型next-key锁。
步骤3:本语句的索引条件下推的条件为name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’x荀彧’的二级索引记录符合索引条件下推的条件。
步骤4:我们读取的是二级索引记录,所以需要对该记录执行回表操作,找到相应的聚簇索引记录,也就是number值为15的聚簇索引记录,然后为该聚簇索引记录加一个S型记录锁。
步骤5:形成扫描区间(‘c曹操’,‘x荀彧’]的边界条件是name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’x荀彧’的二级索引记录符合该边界条件。
步骤6:server层继续判断name值为’x荀彧’的二级索引记录对应的聚簇索引记录是否符合条件country != ‘吴’。很显然符合,所以将其发送到客户端,并且不释放加在该记录上的锁。
步骤7:获取name值为’x荀彧’的二级索引记录所在的单向链表的下一条记录,也就是name值为’z诸葛亮’的二级索引记录。
对name值为’z诸葛亮’的二级索引记录的加锁过程进行分析
步骤2:为name值为’z诸葛亮’的二级索引记录加S型next-key锁。
步骤3:本语句的索引条件下推的条件为name > ‘c曹操’ AND name <= ‘x荀彧’,很显然name值为’z诸葛亮’的二级索引记录不符合索引条件下推的条件,由于它还不符合边界条件,所以就不再去找当前记录的下一条记录了。因此跳过步骤4和步骤5,直接向server层报告查询完毕的信息。
步骤4:本步骤被跳过
步骤5:本步骤被跳过
步骤6:server层收到存储引擎报告的查询完毕信息,结束查询。
综上所述,在隔离级别不小于REPETABLE READ的情况下,该语句在执行过程中的加锁效果如下图所示:
在图中,在隔离级别不小于REPETABLE READ的情况下,该语句对name值为’l刘备’、‘s孙权’、‘x荀彧’、‘z诸葛亮’的二级索引记录都加了S型next-key锁,对number值为1、15、20的聚簇索引记录加了S型记录锁。
上文的两个实例都是以SELECT…LOCK IN SHARE MODE语句为例来介绍如何为记录加锁的。SELECT…FOR UPDATE语句的加锁过程与SELECT…LOCK IN SHARE MODE语句类似,只不过为记录加的是X锁。
对于UPDATE语句来说,加锁方式与SELECT…FOR UPDATE语句类似。不过,如果更新了二级索引列,那么所有被更新的二级索引记录在更新之前都需要加X记录锁。
在介绍完一般情况下锁定读的加锁过程后,下面该介绍一些比较特殊的情况了。
当隔离级别不大于READ COMMITTED时,如果匹配模式为精确匹配,则不会为扫描区间的后面的下一条记录加锁。
比如:
SELECT * FROM hero WHERE name = 'c曹操' FOR UPDATE;
执行计划显示,查询优化器决定使用二级索引idx_name,需要扫描单点扫描区间[‘c曹操’,‘c曹操’]中的二级索引记录。在读取完name值为’c曹操’的二级索引记录后,获取到下一条二级索引记录,也就是name值为’l刘备’的二级索引记录。由于这里的模式是精确匹配,因此在存储引擎内部就判断出该记录不符合精确匹配条件,所以直接向server层报告查询完毕信息,而不再是先给该记录加锁,然后再交给server层判断是否要释放锁。
所以在隔离级别不大于READ COMMITTED时,该语句执行时的加锁情况如下图所示:
当隔离级别不小于REPETABLE READ时,如果匹配模式为精确匹配,则会为扫描区间后面的下一条记录加gap锁。
比如:
SELECT * FROM hero WHERE name = 'c曹操' FOR UPDATE;
这条查询语句执行计划显示,查询优化器决定使用二级索引idx_name,需要扫描单点扫描区间[‘c曹操’,‘c曹操’]中的二级索引记录。在读取完name值为’c曹操’的二级索引记录后,获取到下一条二级索引记录,也就是name值为’l刘备’的二级索引记录。由于这里的模式是精确匹配,因此在存储引擎内部就判断出该记录不符合精确匹配条件,所以向该记录加一个gap锁,之后向server层报告查询完毕信息。
所以在隔离级别不小于REPETABLE READ时,该语句执行时的加锁情况如下图所示:
有时,扫描区间中没有记录,那么也要为扫描区间后面的下一条记录加一个gap锁。比如:
SELECT * FROM hero WHERE name = 'g关羽' FOR UPDATE;
执行计划显示,查询优化器决定使用二级索引idx_name,需要扫描单点扫描区间[‘g关羽’,‘g关羽’]中的二级索引记录。遗憾的是不存在name值为’g关羽’的二级索引记录,所以需要为[‘g关羽’,‘g关羽’]扫描区间后面的一条记录,也就是name值为‘l刘备’的记录加gap锁,目的是防止别的记录插入name值在(‘c曹操’,‘l刘备’)之间的二级索引记录。
当隔离级别不小于REPETABLE READ时,如果匹配模式为精确匹配,并且没有找到匹配的记录,则会为该扫描区间后面的下一条记录加next-key锁。
比如:
SELECT * FROM hero WHERE name > 'd' AND name < 'l' FOR UPDATE;
执行计划显示,查询优化器决定使用二级索引idx_name,需要扫描区间(‘d’,‘l’)中的二级索引记录。遗憾的是不存在name值在(‘d’,‘l’)中的二级索引记录,所以需要为(‘d’,‘l’)扫描区间后面的下一条记录,也就是name值为’l刘备’的记录加next-key锁。
所以在隔离级别不小于REPETABLE READ时,该语句执行时的加锁情况如下图所示:
当隔离级别不小于REPETABLE READ时,如果使用的是聚簇索引,并且扫描的扫描区间是左闭区间,而且定位到的第一条聚簇索引记录的number值正好与扫描区间中的最小值相同,那么会为该聚簇索引记录加记录锁。
比如:
SELECT * FROM hero WHERE number >= 8 FOR UPDATE;
执行计划显示,查询优化器决定使用聚簇索引,需要扫描扫描区间[8,正无穷)中的聚簇索引记录。由于是左闭区间,而且我们表中正好存在一条number值为8的聚簇索引记录,所以会对这条number值为8的聚簇索引记录只添加记录锁。
所以在隔离级别不小于REPETABLE READ时,该语句执行时的加锁情况如下图所示:
从图中可以看到,为number值为8的聚簇索引加了记录锁,为扫描到的其他记录加了next-key锁。
无论是哪个隔离级别,只要是唯一性搜索,并且读取的记录没有被标记为已删除,就为读取到的记录加记录锁。
比如:
SELECT * FROM hero number = 8 FOR UPDATE;
我们在扫描某个扫描区间中的记录时,一般都是按照从左到右的顺序进行扫描,但是有些情况下需要从右到左进行扫描。那么当隔离级别不小于REPETABLE READ,并且按照从右到左的顺序扫描扫描区间中的记录时,会给匹配到的第一条记录的下一条记录加gap锁。
半一致性读的语句
半一致性读是一种夹在一致性读和锁定读之间的读取方式。当隔离级别不大于READ COMMITTED且执行UPDATE语句时将使用半一致性读。所谓半一致性读,就是当UPDATE语句读取到已经被其他事务加了X锁的记录时,InnoDB会将该记录的最新版本读出来,然后判断该版本是否与UPDATE语句中的搜索条件相匹配。如果不匹配,则不对该记录加锁,从而跳到下一条记录;如果匹配,则再次读取该记录并对其进行加锁。这样处理只是为了让UPDATE语句尽量少被别的语句阻塞。
假如事务T1的隔离级别READ COMMITTED,T1执行了下面的这条语句。
SELECT * FROM hero WHERE number = 8 FOR UPDATE;
该语句在执行的时候对number为8的聚簇索引记录加了X型记录锁。
此时隔离级别也为READ COMMITTED的事务T2执行了如下语句:
UPDATE hero SET name = 'cao曹操' WHERE number >= 8 AND number country != '魏';
该语句在执行时需要依次获取number值为8,15,20的聚簇索引记录的X型记录锁。由于T1已经获取了number值为8的聚簇索引记录的X型记录锁,按理说此时事务T2应该由于获取不到number值为8的聚簇索引记录的X型记录锁而阻塞。但是由于进行的是半一致性读,所以存储引擎会先获取number值为8的聚簇索引记录最新提交的版本并返回给server层。该版本的country值为’魏’,很显然不符合country!='魏’的条件,所以server层决定放弃获取number值为8的聚簇索引记录上的X型记录锁,转而让存储引擎读取下一条记录。
INSERT语句
前文说过,INSERT语句在一般情况下不需要在内存中生成锁结构,并单纯依靠隐式锁保护插入记录。不过当前事务在插入一条记录前,需要先定位该记录在B+树中的位置。如果该记录的下一条记录已经被加了gap锁(next-key页包含gap锁),那么当前事务会为该记录加上一种类型为插入意向锁的锁,并且事务进入等待状态。
1、遇到重复键(duplicate key)
在插入一条新记录时,首先要做的是确定这条新记录应该插入到B+树的哪个位置。如果在确定位置时发现有记录的主键或者唯一索引列与待插入记录的主键或者唯一二级索引列相同,此时会报错。当然在生成报错信息前,其实还需要做一件非常重要的事-对聚簇索引中number值为20的记录加S锁。不过加锁的具体类型在不同的隔离级别下是不一样的。
- 当隔离级别不大于READ UNCOMMITTED时,加的是S型记录锁。
- 当隔离级别不小于REPETABLE READ时,加的是S型next-key锁。
如果是唯一二级索引列的值重复,比如我们把普通二级索引idx_name改为唯一二级索引ux_name。需要注意的是,无论是哪个隔离级别,如果在插入新纪录时遇到唯一二级索引列重复,都会对已经在B+树中的那条唯一二级索引记录加next-key锁。
另外,在使用INSERT…ON DUPLICATE KEY这样的语法来插入记录时,如果遇到主键或者唯一二级索引列的值重复,会对B+树已存在的相同键值的记录加X锁,而不是S锁。
总结
MVCC和加锁是解决并发事务带来一致性问题的两种方式。
共享锁简称为S锁,独占锁简称为X锁。S锁与S锁兼容;X锁与S锁不兼容;与X锁也不兼容。
事务利用MVCC进行的读取操作称为一致性读,在读取记录前加锁的读取操作称为锁定读。设计者提供了两种语法来进行锁定读:
- SELECT… LOCK IN SHARE MODE 语句为读取的记录加S锁
- SELECT… FOR UPDATE 语句为读取的记录加X锁
INSERT语句一般情况下不要在内存中生成锁结构,并单纯依靠隐式锁保护插入的记录。UPDATE和DELETE语句在执行过程中,在B+树中定位到待改动的记录并给记录加锁的过程也算是一个锁定读。
IS、IX是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时,可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中没有上锁的记录。
语句加锁的情况受到所在事务的隔离级别,语句执行时使用的索引类型、是否精确匹配是否唯一性搜索具体执行的语句类型等情况制约的。
不同事务由于相互持有对方需要的锁而导致事务都无法继续执行的情况称为死锁。死锁发生时,InnoDB会选择一个较小的事务进行回滚。