第25章 工作面试的老大难-锁
解决并发式事务带来的两种基本方式
并发的事务困难就在于不可能做到绝对的同时性,而是只能交错进行不同的操作。
于是为了防止脏操作,只能祭出最终杀招:锁!
锁看起来很高级,其实就是一种占用记录的机制,通过它让事务并发时排队进行处理
并发事务访问相同记录的情况
(1)读-读:并发的事务相继读取一个数据,这种情况对数据没有修改,因此允许这种情况发生
写-写:
并发事务相继对同一个数据进行修改,这种情况就是脏写情况,绝对不允许的。于是多个事务对一个记录进行改动的时候需要让他们排队进行,排队的过程通过锁来实现
锁是一个内存中的结构,事务对记录做出改动的时候,要先看看有没有相关的锁结构。没有的话会生成一个锁结构关联这个事务
锁结构中重要的属性:
(1)trx信息:代表这个锁结构是哪个事务生成的
(2)is-waiting:代表当前事务是否在等待
事务改动了某条记录,那么就会生成一个锁结构和这个记录相关联。如果没有其他的属性正在操作这个记录,那么这个is- waiting属性就是false的状态:意思是目前不需要排队。
那么这样这个事务操作的记录就加锁成功了,于是可以继续执行操作
后续其他事务也想对着一条记录进行操作,但是发现这个记录已经被其他的事务加锁了,于是自己的is-waiting状态为true:意思是需要排队,等前面之前的事务提交以后再轮到自己
几个关于锁的说法:
(1)不加锁:不需要内存中的锁结构就可以执行操作
(2)获取锁成功:内存中对应的锁结构is-waiting属性为false
(3)加锁失败:is- waiting属性为true
个人思考:这个加锁就是一个事务占据一个记录正在操作的形象化描述,本质并没有那么复杂,利用这个占据状态就就可以实现并发事务排队处理同一条记录,防止出现脏操作了
一句话总结:锁是干什么的?用来给多个事务排队操作一条记录用的
读-写/ 写-读的情况
一个事务进行读取,另一个事务进行修改的情况
这种情况下容易发生脏读、不可重复读、幻读的情况。不过在MySQL中还需要考虑不同的隔离级别
如何解决这些并发问题呢?
(1)利用多版本并发控制(MVCC),写操作进行加锁来控制
MVCC就是利用ReadView来匹配符合要求的历史版本。读取的时候只能读取到ReadView之前已经提交事务的结果,生成ReadView之前没有提交的事务所作出的更改是看不到的。
这里的ReadView就像是一个存档点,这个存档点之前的数据都可以访问,但是之后的数据是不可以访问到的
写操作肯定是针对最新的版本记录进行操作,MVCC在读-写的情况中并不冲突
(2)读写都采用加锁的方式
MVCC只能保证读取到最正确的数据,不保证读取最新的数据。如果一些场景需要最新的数据,那么MVCC就不合适了
此时使用都加锁的方式:读写操作都排队进行。
这是这种方式性能不高,MVCC更适合并发状态的多事务
一致性读取
使用MVCC进行事务读取的方式称之为一致性读取:一致性无锁读,或者快照读取
这种方式不会加锁,读取的时候其他的事务可以自由操作记录
锁定读取
除了读-读的情况,其他的情况并发都有可能出现问题。因此需要MVCC机制或者加锁来解决
加锁的时候为了允许读-读情况,于是给锁进行分类
共享锁:Shared Locks 简称S锁,读取记录的时候先要获取这个S锁
独占锁:排他锁:Exclusive Locks简称X锁,事务改动一条记录的时候,需要获取X锁
两种锁的使用:
对于一条记录,假设有两个事务a和b
A先获取了这个记录的S锁,那么b也可以获取S锁,ab同时持有这个记录的S锁,这也就是共享的意思
后面b想获取X锁,但是是A先访问的,必须等A扔掉它的S锁才行
如果一开始A就获取了X锁,那么B连S锁都获取不到
个人思考:这也就是前者约束了后者,如果先者获取的是S锁,那么后者最多也获取到S锁。如果前者一开始就是X锁,那么后者两种锁都不能得到
个人思考:尽管锁有了分类,但是锁的机制还是优先排队原则的:前者总是比后者有更多选择权,有更多对记录的操作权
锁定读的语句
有时候读取的时候想获取X锁,来专享一条记录,不想其他事务来读写,有时候想只加一个S锁
读取加一个S锁:
SELECT ...LOCK IN SHARE MODE;
这样允许其他事务获取这条记录的S锁,但是不能获取到X锁
对读取的记录加X锁:
SELECT ... FOR UPDATE;
这样就是独占一条记录,直接加了X锁。其他事务无法访问到这记录,直到当前记录释放掉X锁
写操作
对于写操作来说加锁就比读取更加严格
增删改操作都是写操作
DELETE:先在B+树中定位到这条记录的位置,然后获得该记录的X锁,然后软删除。定位相当于是X锁的锁定读
UPDATE:
(1)更新所占用的存储空间没有变化:则先X锁定读取到位置以后,再在原位置中进行修改
(2)如果没有修改键值,但是存储占用发生变化:先X读取定位到原位置,然后删除它:将其移动到垃圾链表。最后然后再在这个位置上插入一条新的记录。整个过程都是由X锁保护的。新插入的记录由INSERT操作提供隐式锁的保护
(3)如果修改了键值,需要DELETE再INSERT:因为位置在B+树中发生了变化,因此加锁的方式按照DELETE和INSERT的规则进行即可
INSERT:一般新插入记录不加锁,不过设计师设计了隐藏锁来保护插入,在提交事务之前不被其他事务破坏
多粒度锁
之前的锁都是针对行记录来说的,粒度比较细。锁也可以加在表中进行,称之为表级锁或者表锁。这就是一种较粗的锁
表锁也有S和X两种锁,道理和行记录中的S和X锁一样
但是这里针对的就是整个表了,事务限制级别从行记录变成了该表中的所有记录:该表的所有记录都会被上锁
但是如果想给整个表上锁的话,如果表里面有记录已经被其他事务抢先一步了,那么就排队再给表加锁了
怎么知道表中有没有记录被上锁呢?
设计师提出了一种意向锁的东西:Intention Locks。
意向锁又分为共享和独占:IS锁和IX锁
直接从字面意思理解即可:IS锁意思就是准备给某个记录加上S锁的意思:我已经预约了!!可以这样说
IX就是已经预约给某个记录加上X锁
事务使用I锁就是告知其他事务:这个表中有记录正在被我S或者X了,你们不要一上来就给这个表加锁了,要先等我操作完!
也就是给整个表挂上一个标志,表示该表中有记录正在被S或者X,那么这整个表就不可以被其他事务加锁
总结:
IS、IX是表级锁,给整个表做上标记,为了后续其他事务想给这个表加锁时提供一个快速判断的标志。这样就不用遍历来检查这个表中到底有没有记录正在被占用了
InnoDB存储引擎中的锁
InnoDB支持表锁也支持行锁。表锁比较笨拙,操作对象是整个表,一下子全部加锁,一下子又全部解锁
使用行锁精度更高,可以实现更加精准的并发控制
InnoDB中的表级锁
(1)S、X
表级别的锁给某个表加锁之前需要先等表中的记录被其他事务操作完成以后才能加
执行DDL语句(比如更新表,删除表)的时候,其他事务是操作不了这个表的。反之亦然
这个过程实际上是server层使用的一种元数据锁,一般不使用InnoDB的S或者X表级锁
只有特殊情况会用到它们
表级别的IS、IX锁
就是一个标记表目前有没有记录正在被占领的判断标志而已
表级别的AUTO-INC锁
使用表的时候可以给某个列添加AUTO-INCREMENT属性
插入记录的时候可以不指定该列的值,系统会自动给它附上递增的值
实现的原理就用到了AUTO-INC锁了:
(1)执行插入语句的时候自动加一个表级别的AUTO-INC锁,执行结束以后其他事务才能操作这个表的记录。有了这个锁可以保证每次插入的值是连续自增不会被其他事务干扰
注意这个锁的作用范围限于插入,插入结束以后自动释放,而不是等到事务提交再释放
(2)采用一个轻量级的锁,为插入自增列生成值的时候获取到这个锁,然后生成AUTO-INCREMENT列值以后再释放掉。这样就不用等待整个插入语句执行完再释放了
这种颗粒度更细,插入的时候可以避免锁定整个表从而提升性能
总结:这个AUTO-INC锁就是一个用于主键自增记录插入式的一个确保措施------确保主键自增不被干扰
InnoDB中的行级锁
行锁:直接在记录上加的锁
行锁的种类很多,不同的种类效果也不一样
Record Locks:
一个最常规的锁,也有S和X两种种类,道理和之前说的一样:先者限制后者,S控制容错,X直接限制
Gap Locks:
为了防止幻读情况时插入幻影记录,具体就是阻止给加了该锁的记录的前一段记录区间内有其他事务插入新的记录:
阻止两个记录之间的间隙插入新纪录。
但是对于最后一条记录来说,怎么阻止它后面间隙的插入呢?此时需要用到之前页结构中的伪记录来辅助:
Supremum记录,表示页面中最大记录。
于是你给这个Supremum记录加上一个gap锁就可以阻止间隙插入了
个人思考:其实防止幻读就是防止出现陌生的记录id键值,反映到表中就是防止间隙中出现不应该有的记录
Next-Key Locks:
有时候既想锁住某条记录,又想阻止其他事务在给记录前面的间隙中插入新纪录,于是就有了Next-Key的锁
相当于常规锁 + gap锁
Insert Intention Locks:
因为有了前面这么多锁,所以你插入数据之前得看看该位置有没有其他事务的gap锁之类的。如果有则需要等待,等待的时候也设计了一个锁,称之为插入意向锁
意思就是我已经排上队了!
这个意向锁并不会阻止其他事务就获取该记录上的锁,比较鸡肋
隐式锁
一般情况除了之前的插入意向锁,否则插入操作是不加锁的
不加锁就有问题了,其他事务在你插入的时候如果马上读取就会发生脏读,或者马上修改就会发生脏写。
(1)那么这个时候就的事务id上场了!
在聚簇索引记录中自带有事务id的隐藏列,其他事务对记录操作的时候首先先看看该事务隐藏列对应的事务是否处于活跃事务,是的话就帮助它创建一个X锁
然后自己进入等待状态!
天!他真的!我哭死 !哈哈哈哈💦
总之其他事务可以通过这个隐藏列来判断该记录是否处于被INSERT的状态,然后帮助其加上相应的锁
个人思考:其实隐式锁是通过隐藏列的事务id,通过其他事务发现该记录已经被占据的被动上锁的方式------由其他事务帮忙上锁,事务id只是告知一个状态
(2)不是主键值索引的话,没有隐藏列,但是有一个Page Header中的事务id属性------最新处理该页面的事务ID。如果这个最新的比你正在处理的事务id还老的话,说明除了你没有前者在操作数据了,那么你就可以上位了。否则还需要回表到聚簇索引中重复(1)的做法------你目前在二级索引中判断不了这个记录是不是正在被操作的
总结:
插入确实可以不显示加锁,但是有事务Id,相当于添加了一个隐式的锁
InnoDB锁结构
一个事务操作多个记录,如果多个记录都要加锁,那么确实很浪费了
所以设计师提出,如果满足以下的条件就可以同时重用锁:
(1)同一个事务的加锁操作
(2)加锁记录在同一个页中
(3)锁的类型相同
(4)等待状态一致
于是这些同类的锁就可以放到一个锁结构中
锁结构信息
(1)锁所在的事务信息
锁是在事务执行的时候生成的,哪个事务生成的锁,就记录哪个事务的信息
(2)索引信息
行锁需要记录加锁记录是处于哪个索引中的
(3)表锁,行锁信息
表锁记录哪个表的信息;行锁记录表空间、页号以及记录对应的比特位(通过行记录上的指定比特位来区分哪一条记录加了锁)
(4)type-mode
锁的模式,具体是通过占的比特位表示的
等等还有很多结构,具体就不详细探索了哈哈哈💦
总结:
为了性能总是要有并发事务的需求,但是数据库中的并发并不能真正做到现实生活中并发的一致性,于是需要人为添加各种机制来确保一致性:
如果是不那么严格并发:MVCC多版本并发控制可以来处理:写-读,读-写
MVCC尽量确保提供最正确的数据但是不是最新的数据给用户。具体是通过ReadView实现的,并且不同的隔离等级运用MVCC也是不同的,并且MVCC并不会影响写操作
如果是最严格的情况:写-写,这个时候就需要使用锁来进行排队处理了,这也是最符合一致性要求的模式。并且可以保证数据总是最新的,只是性能不高,因为不能并发那样同时处理多个事务,事务需要排队操作一个记录。
有时候读取的时候也要求最新版本的话,那么也会用到锁来读取
对于读-读操作来说并发交替总是安全的,所以MySQL是允许这种情况存在的,为了使得锁机制也包容这种情况,于是给锁进行了分类:S和X锁。分别对应不同的限制级别,S锁允许前者共享,X锁则是前者独享一个记录
然后还可以对颗粒读进行划分:表锁和行锁,但是表锁比较笨拙,一次性操作的是一整个表,所以表锁用的不多。行锁比较灵活,多事务并发的情况下行记录锁的灵活性适用程度更好。
一般涉及到修改操作:增删改这种处理用X行锁比较多一些。不过插入的时候一般有隐式锁来处理(事务id被动上锁)
特殊一点的锁比如gap锁,就是防止幻读产生的一种机制:防止陌生的记录id被用户读到------防止无中生有的id记录
还有意向锁:多是告知作用,并不是真正上锁的含义。
多个同类型的事务满足一定的条件就可以重复用一个锁
锁的结构也并不简单,了解一下即可
总之锁就是一种占用机制,用于事务并行处理的排队方法