mysql事务隔离级别实现原理全面解读?
本文目录
2022-10-17 修正版~
修正内容(工作后修正的内容):
- 可重复度没有解决幻读,只是极大程度避免了快照读下会发生的幻读情况
- 增加 Mvcc Readview、版本控制链条内容的诠释,帮助读者更好的理解可重复度与幻读的关系
- 增加实验,验证RR隔离级别下,幻读还是有可能发生的,以及诠释发生的原因
2023-02-16 新增~
更新点:结合自己阅读源码的经验,新增面试专栏,后续会一直更新,如果回答的造成了误解,望斧正。看到并采纳会及时修成。
夺命6连call,本文高能全是干货!!!!
- 天天用事务可是你知道事务是怎么来实现的吗?
- 什么叫做事务?事务是什么?
- mysql中的锁了解嘛?mysql中有哪些锁?mysql中的锁和事务有什么联系?
- 事务有什么特性呢?事务隔离级别有哪几种?不同事务隔离级别解决了什么问题?
- spring中的事务与mysql中的事务有什么区别?
- 开启事务的方式有哪几种?
莫慌本文一一来探究~~~~~来自大三时候码的文章
什么叫做事务?事务是什么?
- 事务:
- java硬编码角度:保证代码片段伪原子性执行的一种手段
- sql角度:保证sql语句伪原子执行的一种手段
事务有什么特性?(基本概念不过多解读)
- 原子性、持久性、一致性、 隔离性
事务隔离级别有哪些?(基本概念不过多解读)
- 读已经提交、读未提交、可重复读(mysql默认隔离级别)、串行化
不同事务隔离级别解决了什么问题?(先搞清楚有什么问题!下文着重有研究)
- 事务能解决的问题
- 脏读:一个事务读取到另一个事务未提交的数据
- 幻读:情况一:开启一个事务,第一次查 name =1 的数据查不到,第二次查到了。情况二:开启一个事务:第一次查 name =1 的数据只有一条,第二次查到了多条。情况三:开启一个事务:第一次查所有数据只有一条,第二次查到了一条以上的数据
- 不可重复读:一个事务内,对于同一条数据的读取,读取 内容 前后不一致(mvcc解决了)
- 读未提交:啥问题都没解决,不愧是地主家的傻儿子啊💩
- 读已提交:解决了脏读
- 可重复读:极大的避免了 当前读 下幻读的发生、解决了脏读、不可重复读
- 串行化:解决了幻读、脏读、不可重复读
可能很多读者很疑惑,到底可重复度解决了幻读还是没解决呢?我在刚入坑mysql的时候也为此疑惑过,毕竟当初也是看着网上的教程一步步走过来的(网上的资源鱼龙混杂)。要搞明白这个问题首先就要知道什么是幻读?如上事务能解决的问题二
为什么说可重复读隔离级别下极大的避免当前读下幻读的发生、解决了不可重复读呢?(严谨点来说是这一切都是针对mysql下的innodb存储引擎来说的)
解决不可重复读演示
先后开启事务一、事务二,事务一查询 id 为 3 的数据,查到结果如下图一,然后切换事务二查 id 为 3 的数据,查到的结果和下图一一致,紧接着事务一执行下图一中的更新操作,并且提交事务一,事务二接着查 id 为 3 的数据,查到的结果依然和下图一一致。
事务一
事务二
- 剖析上述图片是如何:解决不可重复读的:
- 可重复读隔离级别是基于MVCC(版本控制链)解决了不可重复读的问题,而所谓的MVCC即版本控制链,可以这么来理解这个版本控制链:某条数据的各个历史版本的链条,且内部根据修改时间排序。
- 可重复读只会在第一次 select 的时候生成一个 Readview,后续的快照读,都要做判断,是否满足 Readview 可见性,满足才读取反之不读取,这其中的Readview 算法保证了可重复读,解决了不可重复读的问题
不知道读者思考过:快照读、拍快照 Readview 是个什么东西?其实不管是什么高深抽象的名字,在开发上一定都是有他自己的一套运作逻辑的,其实 Readview 就是一个类,里面装载了:Readview 生成时刻的活跃事务id列表、Readview 生成时刻的活跃事务最小id、Readview 生成时刻的已出现过的事务id+1、生成 Readview 的事务id,然后根据这些条件,去版本控制链上依次去做条件判断,找出当前事务可见的数据
Readview可见性算法
先来说下版本控制链可以比作是一个 List ,由不同事务按照先后顺序修改同一条数据形成的记录依次链接组成,而且每一条记录都有一个事务id对应,表明此记录是哪个事务修改的。
LinkedList<Object>
版本控制链 里面装载了一个个相同 id 的行数据(内含了一些隐藏列,譬如事务 id 等),当我们开启事务第一次快照读:查某个 id 或者 id 区间的数据的时候,就根据 Readview 做条件判断然后从版本控制链中取得对当前事务可见得数据,逻辑如下
1:获取版本控制链条中最新数据的事务id,与 Readview 中最小活跃事务 id 逐个进行比较,如果小于,则这条数据对当前事务可见。
2:如果大于等于,会接着与 Readview 中的出现过的最大事务 id+1 进行比较,如果大于等于,那么这条数据对当前事务不可见(说明生成 Readview 之前是没有这个版本的数据的),如果小于的话,判断 Readview 中最新的这条数据有无 commit(Readview 中维护着一个活跃事务 id 的数组(排自己),活跃事务数据包含此事务 id就表示未 commit,反之 commit),commit 了的就是可见,没提交就是不可见。
规避幻读的演示及解读
表初始数据
事务一
start TRANSACTION;
INSERT INTO `xiaomi`.`goods` (`id`, `name`, `price`, `stock`, `goods_type_id`, `remark`, `version`) VALUES (3, '3', 3, 3, 3, '3', 3);
UPDATE `xiaomi`.`goods` SET `name` = '2', `price` = 2, `stock` = 2, `goods_type_id` = 2, `remark` = '2', `version` = 1 WHERE `id` = 1;
commit;
事务二
start TRANSACTION;
SELECT * from goods;
commit;
先后开启事务一、事务二,事务一中可以查到的数据(id=1),事务二可以查到的数据(id=1) ,然后事务一更新 id =1 的数据,且插入 id =3 的数据,并且提交事务,事务二接着查数据,执行多少遍结果都是只能查到(id=1)的数据。根本原因就是 快照读在第一次 select 的时候会生成一个 Readview 如下:
- 活跃事务id列表:1
- 活跃事务最小id:1
- 生成 Readview 事务id:2
- 出现过的最大事务id+1:2+1=3
直到事务一 commit 然后有如下版本控制链如下:
id为1的版本控制链条
id为3的版本控制链条
查询id为1的数据,Readview会做出如下判断:
- 最新数据事务id:1 >= Readview 中最小活跃事务id:1
- 最新数据事务id:1 <= Readview 中最大事务id+1:2+1=3
- Readview 中活跃事务id数组:1 包含最新数据事务id:1(说明在事务二在生成 Readview 的时候,最新的这条数据还未 commit,为了防止幻读,最新的这条数据此时对事务二就不可见)
特殊情况下发生的幻读
表初始数据
事务一
start TRANSACTION;
INSERT INTO `xiaomi`.`goods` (`id`, `name`, `price`, `stock`, `goods_type_id`, `remark`, `version`) VALUES (3, '3', 3, 3, 3, '3', 3);
commit;
事务二
start TRANSACTION;
SELECT * from goods;
UPDATE `xiaomi`.`goods` SET `name` = '4', `price` = 4, `stock` = 4, `goods_type_id` = 4, `remark` = '4', `version` = 1 WHERE `id` = 3;
commit;
先后开启事务一、事务二,事务二查所有数据,查到只有id =1 的数据,并且生成 Readview,然后事务一插入id = 3的数据并且commit,事务二预判了你的预判,更新id = 3 的数据,然后再次查数据可以查到 id = 3 的数据了。
此时的版本控制链为:根据 Readview 可见性算法,不难计算出结论,id=3 的数据此时事务二也是可以查到的
探究完了幻读,顺便提一嘴mysql中的锁机制,mysql中的锁有很多大体如下:
innodb使用的锁类型:
- 意向锁是一把表级锁
- 意向排他锁:IX
- 作用:获取X锁之前先尝试获取IX锁,获取成功才能获取X锁
- 意向共享锁:IS
- 作用:获取X锁之前先尝试获取IS锁或者更强的锁,获取成功才能获取S锁
-
读锁(S):lock in share mode
- 代码:select * from user where id = 1 lock in share mode;
- 解释:锁定id = 1的这一行数据,在锁释放之前,别人不能修改id = 1 的这行数据,但是能读取这行数据
-
写锁(X):for update (根据使用场景可以细分如下)
- 记录锁(record lock):select * from user where id = 1 for update;锁定id = 1这行数据,阻止别人修改、删除更新此数据行。效果相当于给行上了一把锁,因此有人称之为行锁!!!!!
-
- 误区:网上老有人说什么mysql有行锁,有个鸡儿行锁,还有说mysql索引是b树,b个鸡儿的树,明明是b树的改造版,mysql只是遵从规范那样定义的!还是看官网靠谱啊。官网定义如下
- 误区:网上老有人说什么mysql有行锁,有个鸡儿行锁,还有说mysql索引是b树,b个鸡儿的树,明明是b树的改造版,mysql只是遵从规范那样定义的!还是看官网靠谱啊。官网定义如下
- 间隙锁(GAP):锁定数据之间的间隙(针对于查询加了非唯一索引的列for update,间隙锁锁住范围下文详解)
- next-key lock: 记录锁(相当于行锁)+间隙锁的组合
- 插入意图锁(Insert Intention Locks)
-
- 本质:插入数据行位置的一把间隙锁
-
- insert语句执行原理分析:获取插入数据行安放位置的Insert Intention Locks,接着设置插入数据行的X锁
官网的原话,我用浏览器翻译了一遍。出处链接
select * from user where id = 1;快照读可以读取任何加了锁的数据
延伸出来的锁称呼
- 乐观锁:查询使用普通sql、更新加版本号条件判断是否能更新(表层面实现控制并发,可以理解为轻量级锁)
- 表锁:锁定整个表的数据(针对于未加索引的列for update,此时锁住整个表)
- 悲观锁:在sql层面,查询使用for update(sql层面限制,可以理解为一把重量级锁)
- 行锁:X,REC_NOT_GAP,本质是记录锁
读锁演示:
行锁演示:
间隙锁演示
数据表user:id:主键、name:普通索引、age:未加索引的普通字段
开启事务一执行如下sql:
开启事务二执行如下sql:
我们发现了一个很有意思的现象,就是name在[c,g)的数据都被锁住了插入不进去了,这就是避免发生幻读的本质啊,要充分理解为什么mysql这么设计,必须要有对索引有一定的了解才行,不了解mysql索引的读者可以看这篇文章精通mysql索引,我这里还是多补充几个概念吧:
- 当前读:读加锁,delete、update、insert都属于当前读~
- 快照读:读不加锁,普通的select操作
我用b+树生成工具依据name索引构建了如下一颗b+树(b+树实际上不是这样的,mysql的b+树叶子节点是双向链表,这也是一个坑!)
首先我们要知道b+树的一大特性就是排好序的,那么无论我们插入多少条name = "e"的数据,这个辅助索引上的e的区间永远介于c到g之间
- 幻读和锁住[c,g)区间的元素有什么关系?
- select * from user where name = “e” for update;
- 当有多条name = "e"的数据存在的时候,这多条数据必然是存在 c于g之间的,你说为啥锁住这些地方呢?还不是为了防止幻读吗!让你插都插不进去
- 为什么锁住的区间是[c,g)而不是(c,g)、(c,g]、[c,g]呢?
- 其实这个和我们创建的索引有关,mysql创建索引默认是ASC(升序)
- 证明:下文详解
- 很大程度上避免发生幻读是利用了什么类型的锁?
- 锁了间隙又锁住了行,依靠next-key lock极大程度上避免了幻读的发生
摘抄官网的原话:幻读的避免依靠next-key lock实现的
间隙锁区间详解
我现在喜欢把知识学透学精,知其然知其所以然感觉更有味道!夺命call来袭
- 间隙锁是加在谁身上?
- 查看锁信息(mysql8):select * from performance_schema.data_locks;
- 执行select name,age from user where name = “e” for update;加锁情况如下
- g加上GAP、X组合锁,e数据对应的主键id被加上了X、X,REC_NOT_GAP锁、e被加上X锁、整个表加上IX锁
现阶段的我还不能完全理解LOCK_MODE中的组合锁是啥意思,贴一段我阅读官方文档以及实操的心得吧
- X:锁行,可以理解为行锁
- X,REC_NOT_GAP:锁行不包括间隙,可以理解为作用与主键的行锁
- X,GAP:锁行以及间隙 、可以理解为next-key lock
- X,GAP对应的(‘g’, 187):说明g之前存在间隙锁、行锁,g只是一个上限值
- 什么时候加间隙锁?什么时候加行锁?什么时候加表锁?
- 当前读使用的是普通索引查询,那么加间隙锁
- 当前读使用的是主键索引、唯一索引查询,那么加行锁
- 当前读使用压根就没有索引的字段查询,锁表
- 为什么主键加的是X,REC_NOT_GAP锁?
- 主键索引具有唯一性,你总不能插入俩条主键相同的数据入库吧,主键查询压根就不会出现幻读的问题产生好吧。
- 加行锁是为了别的事务修改此条数据,造成同一事务俩次当前读,读取到同一条数据内容不一致的情况出现。
- X,REC_NOT_GAP:个人觉着可以理解为就是行锁
- 为什么整个表加一把意向排他锁(IX)?
- 加意向排他锁(IX)的目的在于解决行锁与表锁的冲突问题的
- 当事务一向一个表中的某一行数据加上一把行锁,事务二想往该表加一个表锁,那么此时表锁是加入不进去的。事务二在进行加表锁之前会先判断表是否有IX锁,如果有那么表示此表有数据已经上了行锁,需要等待行锁释放,表锁才能添加成功。IX的好处就是不用在逐行的去扫描到底表中是否有添加了行锁的记录。
是不是现在满脑子问号呢?你们最最最最想知道的幻读是怎么解决的来来了😬,为什么锁住的区间是[c,g)呢?开始步入正题。我们要防止c、g之间插入e,很简单把c到e之间的间隙锁住、以及把中间的e数据加一把记录锁就好了。现在的问题就是能插入g,而不能插入c?
- 原因很简单:建索引的时候默认是ASC(升序排序)的,如果可以插入c数据,那么必然插入的c数据位置介于c - g之间,插入成功我们把c数据改成e,那么c - g这个区间不就是有俩条e数据了吗。而插入g,插入的位置在c - g这个范围之外
- 那么还有人要问了:我插入g,接着修改g为e不行吗?
- 小伙汁你很优秀啊very good这个问题,而这又涉及到update一条语句的加锁过程了
- update操作加锁过程?
- 如果修改前后此行数据前后存储空间发生了改变,那么会先添加X锁给记录,接着删除记录,然后insert新记录,这个记录还不是要insert?insert到哪?还不是添加到c - g这个间隙嘛!所以说update g改成e压根就不会执行成功,这些间隙都被锁住了,压根insert不进去呀
如果建立索引是DESC那么锁住的间隙,用区间表示就是(c,g]。
InnoDB中由不同SQL语句设置的锁:
https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html
mysql幻读官方解释:
https://dev.mysql.com/doc/refman/8.0/en/innodb-next-key-locking.html
mysql锁官方解释:
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html
mysql data_lock表官方解释:
https://dev.mysql.com/doc/refman/8.0/en/performance-schema-data-locks-table.html
思考
什么情况下,可重复度隔离级别会发生幻读呢?后续更新~~~~~
面试专栏
可重复读与读已提交本质区别是什么?
可重复读只会在事务开启后的第一次 select 的时候生成 readview ,而 读已提交在事务开启后的每次 select 都会生成最新的一个 readview ,同时由于遵循 readview 可见性算法,未提交的数据 ,在读已提交隔离级别下是读取不到的。
隔离级别的作用?(对四大隔离级别作用一一阐述一下就好了)
为了防止幻读、脏读、不可重复读的这些情况的发生,因此产生了隔离级别其中
- 读未提交:啥都没解决
- 读已提交:只能读取另一个事务已提交的数据,杜绝了脏读
- 可重复读:极大的避免了幻读、杜绝了脏读、不可重复读
- 串行化:解决了幻读、脏读、不可重复读
----------其他关于事务的面试问题,欢迎大家留言--------
小咸鱼的技术窝
关注不迷路,日后分享更多技术干货,B站、CSDN、微信公众号同名,名称都是(小咸鱼的技术窝)更多详情在主页