数据库9种锁、3种读、4种隔离级别一次性串联起来,用15张图呈现背后数据库事务背后的并发原理

网友投稿 696 2022-05-29

前段时间开发时,正好遇到了2个进程同时更新一行记录时引发的bug,虽然问题最终解决了,但自己对背后的运行逻辑仍旧一头雾水。事后尝试简单翻了下各种博客资料,还有《高性能mysql》那本书时,发现大部分是将一堆八股文概念堆砌在一起,很少完整串联过这堆概念。

于是我重新完整学习了这些概念和底层原理, 通过一个转账问题的场景,将这些概念全部关联起来。

将下面这些数据库的概念单独拿出来时,相信很多人都有了解或者记忆过,但是将这些概念全部串联在一起时,可能就会很混乱。

将数据库9种锁、3种读、4种隔离级别一次性串联起来,用15张图呈现背后数据库事务背后的并发原理

我这里举个例子:

排他锁、共享锁

行锁、表锁、意向锁、间隙锁、next-key锁

悲观锁、乐观锁

两阶段锁协议

LCBB锁并发控制协议、MVCC多版本控制协议

脏读、不可重复读、幻读

RU\RC\RR\SE隔离级别

然后自己问自己一个问题:

这一堆锁的关联关系究竟是什么?

各隔离级别究竟是怎么用各种锁+MVCC来解决事务读问题的?

首先,我们完全不考虑数据库引擎、隔离级别设置之类的,就当作你用一个超简陋的儿科级别数据库来存放和更新数据。

假设你的商城服务正好在同时执行如下的2种事情

张三给穷光蛋李四转账100元。

李四尝试下单购买100元的衣服

李四在最开始余额只有0元钱。

注意因为是同时执行,在没有做任何保护的情况下,就可能会出现下图这样的情况

可以看到李四明明没有钱,却扣费了,变成了很奇怪的-100元。

Q: 那这个有问题的读过程叫什么?

A: 这个过程就叫做脏读。 即更新回退的时,另一个事务读到了脏数据,判断失误,导致做了错误的处理。

根本原因是2个事务都是先查后扣,却没有提前保护的形式

Q: 在不修改数据库隔离级别的情况下, 我们可以如何用sql语句手动解决这个脏读?

A: 那很显然就是加锁对事务过程做提前保护, 不让B去判断和扣费。

sql语句里有个 ”for update“ 语法, 会手动锁住李四那一行,在调用commit后释放

具体见下面绿色的标注部分:

Q: 刚才看到”锁住李四这一行“, 那么这个就叫行级锁。

什么情况下会变成锁住整个表?

A:

name ='李四’这句话, 如果name是索引列的话,就会加行锁

如果不是索引列, 就会变成表锁。

换言之, 行锁的本质是在索引节点上加锁

如果无法在索引节点上加锁,那就会直接变成整张表的锁,代价就会很大。

另外表锁也可以单独用lock table的语法手动加锁

Q: 如果一个事务A申请了行锁,锁住某一行, 另一个事务B申请了表锁,那B会被阻塞吗?

A:

B事务既然申请表锁,说明可能会用到A中的每一行。

B申请的流程可以是下面这样:

判断表是否已被其他事务用表锁锁表

判断表中的每一行是否已被行锁锁住。

但2这一步也太耗时了。

因此A申请行锁前,会优先申请一个意向锁,再申请行锁。

然后B申请时,第2步改成判断意向锁即可,有意向锁就阻塞。

简单点说, 意向锁就是行锁操作用来阻塞表锁用的。 但行锁和行锁之间不会互相阻塞,除非行有冲突。

刚才看到的for update会限制其他并行事务的所有读写操作,而且是2个事务上都加了”for update“。

那么这个锁就叫做”排他锁“, 属于非常强势的锁, 相当于其他读写操作马上全部拦住了。

这里使用排他锁来解决脏读的原因是因为后面有查询余额+扣余额的代码,写这段代码的人必须做提前保护,以避免自己读到一个可能被修改的数据,导致判断和修改失误。

和排他锁对应的是“共享锁”,也就是熟知的读写锁。

可以让多个事务同时读,但是不允许修改 。

手动加共享锁的方式:把for update改成 lock in share mode即可

Q: 那么什么时候使用共享锁比排他锁要好呢?

A:

可以看下面的例子:

可以看到没有查自身+更新自身的操作, 仅仅是查+更新其他表,表之间也互不关联,对余额的实时性也不是要求太高。

如果都加排他锁,各种select操作就会很慢。

