【MySQL进阶】MySQL事务隔离与锁机制底层原理万字总结(建议收藏!!)
参考资料:
美团技术团队:Innodb中事务隔离级别和锁的关系
数据库的锁,到底锁的是什么?
阿里面试:说说一致性读实现原理?
MySQL 默认隔离级别是RR,为什么阿里等大厂会改成RC?
字节面试:加了什么锁,导致死锁的?
我的阿里二面,为什么MySQL选择Repeatable Read作为默认隔离级别?
全解MySQL之死锁问题分析、事务隔离与锁机制的底层原理剖析
MySQL 可重复读隔离级别,完全解决幻读了吗?
MySQL 记录锁+间隙锁可以防止删除操作而导致的幻读吗?
MySQL 是怎么加锁的?
update 没加索引会锁全表?
【MySQL进阶】多版本并发控制——MVCC
书籍:《MySQL是怎样运行的》
文章目录
- 【MySQL进阶】MySQL事务隔离与锁机制底层原理万字总结(建议收藏!!)
- 一:解决并发事务带来问题的两种基本方式
- 1:并发事务带来的问题
- 2:两种可选的解决方案
- 方案一:读操作利用多版本并发控制( MVCC ),写操作进行 加锁
- 方案二:读、写操作都采用 加锁 的方式
- 3:一致性读(Consistent Reads)
- 4:锁定读(Locking Reads)
- 共享锁和独占锁
- 锁定读的语句
- 5:写操作
- 二:多粒度锁
- 1:共享锁( S锁 )和 独占锁 ( X锁 )的多粒度
- 2:意向锁
- 三:MySQL中的行锁和表锁
- 1:其他存储引擎中的锁
- 2:InnoDB存储引擎中的锁
- 表级别的 S锁 、 X锁
- 表级别的 IS锁 、 IX锁
- 表级别的 AUTO-INC锁
- 行级别的 Record Locks锁
- 行级别的 Gap Locks锁
- 行级别的 Next-Key Locks锁
- 行级别的 Insert Intention Locks锁
- 行级别的 隐式锁
- 小争议:MVCC机制是否彻底解决了幻读问题呢?
- 四:事务隔离机制的底层实现
- 1:RU(Read Uncommitted)读未提交级别的实现
- 2:RC(Read Committed)读已提交级别的实现
- 3:RR(Repeatable Read)可重复读级别的实现
- 4:Serializable序列化级别的实现
- 五:事务与锁机制原理篇总结
一:解决并发事务带来问题的两种基本方式
1:并发事务带来的问题
-
读 - 读 情况:即并发事务相继读取相同的记录。
读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。
-
写 - 写 情况:即并发事务相继对相同的记录做出改动。
在这种情况下会发生 脏写 的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过 锁 来实现的。
这个所谓的 锁 其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有 锁结构 和记录进行关联的,如图所示:当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的 锁结构 ,当没有的时候就会在内存中生成一个 锁结构 与之关联。比方说事务 T1 要对这条记录做改动,就需要生成一个 锁结构 与之关联:
其实在 锁结构 里有很多信息,不过为了简化理解,我们现在只把两个比较重要的属性拿了出来:
-
trx信息
:代表这个锁结构是哪个事务生成的 -
is_waiting
:代表当前事务是否在等待如图所示,当事务 T1 改动了这条记录后,就生成了一个 锁结构 与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting 属性就是 false ,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。
在事务 T1 提交之前,另一个事务 T2 也想对该记录做改动,那么先去看看有没有 锁结构 与这条记录关联,发现有一个 锁结构 与之关联后,然后也生成了一个 锁结构 与这条记录关联,不过 锁结构 的is_waiting 属性值为 true ,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,或者没有成功的获取到锁,画个图表示就是这样:
在事务 T1 提交之后,就会把该事务生成的 锁结构 释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务 T2 还在等待获取锁,所以把事务 T2 对应的锁结构的 is_waiting 属性设置为 false ,然后把该事务对应的线程唤醒,让它继续执行,此时事务 T2 就算获取到锁了。效果图就是这样:
我们总结一下后续内容中可能用到的几种说法,以免大家混淆:
-
不加锁
意思就是不需要在内存中生成对应的 锁结构 ,可以直接执行操作。
-
获取锁成功,或者加锁成功
意思就是在内存中生成了对应的 锁结构 ,而且锁结构的 is_waiting 属性为 false ,也就是事务可以继续执行操作。
-
获取锁失败,或者加锁失败,或者没有获取到锁
意思就是在内存中生成了对应的 锁结构 ,不过锁结构的 is_waiting 属性为 true ,也就是事务需要等待,不可以继续执行操作。
-
-
读 - 写 或 写 - 读 情况:也就是一个事务进行读取操作,另一个进行改动操作。
我们前边说过,这种情况下可能发生 脏读 、 不可重复读 、 幻读 的问题。
小贴士:
幻读问题的产生是因为某个事务读了一个范围的记录,之后别的事务在该范围内插入了新记录,该事务再次读取该范围的记录时,可以读到新插入的记录,所以幻读问题准确的说并不是因为读取和写入一条相同记录而产生的。
SQL标准 规定不同隔离级别下可能发生的问题不一样:
- 在 READ UNCOMMITTED 隔离级别下, 脏读 、 不可重复读 、 幻读 都可能发生。
- 在 READ COMMITTED 隔离级别下, 不可重复读 、 幻读 可能发生, 脏读 不可以发生。
- 在 REPEATABLE READ 隔离级别下, 幻读 可能发生, 脏读 和 不可重复读 不可以发生。
- 在 SERIALIZABLE 隔离级别下,上述问题都不可以发生。
2:两种可选的解决方案
-
方案一:读操作利用多版本并发控制( MVCC ),写操作进行 加锁
所谓的 MVCC 我们在前一章有过详细的描述,就是通过生成一个 ReadView ,然后通过 ReadView 找到符合条件的记录版本(历史版本是由 undo日志 构建的),其实就像是在生成 ReadView 的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成 ReadView 之前已提交事务所做的更改,在生成ReadView 之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC 时, 读-写 操作并不突。
小贴士:
我们说过普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题。
-
方案二:读、写操作都采用 加锁 的方式
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行 加锁 操作,这样也就意味着 读 操作和 写 操作也像 写-写 操作那样排队执行。
小贴士:
我们说脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。我们说幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,我们把新插入的那些记录称之为幻影记录。采用加锁的方式解决幻读问题就有那么一丢丢麻烦了,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以读取的时候加锁就有点尴尬 —— 因为你并不知道给谁加锁,没关系,这难不倒设计InnoDB的大叔的。
很明显,采用 MVCC 方式的话, 读-写 操作彼此并不冲突,性能更高,采用 加锁 方式的话, 读-写 操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用 MVCC 来解决 读-写 操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用 加锁 的方式执行,那也是没有办法的事。
3:一致性读(Consistent Reads)
事务利用 MVCC 进行的读取操作称之为 一致性读 ,或者 一致性无锁读 ,有的地方也称之为 快照读 。所有普通的 SELECT 语句( plain SELECT )在 READ COMMITTED 、 REPEATABLE READ 隔离级别下都算是 一致性读 ,比方说:
SELECT * FROM t;
SELECT * FROM t1 INNER JOIN t2 ON t1.col1 = t2.col2
一致性读 并不会对表中的任何记录做 加锁 操作,其他事务可以自由的对表中的记录做改动。
4:锁定读(Locking Reads)
共享锁和独占锁
我们前边说过,并发事务的 读-读 情况并不会引起什么问题,不过对于 写-写 、 读-写 或 写-读 这些情况可能会引起一些问题,需要使用 MVCC 或者 加锁 的方式来解决它们。在使用 加锁 的方式解决问题时,由于既要允许 读-读 情况不受影响,又要使 写-写 、 读-写 或 写-读 情况中的操作相互阻塞,所以设计 MySQL 的大叔给锁分了个类:
- 共享锁 :英文名: Shared Locks ,简称 S锁 。在事务要读取一条记录时,需要先获取该记录的 S锁 。
- 独占锁 :也常称 排他锁 ,英文名: Exclusive Locks ,简称 X锁 。在事务要改动一条记录时,需要先获取该记录的 X锁 。
假如事务 T1 首先获取了一条记录的 S锁 之后,事务 T2 接着也要访问这条记录:
- 如果事务 T2 想要再获取一个记录的 S锁 ,那么事务 T2 也会获得该锁,也就意味着事务 T1 和 T2 在该记录上同时持有 S锁 。
- 如果事务 T2 想要再获取一个记录的 X锁 ,那么此操作会被阻塞,直到事务 T1 提交之后将 S锁 释放掉。
如果事务 T1 首先获取了一条记录的 X锁 之后,那么不管事务 T2 接着想获取该记录的 S锁 还是 X锁 都会被阻塞,直到事务 T1 提交。
所以我们说 S锁 和 S锁 是兼容的, S锁 和 X锁 是不兼容的, X锁 和 X锁 也是不兼容的,画个表表示一下就是这样:
锁定读的语句
我们前边说在采用 加锁 方式解决 脏读 、 不可重复读 、 幻读 这些问题时,读取一条记录时需要获取一下该记录的 S锁 ,其实这是不严谨的,有时候想在读取记录时就获取记录的 X锁 ,来禁止别的事务读写该记录,为此设计 MySQL 的大叔提出了两种比较特殊的 SELECT 语句格式:
-
对读取的记录加 S锁 :
SELECT ... LOCK IN SHARE MODE;
也就是在普通的 SELECT 语句后边加 LOCK IN SHARE MODE ,如果当前事务执行了该语句,那么它会为读取到的记录加 S锁 ,这样允许别的事务继续获取这些记录的 S锁 (比方说别的事务也使用 SELECT … LOCK INSHARE MODE 语句来读取这些记录),但是不能获取这些记录的 X锁 (比方说使用 SELECT … FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的 X锁 ,那么它们会阻塞,直到当前事务提交之后将这些记录上的 S锁 释放掉。
-
对读取的记录加 X锁 :
SELECT ... FOR UPDATE;
也就是在普通的 SELECT 语句后边加 FOR UPDATE ,如果当前事务执行了该语句,那么它会为读取到的记录加 X锁 ,这样既不允许别的事务获取这些记录的 S锁 (比方说别的事务使用 SELECT … LOCK IN SHAREMODE 语句来读取这些记录),也不允许获取这些记录的 X锁 (比方也说使用 SELECT … FOR UPDATE 语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的 S锁 或者 X锁 ,那么它们会阻塞,直到当前事务提交之后将这些记录上的 X锁 释放掉。
5:写操作
平常所用到的 写操作 无非是 DELETE
、 UPDATE
、 INSERT
这三种:
-
DELETE :
对一条记录做 DELETE 操作的过程其实是先在 B+ 树中定位到这条记录的位置,然后获取一下这条记录的 X锁 ,然后再执行 delete mark 操作。我们也可以把这个定位待删除记录在 B+ 树中位置的过程看成是一个获取 X锁 的 锁定读 。
-
UPDATE :
在对一条记录做 UPDATE 操作时分为三种情况:
- 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在 B+ 树中定位到这条记录的位置,然后再获取一下记录的 X锁 ,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在 B+ 树中位置的过程看成是一个获取 X锁 的 锁定读 。
- 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+ 树中定位到这条记录的位置,然后获取一下记录的 X锁 ,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在 B+ 树中位置的过程看成是一个获取 X锁 的 锁定读 ,新插入的记录由 INSERT 操作提供的 隐式锁 进行保护。
- 如果修改了该记录的键值,则相当于在原记录上做 DELETE 操作之后再来一次 INSERT 操作,加锁操作就需要按照 DELETE 和 INSERT 的规则进行了。
-
INSERT :
一般情况下,新插入一条记录的操作并不加锁,设计 InnoDB 的大叔通过一种称之为 隐式锁 的东东来保护这条新插入的记录在本事务提交前不被别的事务访问,更多细节我们后边看哈~
二:多粒度锁
1:共享锁( S锁 )和 独占锁 ( X锁 )的多粒度
我们前边提到的 锁 都是针对记录的,也可以被称之为 行级锁 或者 行锁 ,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在 表 级别进行加锁,自然就被称之为 表级锁 或者 表锁 ,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为 共享锁( 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 Locks )的东东。
2:意向锁
- 意向共享锁,英文名: 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锁的,维修工在教学楼门口加IX锁时,是不关心教学楼门口是否有IS锁或者其他IX锁的。IS和IX锁只是为了判断当前时间教学楼里有没有被占用的教室用的,也就是在对教学楼加S锁或者X锁时才会用到。
总结一下:IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。我们画个表来看一下表级别的各种锁的兼容性:
三:MySQL中的行锁和表锁
上边说的都算是些理论知识,其实 MySQL 支持多种存储引擎,不同存储引擎对锁的支持也是不一样的。当然,我们重点还是讨论 InnoDB 存储引擎中的锁,其他的存储引擎只是稍微提一下~
1:其他存储引擎中的锁
对于 MyISAM 、 MEMORY 、 MERGE 这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,所以使用这些存储引擎的锁一般都是针对当前会话来说的。比方说在 Session 1 中对一个表执行 SELECT 操作,就相当于为这个表加了一个表级别的 S锁 ,如果在 SELECT 操作未完成时, Session 2 中对这个表执行 UPDATE 操作,相当于要获取表的 X锁 ,此操作会被阻塞,直到 Session 1 中的 SELECT 操作完成,释放掉表级别的 S锁 后,Session 2 中对这个表执行 UPDATE 操作才能继续获取 X锁 ,然后执行具体的更新语句。
小贴士:
因为使用MyISAM、MEMORY、MERGE这些存储引擎的表在同一时刻只允许一个会话对表进行写操作,所以这些存储引擎实际上最好用在只读,或者大部分都是读操作,或者单用户的情景下。另外,在MyISAM存储引擎中有一个称之为Concurrent Inserts的特性,支持在对MyISAM表读取时同时插入记录,这样可以提升一些插入速度。
2:InnoDB存储引擎中的锁
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 Locks ,简称 MDL )东东来实现的,一般情况下也不会使用 InnoDB 存储引擎自己提供的表级别的 S锁 和 X锁 。
其实这个 InnoDB 存储引擎提供的表级 S锁 或者 X锁 是相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到。不过我们还是可以手动获取一下的,比方说在系统变量 autocommit=0,innodb_table_locks =1 时,手动获取 InnoDB 存储引擎提供的表 t 的 S锁 或者 X锁 可以这么写:
- LOCK TABLES t READ : InnoDB 存储引擎会对表 t 加表级别的 S锁 。
- LOCK TABLES t WRITE : InnoDB 存储引擎会对表 t 加表级别的 X锁 。
不过请尽量避免在使用 InnoDB 存储引擎的表上使用 LOCK TABLES 这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。 InnoDB 的厉害之处还是实现了更细粒度的行锁,关于表级别的 S锁 和 X锁 大家了解一下就罢了。
表级别的 IS锁 、 IX锁
当我们在对使用 InnoDB 存储引擎的表的某些记录加 S锁 之前,那就需要先在表级别加一个 IS锁 ,当我们在对使用 InnoDB 存储引擎的表的某些记录加 X锁 之前,那就需要先在表级别加一个 IX锁 。 IS锁 和 IX锁的使命只是为了后续在加表级别的 S锁 和 X锁 时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。
表级别的 AUTO-INC锁
在使用 MySQL 过程中,我们可以为表的某个列添加 AUTO_INCREMENT 属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值,比方说我们有一个表:
CREATE TABLE t (
id INT NOT NULL AUTO_INCREMENT,
c VARCHAR(100),
PRIMARY KEY (id)
) Engine=InnoDB CHARSET=utf8;
由于这个表的 id 字段声明了 AUTO_INCREMENT ,也就意味着在书写插入语句时不需要为其赋值,比方说这样:
INSERT INTO t(c) VALUES('aa'), ('bb');
上边的插入语句并没有为 id 列显式赋值,所以系统会自动为它赋上递增的值,效果就是这样:
系统实现这种自动给 AUTO_INCREMENT 修饰的列递增赋值的原理主要是两个:
-
采用 AUTO-INC 锁,也就是在执行插入语句时就在表级别加一个 AUTO-INC 锁,然后为每条待插入记录的 AUTO_INCREMENT 修饰的列分配递增的值,在该语句执行结束后,再把 AUTO-INC 锁释放掉。这样一个事务在持有 AUTO-INC 锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
如果我们的插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量),比方说使用 INSERT … SELECT 、 REPLACE … SELECT 或者 LOAD DATA 这种插入语句,一般是使用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修饰的列生成的值是交叉的,在有主从复制的场景中是不安全的。
介绍行级锁前的准备
行锁 ,也称为 记录锁 ,顾名思义就是在记录上加的锁。不过设计 InnoDB 的大叔很有才,一个 行锁 玩出了各种花样,也就是把 行锁 分成了各种类型。换句话说即使对同一条记录加 行锁 ,如果类型不同,起到的功效也是不同的。为了故事的顺利发展,我们还是先将之前唠叨 MVCC 时用到的表抄一遍:
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number),
KEY idx_name (name)
) Engine=InnoDB CHARSET=utf8;
INSERT INTO hero VALUES
(1, 'l刘备', '蜀'),
(3, 'z诸葛亮', '蜀'),
(8, 'c曹操', '魏'),
(15, 'x荀彧', '魏'),
(20, 's孙权', '吴');
现在表里的数据就是这样的:
我们把 hero 表中的聚簇索引的示意图画一下:
当然,我们把 B+树 的索引结构做了一个超级简化,只把索引中的记录给拿了出来,我们这里只是想强调聚簇索引中的记录是按照主键大小排序的,并且省略掉了聚簇索引中的隐藏列(不理解索引结构的话可以去前边的文章中查看)。
【MySQL进阶】深入理解B+树索引底层原理
现在准备工作做完了,下边我们来看看都有哪些常用的 行锁类型 。
行级别的 Record Locks锁
我们前边提到的记录锁就是这种类型,也就是仅仅把一条记录锁上,我决定给这种类型的锁起一个比较不正经的名字: 正经记录锁 。官方的类型名称为:LOCK_REC_NOT_GAP 。比方说我们把 number 值为 8 的那条记录加一个 正经记录锁 的示意图如下:
正经记录锁 是有 S锁 和 X锁 之分的,让我们分别称之为 S型正经记录锁 和 X型正经记录锁 吧(听起来有点怪怪的),当一个事务获取了一条记录的 S型正经记录锁 后,其他事务也可以继续获取该记录的 S型正经记录锁 ,但不可以继续获取 X型正经记录锁 ;当一个事务获取了一条记录的 X型正经记录锁 后,其他事务既不可以继续获取该记录的 S型正经记录锁 ,也不可以继续获取 X型正经记录锁 ;
行级别的 Gap Locks锁
我们说 MySQL 在 REPEATABLE READ 隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用 MVCC 方案解决,也可以采用 加锁 方案解决。但是在使用 加锁 方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上 正经记录锁 。
不过这难不倒设计 InnoDB 的大叔,他们提出了一种称之为 Gap Locks 的锁,官方的类型名称为: LOCK_GAP ,我们也可以简称为 gap锁 。比方说我们把 number 值为 8 的那条记录加一个 gap锁 的示意图如下:
如图中为 number 值为 8 的记录加了 gap锁 ,意味着不允许别的事务在 number 值为 8 的记录前边的 间隙插入新记录,其实就是 number 列的值 (3, 8) 这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条 number 值为 4 的新记录,它定位到该条新记录的下一条记录的 number 值为8,而这条记录上又有一个 gap锁 ,所以就会阻塞插入操作,直到拥有这个 gap锁 的事务提交了之后, number 列的值在区间 (3, 8) 中的新记录才可以被插入。
这个 gap锁 的提出仅仅是为了防止插入幻影记录而提出的,虽然有 共享gap锁 和 独占gap锁 这样的说法,但是它们起到的作用都是相同的。而且如果你对一条记录加了 gap锁 (不论是 共享gap锁 还是 独占gap锁 ),并不会限制其他事务对这条记录加 正经记录锁 或者继续加 gap锁 ,再强调一遍, gap锁 的作用仅仅是为了防止插入幻影记录的而已。
不知道大家发现了一个问题没,给一条记录加了 gap锁 只是不允许其他事务往这条记录前边的间隙插入新记录,那对于最后一条记录之后的间隙,也就是 hero 表中 number 值为 20 的记录之后的间隙该咋办呢?也就是说给哪条记录加 gap锁 才能阻止其他事务插入 number 值在 (20, +∞) 这个区间的新记录呢?这时候应该想起我们在前边唠叨 数据页 时介绍的两条伪记录了:
Infimum 记录
:表示该页面中最小的记录。Supremum 记录
:表示该页面中最大的记录。
为了实现阻止其他事务插入 number 值在 (20, +∞) 这个区间的新记录,我们可以给索引中的最后一条记录,也就是number 值为 20 的那条记录所在页面的 Supremum 记录加上一个 gap锁 ,画个图就是这样:
这样就可以阻止其他事务插入 number 值在 (20, +∞) 这个区间的新记录。为了大家理解方便,之后的索引示意图中都会把这个 Supremum 记录画出来。
行级别的 Next-Key Locks锁
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的 间隙 插入新记录,所以设计 InnoDB 的大叔们就提出了一种称之为 Next-Key Locks 的锁,官方的类型名称为: LOCK_ORDINARY ,我们也可以简称为next-key锁 。比方说我们把 number 值为 8 的那条记录加一个 next-key锁 的示意图如下:
next-key锁 的本质就是一个 正经记录锁 和一个 gap锁 的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的 间隙 。
行级别的 Insert Intention Locks锁
我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的 gap锁 ( next-key锁 也包含 gap锁 ,后边就不强调了),如果有的话,插入操作需要等待,直到拥有 gap锁 的那个事务提交。但是设计 InnoDB 的大叔规定事务在等待的时候也需要在内存中生成一个 锁结构 ,表明有事务想在某个 间隙 中插入新记录,但是现在在等待。设计 InnoDB 的大叔就把这种类型的锁命名为 Insert IntentionLocks ,官方的类型名称为: LOCK_INSERT_INTENTION ,我们也可以称为 插入意向锁 。
比方说我们把 number 值为 8 的那条记录加一个 插入意向锁 的示意图如下:
为了让大家彻底理解这个 插入意向锁 的功能,我们还是举个例子然后画个图表示一下。比方说现在 T1 为number 值为 8 的记录加了一个 gap锁 ,然后 T2 和 T3 分别想向 hero 表中插入 number 值分别为 4 、 5 的两条记录,所以现在为 number 值为 8 的记录加的锁的示意图就如下所示:
小贴士:
我们在锁结构中又新添了一个type属性,表明该锁的类型。
从图中可以看到,由于 T1 持有 gap锁 ,所以 T2 和 T3 需要生成一个 插入意向锁 的 锁结构 并且处于等待状态。当 T1 提交后会把它获取到的锁都释放掉,这样 T2 和 T3 就能获取到对应的 插入意向锁 了(本质上就是把插入意向锁对应锁结构的 is_waiting 属性改为 false ), T2 和 T3 之间也并不会相互阻塞,它们可以同时获取到 number 值为8的 插入意向锁 ,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁( 插入意向锁 就是这么鸡肋)。
行级别的 隐式锁
我们前边说一个事务在执行 INSERT 操作时,如果即将插入的 间隙 已经被其他事务加了 gap锁 ,那么本次INSERT 操作会阻塞,并且当前事务会在该间隙上加一个 插入意向锁 ,否则一般情况下 INSERT 操作是不加锁的。那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务:
-
立即使用 SELECT … LOCK IN SHARE MODE 语句读取这条事务,也就是在要获取这条记录的 S锁 ,或者使用 SELECT … FOR UPDATE 语句读取这条事务或者直接修改这条记录,也就是要获取这条记录的 X锁 ,该咋办?
如果允许这种情况的发生,那么可能产生 脏读 问题。
-
立即修改这条记录,也就是要获取这条记录的 X锁 ,该咋办?
如果允许这种情况的发生,那么可能产生 脏写 问题。
这时候我们前边唠叨了很多遍的 事务id 又要起作用了。我们把聚簇索引和二级索引中的记录分开看一下:
- 情景一:对于聚簇索引记录来说,有一个 trx_id 隐藏列,该隐藏列记录着最后改动该记录的 事务id 。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的 trx_id 隐藏列代表的的就是当前事务的事务id ,如果其他事务此时想对该记录添加 S锁 或者 X锁 时,首先会看一下该记录的 trx_id 隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个 X锁 (也就是为当前事务创建一个锁结构, is_waiting 属性是 false ),然后自己进入等待状态(也就是为自己也创建一个锁结构, is_waiting 属性是 true )。
- 情景二:对于二级索引记录来说,本身并没有 trx_id 隐藏列,但是在二级索引页面的 Page Header 部分有一个 PAGE_MAX_TRX_ID 属性,该属性代表对该页面做改动的最大的 事务id ,如果PAGE_MAX_TRX_ID 属性值小于当前最小的活跃 事务id ,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复 情景一 的做法。
通过上边的叙述我们知道,一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id 这个牛逼的东东的存在,相当于加了一个 隐式锁 。别的事务在对这条记录加 S锁 或者 X锁时,由于 隐式锁 的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。
小争议:MVCC机制是否彻底解决了幻读问题呢?
普通的 SELECT 语句在:
- READ UNCOMMITTED 隔离级别下,不加锁,直接读取记录的最新版本,可能发生 脏读 、 不可重复读 和 幻读 问题。
- READ COMMITTED 隔离级别下,不加锁,在每次执行普通的 SELECT 语句时都会生成一个 ReadView ,这样解决了 脏读 问题,但没有解决 不可重复读 和 幻读 问题。
- 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 row affected (0.01 sec)
mysql> SELECT * FROM hero WHERE number = 30;
+--------+---------+---------+
| number | name | country |
+--------+---------+---------+
| 30 | g关羽 | 蜀 |
+--------+---------+---------+
1 row in set (0.01 sec)
在 REPEATABLE READ 隔离级别下, T1 第一次执行普通的 SELECT 语句时生成了一个 ReadView ,之后 T2 向 hero 表中新插入了一条记录便提交了, ReadView 并不能阻止 T1 执行 UPDATE 或者 DELETE 语句来对改动这个新插入的记录(因为 T2 已经提交,改动该记录并不会造成阻塞),但是这样一来这条新记录的 trx_id 隐藏列就变成了 T1 的 事务id ,之后 T1 中再使用普通的 SELECT 语句去查询这条记录时就可以看到这条记录了,也就把这条记录返回给客户端了。因为这个特殊现象的存在,你也可以认为 InnoDB 中的 MVCC 并不能完完全全的禁止幻读。
实际上这个问题有点四不像,可以理解成幻读问题,也可以理解成是不可重复读问题,总之不管怎么说,就是
MVCC
机制存在些许问题!但这种情况线下一般不会发生,毕竟不同事务之间都是互不相知的,在一个事务中,不可能会去主动修改一条“不存在”的记录。
但如若你实在不放心,想要彻底杜绝任何风险的出现,那就直接将事务隔离级别调整到Serializable
即可。
四:事务隔离机制的底层实现
对于事务隔离机制的底层实现,其实在前面的章节中简单聊到过,对于并发事务造成的各类问题,在不同的隔离级别实际上,是通过不同粒度、类型的锁以及MVCC
机制来解决的,也就是调整了并发事务的执行顺序,从而避免了这些问题产生,具体是如何做的呢?
RU
/读未提交级别:要求该隔离级别下解决脏写问题。RC
/读已提交级别:要求该隔离级别下解决脏读问题。RR
/可重复读级别:要求该隔离级别下解决不可重复读问题。Serializable
/序列化级别:要求在该隔离级别下解决幻读问题。
虽然DBMS
中要求在序列化级别再解决幻读问题,但在MySQL
中,RR
级别中就已经解决了幻读问题,因此MySQL
中可以将RR
级别视为最高级别,而Serializable
级别几乎用不到,因为序列化级别中解决的问题,在RR
级别中基本上已经解决了,再将MySQL
调到Serializable
级别反而会降低性能。
当然,
RR
级别下有些极端的情况,依旧会出现幻读问题,但线上100%
不会出现,先来看看各大隔离级别在MySQL
中是如何实现的。
1:RU(Read Uncommitted)读未提交级别的实现
对于RU
级别而言,从它名字上就可以看出来,该隔离级别下,一个事务可以读到其他事务未提交的数据,但同时要求解决脏写(更新覆盖)问题,那思考一下该怎么满足这个需求呢?先来看看不加锁的情况:
SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 1 | 熊猫 | 女 | 6666 | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+
-- ----------- 请按照标出的序号阅读代码!!! --------------
-- ①开启一个事务T1
begin;
-- ③修改 ID=1 的姓名为 竹子
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
-- ⑥提交T1
commit;
-- ②开启另一个事务T2
begin;
-- ④这里可以读取到T1中还未提交的 竹子 记录
SELECT * FROM `zz_users` WHERE user_id = 1;
-- ⑤T2中再次修改姓名为 黑熊
UPDATE `zz_users` SET user_name = "黑熊" WHERE user_id = 1;
-- ⑦提交T2
commit;
假设上述两个事务并发执行时,都不加锁,T2
自然可以读取到T1
修改后但还未提交的数据,但当T2
再次修改ID=1
的数据后,两个事务一起提交,此时就会出现T2
覆盖T1
的问题,这也就是脏写问题,而这个问题是不允许存在的,所以需要解决,咋解决呢?
写操作加排他锁,读操作不加锁!
还是上述的例子,当写操作加上排他锁后,T1
在修改数据时,当T2
再次尝试修改相同的数据,也要获取排他锁,因此T1、T2
两个事务的写操作会相互排斥,T2
就需要阻塞等待。但因为读操作不会加锁,因此当T2
尝试读取这条数据时,自然可以读到数据。
因为写-写会排斥,但写-读不会排斥,因此也满足了
RU
级别的要求,即可以读到未提交的数据,但是不允许出现脏写问题。
最终经过这一系列的讲解后,能够得知MySQL-RU
级别的实现原理,即写操作加排他锁,读操作不加锁!
2:RC(Read Committed)读已提交级别的实现
理解了RU
级别的实现后,再来看看RC
,RC
级别要求解决脏读问题,也就是一个事务中,不允许读另一个事务还未提交的数据,咋实现呢?
写操作加排他锁,读操作加共享锁!
这样一想,似乎好像没问题,还是以之前的例子来说,因为T1
在修改数据,所以会对ID=1
的数据加上排他锁,此时T2
想要获取共享锁读数据时,T1
的排他锁就会排斥T2
,因此T2
需要等到T1
事务结束后才能读数据。
因为
T2
需要等待T1
结束后才能读,既然T1
都结束了,那也就代表着T1
事务要么回滚了,T2
读上一个事务提交的数据;要么T1
提交了,T2
读T1
提交的数据,总之T2
读到的数据绝对是提交过的数据。
这种方式的确能解决脏读问题,但似乎也会将所有并发事务串行化,会导致MySQL
整体性能下降,因此MySQL
引入了一种技术,在每次select
查询数据时,都会生成一个ReadView
快照,然后依据这个快照去选择一个可读的数据版本。
因此对于
RC
级别的底层实现,对于写操作会加排他锁,而读操作会使用MVCC
机制。
但由于每次select
时都会生成ReadView
快照,此时就会出现下述问题:
-- ①T1事务中先读取一次 ID=1 的数据
SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 1 | 熊猫 | 女 | 6666 | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+
-- ②T2事务中修改 ID=1 的姓名为 竹子 并提交
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
commit;
-- ③T1事务中再读取一次 ID=1 的数据
SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 1 | 竹子 | 女 | 6666 | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+
复制代码
此时观察这个案例,明明是在一个事务中查询同一条数据,结果两次查询的结果并不一致,这也是所谓的不可重复读的问题。
3:RR(Repeatable Read)可重复读级别的实现
在RC
级别中,虽然解决了脏读问题,但依旧存在不可重复读问题,而RR
级别中,就是要确保一个事务中的多次读取结果一致,即解决不可重复读问题,咋解决呢?两种方案:
- ①查询时,对目标数据加上临键锁,即读操作执行时,不允许其他事务改动数据。
- ②
MVCC
机制的优化版:一个事务中只生成一次ReadView
快照。
相较于第一种方案,第二种方案显然性能会更好,因为第一种方案不允许读-写、写-读事务共存,而第二种方案则支持读写事务并行执行,咋做到的呢?其实也比较简单:
写操作加排他锁,对读操作依旧采用
MVCC
机制,但RR
级别中,一个事务中只有首次select
会生成ReadView
快照。
-- ①T1事务中先读取一次 ID=1 的数据
SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 1 | 熊猫 | 女 | 6666 | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+
-- ②T2事务中修改 ID=1 的姓名为 竹子 并提交
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
commit;
-- ③T1事务中再读取一次 ID=1 的数据
SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 1 | 竹子 | 女 | 6666 | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+
复制代码
还是以这个场景为例,在RC
级别中,会对于T1
事务的每次SELECT
都生成快照,因此当T1
第二次查询时,生成的快照中就能看到T2
修改后提交的数据。但在RR
级别中,只有首次SELECT
会生成快照,当第二次SELECT
操作出现时,依旧会基于第一次生成的快照查询,所以就能确保同一个事务中,每次看到的数据都是相同的。
也正是由于
RR
级别中,一个事务仅有首次select
会生成快照,所以不仅仅解决了不可重复读问题,还解决了幻读问题,举个例子:
-- 先查询一次用户表,看看整张表的数据
SELECT * FROM `zz_users`;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 1 | 熊猫 | 女 | 6666 | 2022-08-14 15:22:01 |
| 2 | 竹子 | 男 | 1234 | 2022-09-14 16:17:44 |
| 3 | 子竹 | 男 | 4321 | 2022-09-16 07:42:21 |
| 4 | 猫熊 | 女 | 8888 | 2022-09-27 17:22:59 |
| 9 | 黑竹 | 男 | 9999 | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+
-- ①T1事务中,先查询所有 ID>=4 的用户信息
SELECT * FROM `zz_users` WHERE user_id >= 4;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 4 | 猫熊 | 女 | 8888 | 2022-09-27 17:22:59 |
| 9 | 黑竹 | 男 | 9999 | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+
-- ②T1事务中,再将所有 ID>=4 的用户密码重置为 1111
UPDATE `zz_users` SET password = "1111" WHERE user_id >= 4;
-- ③T2事务中,插入一条 ID=6 的用户数据
INSERT INTO `zz_users` VALUES(6,"棕熊","男","7777","2022-10-02 16:21:33");
-- ④提交事务T2
commit;
-- ⑤T1事务中,再次查询所有 ID>=4 的用户信息
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time |
+---------+-----------+----------+----------+---------------------+
| 4 | 猫熊 | 女 | 1111 | 2022-09-27 17:22:59 |
| 6 | 棕熊 | 男 | 7777 | 2022-10-02 16:21:33 |
| 9 | 黑竹 | 男 | 1111 | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+
复制代码
此时会发现,明明T1
中已经将所有ID>=4
的用户密码重置为1111
了,结果改完再次查询会发现,表中依旧存在一条ID>=4
的数据:棕熊,而且密码未被重置,这似乎产生了幻觉一样。
如果是
RC
级别,因为每次select
都会生成快照,因此会出现这个幻读问题,但RR
级别中因为只有首次查询会生成ReadView
快照,因此上述案例放在RR
级别的MySQL
中,T1
看不到T2
新增的数据,因此MySQL-RR
级别也解决了幻读问题。
4:Serializable序列化级别的实现
前面已经将RU、RC、RR
三个级别的实现原理弄懂了,最后再来看看最高的Serializable
级别,在这个级别中,要求解决所有可能会因并发事务引发的问题,那怎么做呢?比较简单:
所有写操作加临键锁(具备互斥特性),所有读操作加共享锁。
由于所有写操作在执行时,都会获取临键锁,所以写-写、读-写、写-读这类并发场景都会互斥,而由于读操作加的是共享锁,因此在Serializable
级别中,只有读-读场景可以并发执行。
五:事务与锁机制原理篇总结
在本章中结合事务、锁、MVCC
机制三者的知识点,彻底理清楚了MySQL
不同隔离级别下的实现,最后做个简单的小总结:
- RU级别:读操作不加锁,写操作加排他锁。
- RC级别:读操作使用
MVCC
机制,每次SELECT
生成快照,写操作加排他锁。 - RR级别:读操作使用
MVCC
机制,首次SELECT
生成快照,写操作加临键锁。 - 序列化级别:读操作加共享锁,写操作加临键锁。
级别/场景 | 读-读 | 读-写/写-读 | 写-写 |
---|---|---|---|
RU级别 | 并行执行 | 并行执行 | 串行执行 |
RC级别 | 并行执行 | 并行执行 | 串行执行 |
RR级别 | 并行执行 | 并行执行 | 串行执行 |
序列化级别 | 并行执行 | 串行执行 | 串行执行 |