想掌握数据库事务背后的并发原理,请看这15张图

Published by 搬运工 on

摘要:通过一个转账问题的场景,将数据库9种锁、3种读、4种隔离级别一次性串联起。

本文分享自华为云社区《将数据库9种锁、3种读、4种隔离级别一次性串联起来,用15张图呈现背后数据库事务背后的并发原理》,作者: breakDawn。

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

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

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

我这里举个例子:

  • 排他锁、共享锁
  • 行锁、表锁、意向锁、间隙锁、next-key锁
  • 悲观锁、乐观锁
  • 两阶段锁协议
  • LCBB锁并发控制协议、MVCC多版本控制协议
  • 脏读、不可重复读、幻读
  • RU\RC\RR\SE隔离级别

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

  1. 这一堆锁的关联关系究竟是什么?
  2. 各隔离级别究竟是怎么用各种锁+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申请的流程可以是下面这样:

  1. 判断表是否已被其他事务用表锁锁表
  2. 判断表中的每一行是否已被行锁锁住。

但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都不用任何锁, 每次都是快照读,不会阻塞,因此会变成下面这样:

总结这个图,就是

  1. 每次读时,会生成一个readView,用来记录当前还没提交的事务版本号。
  2. 根据自己事务的版本号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中不建议使用。

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

先这样吧

更多地关注造壳

Categories: Data搬运

发表评论

电子邮件地址不会被公开。 必填项已用*标注