但如果不加共享锁, T6这边删除时,就可能产生冗余数据,所以还是得加锁。

Q: 那我加的共享锁(S锁)和排他锁(X)什么时候释放呢?是每次执行完update马上释放吗?

A:

这里就涉及了“两阶段锁”协议。

加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请并获得X锁(排它锁,其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。

解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。

说人话, 就是在事务中需要加锁时再加锁, 直到commit完一次性解锁。

为什么要两阶段锁,看到的一句话是

若并发执行的所有事务均遵守两段锁协议,则对这些事务的任何并发调度策略都是可串行化的。

Q: 两阶段锁协议可以避免死锁吗?

A:

不能避免,但是可以通过死锁检测算法进行事务解除。

重新回到张三李四转账+下单的场景上来。

for update这种锁,其实也是一种“悲观锁” ,加锁解锁比较耗时, 默认经常发生竞争。

但如果我的转账和下单过程要求非常快,每次只有几毫秒,那加悲观锁成本就太大了

这时候就可以手动使用乐观锁, 需要你自己在余额表里增加version列,增加后如下所示:

这样就不需要特地加锁了,每次循环判断即可,前提是冲突发生概率比较低,阻塞时间比较短。

刚才一个小小的脏读,就已经解决了下面3个问题

排他锁和共享锁的区别:前者是拒绝所有读写 , 后者是允许并发读拒绝写

行锁和表锁的区别: 前者是对单行加锁 , 后者是对整表加锁, 区别是 是否涉及索引

悲观锁和乐观锁的区别: 前者主动用数据库自带的锁, 后者自己添加version版本号

外加一个两阶段锁协议

继续回到脏读问题, 前面我们学习的所有概念,都是和数据库自身隔离级别无关,使用数据库的锁语法或者version版本号来避免。

但数据库发展这么强大,怎么可能需要我们频繁自己写这种复杂逻辑,于是数据库诞生了隔离级别设置。

前面会发生脏读的隔离级别, 叫做RU(read uncommited)

即RU级别时, 我可以在别的事务没完全commit好时就读到数据。

Q: 先来个小问题,RU级别没有任何锁,对吗?

A:

错误, RU级别做update等增删改操作时,仍然会默认在事务更新操作中增加排他锁,避免update冲突。

切记脏读的发生原因,是查询+更新+回滚时没加锁导致其他查询操作出现失误判断。

即查询这块可能读到没提交的数据,导致错误,而不是更新的并发问题。

Q: 当我们的数据库被设置成RC级别(Read commited)时, 可以解决脏读, 那么背后是怎么解决的呢?

A:

业界有两种方式

LBCC基于锁的并发控制(Lock-Based Concurrency Control))

MVCC基于多版本的并发控制协议(Multi-Version Concurrency Control)

LBCC其实就是类似前面手动用悲观锁的方式, 事务操作中查询时默认试图加锁,因此就可能被update的排他锁阻塞住,避免了脏读。

但代价就是效率很低。很多场景下,select的次数是远大于update的。

所以InnoDb 基于乐观锁的概念, 想了一个MVCC,自己在事务的背后实现了一套类似乐观锁的机制来处理这种情况。 确保了尽可能不在读操作上加锁, 排他锁只对更新操作生效。

Q: MVCC究竟是怎么做的呢?

A:

简单来说,就是默认给每个数据行加了一个版本号列TRX_ID和回滚版本链ROLL_BT,具体可以看《高性能mysql》书里的这段描述:

简而言之

查的时候,只查当前事务之前的记录,或者回滚版本比当前大的已删记录。

增的时候,加新版本的记录

删的时候,把老记录标记上回滚版本

改的时候,本质上是加新记录, 同时把老记录标上回滚版本

Q: MVCC机制下, 什么是快照读,什么是当前读?

A:

快照读:对于select读操作,统一默认不加锁,使用历史版本数据。

当前读:对于insert、update、delete操作,仍然需要加X锁,因为涉及了数据变更,必须使用最新数据进行修改

Q: 那么回到刚才的脏读问题, MVCC究竟是怎么在读不加锁的情况下, 解决脏读的?

A:

首先,每次select都不用任何锁, 每次都是快照读,不会阻塞,因此会变成下面这样:

总结这个图,就是

每次读时,会生成一个readView,用来记录当前还没提交的事务版本号。

根据自己事务的版本号version,去寻找小于自己当前版本且不在readView集合中的记录。

这样的话就保证了读的数据必须是已经完成提交的,是不是很简单?

Q: 如果事务B中不做余额判断,支持直接赊账+扣费, 那是不是会导致先扣费,然后回滚成0这样的情况?

A:

不会。

上面提过, MVCC中更新操作都是“当前读”,仍然需要加X锁, 且因为涉及了数据变更,必须使用最新数据版本进行修改

换言之, update等操作, 还是会加锁,且用最新版本更新,避免了脏更新的问题,如下:

Q: 上面这个过程有什么隐患

A:

如果1个事务中连续读2次余额,可能有“不可重复读”的风险,即前后读的数据发生了不一致

如下所示

因此RC隔离级别无法解决 “不可重复读的问题”

Q: RR(可重复读,Repeat Read)的隔离级别又是怎么解决上面这个问题的?

A:

本质上就是readView生成时的区别

上面RC不可重复读的图中可以看到,每次读时,都取了最新的readView。 这可能导致事务A提交后, 事务B观察到的readView集合发生了变化。

因此RR机制改变了readView的生成方式, 每次读时只使用事务B最开始拿到的那个readView,这样永远就只取老的数据了。

Q: 那读问题中的幻读又是什么?

A:

刚才的”不可重复读“,是一个事务中查询2次结果,发现值对不上。

而”幻读“,是指一个事务中查询2批结果,发现这2批数量对不上,就好象发生了幻觉。

就像下图所示展示:

Q: RR隔离级别中的MVCC机制可以解决上面的问题吗?

A:

可以解决。

通过查询的快照读,能够保证只查询到同一批数据。

Q: 那如果像下面这样, 事务A连续做两次更新呢,单纯靠MVCC能避免更新操作的幻读么?

A:

如果只依靠MVCC,那就无法避免了, 因为update操作是”当前读“,每次取最新版本做更新, 这会导致update中的读操作出现幻读,前后更新的记录数量不一样了。

Q: 那数据库怎么处理这种2次updete中间做insert的幻读情况呢?

A:

之前有了解到, update过程仍然会加锁,

RR级别会启用一个叫”间隙锁“(Gap锁)的玩意,专门来防这样情况。

即调用 update xxx where name ='李四’时, 不仅仅在李四的行上加锁, 更会在中间所有行的间隙、左右边界的两边,加上一个gap间隙锁,就像下面这个图一样:

可以看到,订单D的插入过程被update过程的间隙锁拦住了,于是无法插入,置到事务结束才会释放。

因此事务中两次update之间的幻读是可以避免的,也能。

Q: 那行锁、间隙锁、next-key锁是什么区别?

A:

行锁就是单个行(单个索引节点)加锁

间隙锁就是在行(索引节点之间)加锁

next-key就是“行锁+间隙锁”,一起使用。

Q: 如果name这个字段不是索引,而是普通字段,那间隙锁会怎么加?

A:

那就会给整个表的所有间隙都加上锁!

因为数据库无法确认到底是哪个范围,所以干脆全加上。

这就会导致整表锁住,性能很差。

Q: 那是不是只要name是索引,就不会给整个表全加间隙锁了?

A:

不对, 如果where条件写的有问题,不符合最左匹配原则,那也会导致索引失效, 以至于给整个表加锁。

Q: 刚才看到说RR可以解决2次select之间的幻读, 也能解决2次update之间的幻读, 那为什么很多资料里,仍然说RR不能解决幻读?

A:

这个问题我也是翻了好多资料, 终于找到了一个合理的解释。

看下面这个场景:

发现什么区别没, 事务B的insert操作,发生在了事务A的update之前。因此事务B的insert操作没有被间隙锁阻塞。

而update用的是当前读, 于是更新的数量和 最初select的数量匹配不上了。

Mysql官方给出的幻读解释是:只要在一个事务中,第二次select多出了row就算幻读,所以这个场景下,算出现幻读了。

这也就是下面这个图的来源:

Q: 那串行化serializable隔离级别,为什么就能避免幻读了?

A:

Se级别时,会从MVCC并发控制退化为基于锁的并发控制(LCBB)。

不区别快照读和当前读

所有的读操作都是当前读,读加读锁(S锁),写加写锁(X锁)。在该隔离级别下,读写冲突,因此并发性能急剧下降,在MySQL/InnoDB中不建议使用。

这就是我们文章最开头手动加锁的那个过程了。

EI企业智能 MySQL 可信智能计算服务 TICS 数据库 智能数据

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:从Postman到ApiPost——码农闰土
下一篇:数字化转型正当时,云视频会议成为高效沟通协作利器
相关文章